"use client"; // Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design. // Accepts one composite `data` prop (default = the design fixture); page.tsx // feeds mapAnalytics(...) output. The price-history dots navigate to the // "Аналогичные объявления" overlay via onNavigate?.(2). import { useMemo, useState } from "react"; import { tokens } from "./tokens"; import { analytics as ANALYTICS_FIXTURE } from "./fixtures"; import { phYearX as histYearX } from "./mappers"; import type { Analytics, SellTimeTier } from "./types"; interface AnalyticsViewProps { data?: Analytics; onNavigate?: (i: number) => void; } // ---- price-history geometry ----------------------------------------------- // Parse the SVG polyline point strings into coordinates so the markers and // the transparent hover targets can be derived from the same source. Empty // series (graceful-degradation case) parse to [] — no dots, no hover targets. interface Pt { x: number; y: number; } function parsePoints(s: string): Pt[] { return s .trim() .split(/\s+/) .filter((pair) => pair.length > 0) .map((pair) => { const [x, y] = pair.split(",").map(Number); return { x, y }; }); } // M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number // of years, using the SAME geometry the mapper uses for the polyline vertices // (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from // ./mappers (single source of truth) rather than copy-pasted, so the two never // drift apart. A fixed 5-slot array broke whenever the real series had ≠5 // years: the extra labels fell back to an undefined x → x=0, so a late year // (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits // centred under its own vertex. // ---- sell-time tier tints -------------------------------------------------- // One-off decorative tints per variant (design lines 553-556). The soft border // hex (#bfe3d2 / #e3d2ad / #e6c3c3) and the rgba background fills are documented // one-off design tints with no token equivalent; the label text colour reuses // semantic tokens (success / accent / warn / danger). const tierStyles: Record< SellTimeTier["variant"], { bg: string; border: string; label: string } > = { success: { bg: "rgba(27,170,107,.07)", border: "1px solid #bfe3d2", label: tokens.success, }, accent: { bg: "rgba(46,139,255,.08)", border: `1.5px solid ${tokens.accent}`, label: tokens.accent, }, gold: { bg: "rgba(201,154,62,.09)", border: "1px solid #e3d2ad", label: tokens.warn, }, danger: { bg: "rgba(214,90,90,.07)", border: "1px solid #e6c3c3", label: tokens.danger, }, }; const cardStyle = { background: tokens.surface.w55, border: `1px solid ${tokens.line2}`, borderRadius: 8, padding: "16px 18px", } as const; // L1 — a "обычно X–Y дн" band is only honest with ≥2 lots AND a non-degenerate // span. From a single analog (n<2) or when min===max ("237–237") the band is a // fake range, so we suppress it and keep just the point estimate (tier.days) + // the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the // parsed range endpoints, so either signal alone drops the band. function sellTimeRangeMeaningful(tier: SellTimeTier): boolean { if (!tier.range || tier.range === "—") return false; const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1 if (Number.isFinite(n) && n < 2) return false; const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18–180" → [18,180] if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false; return true; } // M6 — «Срок продажи в зависимости от цены» is not a real trend when built on // thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн // median) off just 3–6 analogs, i.e. noise read as a signal. We therefore GATE // the chart honestly instead of faking monotonicity: // • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust); // • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced // by a «мало данных» note (nothing meaningful left to compare). const SELLTIME_MIN_N = 5; const SELLTIME_MIN_TILES = 3; /** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */ function sellTimeTierN(tier: SellTimeTier): number { const n = Number.parseInt(tier.count, 10); return Number.isFinite(n) ? n : 0; } /** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */ function sellTimeTierValid(tier: SellTimeTier): boolean { return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N; } export default function AnalyticsView({ data = ANALYTICS_FIXTURE, onNavigate, }: AnalyticsViewProps) { const [hoverHist, setHoverHist] = useState(false); // M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs). const sellTimeTiles = useMemo( () => data.sellTime.filter(sellTimeTierValid), [data.sellTime], ); // Price-history series + hover targets, derived from the data prop. Яндекс // only draws dots where its series diverges from Avito (последние 3 года). const avitoPts = useMemo( () => parsePoints(data.priceHistory.avito), [data.priceHistory.avito], ); const yandexPts = useMemo( () => parsePoints(data.priceHistory.yandex), [data.priceHistory.yandex], ); const yandexDots = useMemo( () => yandexPts.filter( (p, i) => !avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y, ), [yandexPts, avitoPts], ); const hoverPts = useMemo( () => [...avitoPts, ...yandexDots], [avitoPts, yandexDots], ); // L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any // series that drew no points so the subtitle matches the rendered legend (the // swatches below are already gated on *Pts.length). If the note format changes // the replace is a graceful no-op. const activeSeriesNames = [ avitoPts.length > 0 ? "Avito" : null, yandexPts.length > 0 ? "Яндекс" : null, ].filter((s): s is string => s != null); const historyNote = activeSeriesNames.length > 0 ? data.priceHistory.note.replace( /Avito \+ Яндекс/, activeSeriesNames.join(" + "), ) : data.priceHistory.note; return (
{/* header */}
Аналитика дома
{data.header.ads} · {data.header.radius}
{/* KPI row */}
{data.kpis.map((kpi, i) => (
{kpi.label}
{kpi.value} {kpi.unit ? ( <> {" "} {kpi.unit} ) : null}
{kpi.sub}
))}
{/* срок продажи в зависимости от цены */}
Срок продажи в зависимости от цены
{data.sellTimeNote}
{sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
{sellTimeTiles.map((tier, i) => { const v = tierStyles[tier.variant]; return (
{tier.tier}
{tier.days}
{sellTimeRangeMeaningful(tier) ? ( <> {tier.range}
) : null} {tier.count}
); })}
) : (
Мало данных для оценки чувствительности срока продажи к цене.
)}
{/* история цен */}
История цен в этом доме
{historyNote}
{avitoPts.length > 0 ? ( Avito ) : null} {yandexPts.length > 0 ? ( Яндекс ) : null}
История цен в этом доме — медиана ₽/м² по годам {data.priceHistory.yTicks.map((t) => ( {t.label} ))} {data.priceHistory.years.map((yr, i) => ( {yr} ))} {/* white dot fill on an accent ring — documented one-off, no token (design l.568) */} Архивные объявления Avito · нажмите, чтобы открыть {avitoPts.map((p, i) => ( ))} {yandexDots.map((p, i) => ( ))} setHoverHist(true)} onMouseLeave={() => setHoverHist(false)} onClick={() => onNavigate?.(2)} > {hoverPts.map((p, i) => ( ))} {hoverHist ? ( // Dark navy tooltip surface + white text — documented one-off literals // matching the design (l.572); no token equivalent.
Архивные объявления этого дома
Нажмите, чтобы открыть список →
) : null}
{/* цена × срок продажи (детально) */}
Цена × срок продажи
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
аналоги сделки объект
Цена × срок продажи {data.scatterDetail.deals.map((p, i) => ( ))} {data.scatterDetail.analogs.map((p, i) => ( ))} {data.scatterDetail.yTicks.map((t) => ( {t.label} ))} {data.scatterDetail.xTicks.map((t) => ( {t.label} ))} СРОК ПРОДАЖИ, ДН ЦЕНА ПРОДАЖИ, ₽
); }