diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 28fed31f..01aeff26 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -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 ( +
+

+ Мера оптимизирована для десктопа +

+

+ Откройте оценку на компьютере или расширьте окно браузера до 1024 px + и шире — на узком экране интерфейс отчёта нечитаем. +

+
+ ); + } + return (
))} + {/* WCAG 2.1.1 (#4): the chart's "open archived listings" affordance + was a mouse-only . 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. */} 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) => ( @@ -512,10 +535,15 @@ export default function AnalyticsView({ >
- Цена × срок продажи + Цена × срок на рынке
- Зависимость цены от срока экспозиции · {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}
@@ -582,9 +610,9 @@ export default function AnalyticsView({ style={{ width: "100%", height: 260, marginTop: 12 }} preserveAspectRatio="none" role="img" - aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект" + aria-label="Точечный график зависимости цены от срока лота на рынке: аналоги, сделки и ваш объект" > - Цена × срок продажи + Цена × срок на рынке @@ -680,7 +708,7 @@ export default function AnalyticsView({ fill={tokens.muted2} textAnchor="middle" > - СРОК ПРОДАЖИ, ДН + СРОК НА РЫНКЕ, ДН 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, }, {