Merge pull request 'fix(tradein/v2): honesty + a11y — histogram outlier-guard, scatter-релейбл, mobile-notice, keyboard-nav (R2)' (#2499) from fix/tradein-frontend-honesty into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-12 19:42:15 +00:00
commit c3d0f86f37
3 changed files with 97 additions and 9 deletions

View file

@ -448,10 +448,17 @@ export default function TradeInV2Page() {
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
// inside the scaled artboard — smaller on phones, but no longer overflowing.
const [isMobile, setIsMobile] = useState(false);
// Below this width the fixed 1536px artboard scales down to an unreadable
// ~25% and there is no responsive layout yet. Rather than ship that broken
// scaled-down HUD, render an honest "open on desktop" notice instead (see the
// early return below). Starts false so SSR + first client render match the
// desktop tree; the effect flips it post-hydration → no hydration mismatch.
const [isSmallViewport, setIsSmallViewport] = useState(false);
useEffect(() => {
const compute = () => {
setArtboardScale(Math.min(1, window.innerWidth / 1536));
setIsMobile(window.innerWidth < 768);
setIsSmallViewport(window.innerWidth < 1024);
};
compute();
window.addEventListener("resize", compute);
@ -766,6 +773,48 @@ export default function TradeInV2Page() {
statusMessage = "Оценка готова";
}
// Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
// layout — under ~1024px it scales to an unreadable ~25%. Show an honest
// desktop-only notice instead of the broken scaled-down copy. Placed after all
// hooks so the early return never changes hook order. Desktop (≥1024) is
// untouched.
if (isSmallViewport) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "40px 24px",
boxSizing: "border-box",
gap: 14,
fontFamily: tokens.font.sans,
color: tokens.ink,
background: tokens.gradientBg,
}}
>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
Мера оптимизирована для десктопа
</h1>
<p
style={{
fontSize: 14,
lineHeight: 1.6,
margin: 0,
maxWidth: 360,
color: tokens.body,
}}
>
Откройте оценку на компьютере или расширьте окно браузера до 1024&nbsp;px
и шире на узком экране интерфейс отчёта нечитаем.
</p>
</div>
);
}
return (
<div
style={{

View file

@ -460,12 +460,35 @@ export default function AnalyticsView({
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
{/* WCAG 2.1.1 (#4): the chart's "open archived listings" affordance
was a mouse-only <g onClick>. Expose it as a real focusable
button (role + tabIndex + Enter/Space) so keyboard/SR users can
open the «Аналогичные объявления» overlay too the transparent
hover circles stay as the pointer target. Only interactive when
there are points to click (no empty tab stop on a data-less
chart). onFocus/onBlur mirror hover so the hint tooltip shows on
keyboard focus as well. */}
<g
fill="transparent"
role={hoverPts.length > 0 ? "button" : undefined}
tabIndex={hoverPts.length > 0 ? 0 : undefined}
aria-label={
hoverPts.length > 0
? "Открыть архивные объявления этого дома"
: undefined
}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoverHist(true)}
onMouseLeave={() => setHoverHist(false)}
onFocus={() => setHoverHist(true)}
onBlur={() => setHoverHist(false)}
onClick={() => onNavigate?.(2)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNavigate?.(2);
}
}}
>
{hoverPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={13} />
@ -512,10 +535,15 @@ export default function AnalyticsView({
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Цена × срок продажи
Цена × срок на рынке
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
{/* Honesty (#2): active analogs carry «дней на рынке так далеко»
(today listing_date, нижняя граница), not realized
time-to-sell the axis reads «срок на рынке», not «срок
продажи», so an ещё-продающийся лот isn't shown as a
measured sale time. */}
Цена в зависимости от срока на рынке · {data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
@ -582,9 +610,9 @@ export default function AnalyticsView({
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
aria-label="Точечный график зависимости цены от срока лота на рынке: аналоги, сделки и ваш объект"
>
<title>Цена × срок продажи</title>
<title>Цена × срок на рынке</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} />
@ -680,7 +708,7 @@ export default function AnalyticsView({
fill={tokens.muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
СРОК НА РЫНКЕ, ДН
</text>
<text
x={18}

View file

@ -616,7 +616,10 @@ function resolveDealTier(
hiRub: sd.range_high_rub,
medianPpm: sd.median_price_per_m2,
count: sd.count,
bars: bins8(sd.deals.map((d) => d.price_per_m2)),
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
};
}
const area = e.area_m2;
@ -628,7 +631,10 @@ function resolveDealTier(
hiRub: c.high_ppm2 * area,
medianPpm: c.median_ppm2,
count: c.count,
bars: bins8(e.actual_deals.map((d) => d.price_per_m2)),
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
),
};
}
if (e.actual_deals.length > 0) {
@ -643,7 +649,8 @@ function resolveDealTier(
hiRub: Math.max(...prices),
medianPpm: medPpm,
count: e.actual_deals.length,
bars: bins8(ppms),
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(guardPriceOutliers(ppms).clean),
};
}
return null;
@ -1016,7 +1023,11 @@ export function mapResultPanel(
unit: "млн ₽",
range: rangeLine(e.range_low_rub, e.range_high_rub),
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
// Fix #1/#8: bin over the SAME outlier-guarded pool the spread/CV uses
// (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
// 872k выброс this card already reports as "исключён", squashing the real
// analogs into the left bins while the number says the outlier is dropped.
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
nav: 2,
},
{