// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port. // // These functions take the real trade-in API objects (AggregatedEstimate, // StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture // shapes the v2 components already consume (see ./types + ./fixtures). The Wire // phase swaps each component's hardcoded fixture for a `data` prop fed by one of // these mappers; the fixtures stay as the prop DEFAULT so unwired usage still // renders. ALL money/format/geometry lives here — never copy fixture literals. // // Reconciliations / known FE-approximations (look for "TODO BE-N"): // BE-1 created_at, cv, per-source lot counts are NOT in the API response. // Report date is derived as expires_at − 24h; cv is the FE coefficient // of variation of analog ₽/м²; source counts are an FE groupBy of // analogs + actual_deals by .source. // BE-2 target_address is a single string; street/city are split heuristically // (parseAddress). Backend should return structured address components. // BE-3 location coefficient / street-view caption / compass bearing are not in // the API → surfaced as placeholders. // // Enum <-> RU reconciliation (design dropdowns have options with no enum value): // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) // repair : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair) // See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below. import type { AggregatedEstimate, AnalogLot, ConfidenceLevel, EstimateHistoryItem, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseType, PlacementHistoryItem, RepairState, SalesVsListingsResponse, SellTimeSensitivityResponse, StreetDealsResponse, } from "@/types/trade-in"; import type { AdRow, Analytics, AnalyticsKpi, AxisTickX, AxisTickY, CacheKpi, CacheRow, DealRow, DkpRow, History, HouseSaleRow, MarketAds, MarketDeals, ObjectInfo, PriceHistory, RangeBar, RangeMarker, Ranges, Report, ResultCard, ResultMeta, ScatterDetail, ScatterMini, ScatterPoint, SellTimeTier, SourceCard, Summary, SummaryRow, } from "./types"; import { tokens } from "./tokens"; // Total number of source slots — meta "N / M". Single source of truth is // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). // ── Composite presentation shapes consumed by the wired components ────────── // One `data` prop per component; each field keeps its exact ./types shape. export interface ResultPanelData { cards: ResultCard[]; meta: ResultMeta; ranges: Ranges; scatterMini: ScatterMini; sources: SourceCard[]; } export interface HeroBarData { report: Report; object: ObjectInfo; } export interface ObjectSummaryData { object: ObjectInfo; summary: Summary; } // ── Enum <-> RU maps ──────────────────────────────────────────────────────── // enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches // a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU. export const HOUSE_TYPE_RU: Record = { panel: "Панельный", brick: "Кирпичный", monolith: "Монолитный", monolith_brick: "Монолитно-кирпичный", other: "Блочный", }; // enum -> display label (mapObject). export const REPAIR_RU: Record = { needs_repair: "Требует ремонта", standard: "Удовлетворительный", good: "Хороший", excellent: "Отличный", }; // enum -> RU lower-case (summary quality block; meta uses .toUpperCase()). export const CONFIDENCE_RU: Record = { low: "низкая", medium: "средняя", high: "высокая", }; // RU dropdown label -> enum (Wire-phase form submit). Reconciles the design's // extra options that have no enum value (see header comment). export const HOUSE_TYPE_FROM_RU: Record = { Панельный: "panel", Кирпичный: "brick", Монолитный: "monolith", "Монолитно-кирпичный": "monolith_brick", Блочный: "other", Другой: "other", }; export const REPAIR_FROM_RU: Record = { "Без отделки": "needs_repair", "Требует ремонта": "needs_repair", Удовлетворительный: "standard", Хороший: "good", Отличный: "excellent", }; // ── Format helpers (ru) ───────────────────────────────────────────────────── // Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so // "6,56 млн ₽" / "1,2 – 3,4" never wrap mid-token. const NBSP = " "; /** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */ export function fmtMln(rub: number | null | undefined): string { if (rub == null || !Number.isFinite(rub)) return "—"; return (rub / 1e6).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } /** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */ export function fmtPpm(ppm: number | null | undefined): string { if (ppm == null || !Number.isFinite(ppm)) return "—"; return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`; } /** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */ function fmtArea(area: number | null | undefined): string { if (area == null || !Number.isFinite(area)) return "—"; return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`; } /** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */ export function fmtDate(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", }); } const RU_MONTHS = [ "янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек", ]; /** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */ function fmtMonthYear(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`; } /** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */ function fmtDateShift(iso: string | null | undefined, hours: number): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; d.setHours(d.getHours() + hours); return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", }); } /** Signed percent with proper ru minus. -3 -> "−3%", 5 -> "+5%", 0 -> "0%". */ export function fmtPct(v: number | null | undefined, digits = 0): string { if (v == null || !Number.isFinite(v)) return "—"; const rounded = digits > 0 ? Number(v.toFixed(digits)) : Math.round(v); const sign = rounded < 0 ? "−" : rounded > 0 ? "+" : ""; return `${sign}${Math.abs(rounded)}%`; } /** Russian plural picker. forms = [one, few, many]. */ export function pluralRu(n: number, forms: [string, string, string]): string { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return forms[2]; if (b > 1 && b < 5) return forms[1]; if (b === 1) return forms[0]; return forms[2]; } // ── Numeric helpers ───────────────────────────────────────────────────────── function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); } function round1(v: number): number { return Math.round(v * 10) / 10; } function median(values: number[]): number | null { const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); if (clean.length === 0) return null; const mid = Math.floor(clean.length / 2); return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2; } /** Coefficient of variation (%) of positive values; null if <2 samples. */ function coeffVar(values: number[]): number | null { const clean = values.filter((v) => Number.isFinite(v) && v > 0); if (clean.length < 2) return null; const mean = clean.reduce((a, b) => a + b, 0) / clean.length; if (mean === 0) return null; const variance = clean.reduce((a, b) => a + (b - mean) ** 2, 0) / clean.length; return (Math.sqrt(variance) / mean) * 100; } function cvStr(values: number[]): string { const cv = coeffVar(values); return cv != null ? `${cv.toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1, })}%` : "—"; } /** * Bin `values` into 8 buckets across [min,max]; return per-bucket height as * count/maxCount*100 (the mini-histogram bar heights). Empty input -> []. */ export function bins8(values: number[]): number[] { const clean = values.filter((v) => Number.isFinite(v) && v > 0); if (clean.length === 0) return []; const min = Math.min(...clean); const max = Math.max(...clean); if (max === min) { // Degenerate: all equal -> single central bar. const bars = new Array(8).fill(0); bars[3] = 100; return bars; } const span = max - min; const counts = new Array(8).fill(0); for (const v of clean) { const idx = clamp(Math.floor(((v - min) / span) * 8), 0, 7); counts[idx] += 1; } const maxCount = Math.max(...counts); if (maxCount === 0) return []; return counts.map((c) => Math.round((c / maxCount) * 100)); } export interface ScatterDomain { xMin: number; xMax: number; yMin: number; yMax: number; } export interface ScatterBox { left: number; right: number; top: number; // smaller pixel y (high value) bottom: number; // larger pixel y (low value) } /** * Project data points {x,y} into the SVG pixel box. Y is inverted (high value -> * top). Out-of-domain points are clamped into the box. Coords rounded to 1dp. */ export function scatterProject( pts: ReadonlyArray<{ x: number; y: number }>, domain: ScatterDomain, box: ScatterBox, r: number, ): ScatterPoint[] { const xSpan = domain.xMax - domain.xMin || 1; const ySpan = domain.yMax - domain.yMin || 1; return pts.map((p) => { const fx = clamp((p.x - domain.xMin) / xSpan, 0, 1); const fy = clamp((p.y - domain.yMin) / ySpan, 0, 1); return { x: round1(box.left + fx * (box.right - box.left)), y: round1(box.bottom - fy * (box.bottom - box.top)), r, }; }); } // ── Range marker (CSS % offsets) ──────────────────────────────────────────── function pctStr(n: number): string { return `${round1(n)}%`; } /** * Filled-segment offsets + median dot position for a price range bar. * Domain is [lo,hi] padded by `padFrac` on each side so the dot/segment have * breathing room. Degenerate ranges fall back to a centred dot. */ function rangeMarker( lo: number | null | undefined, med: number | null | undefined, hi: number | null | undefined, padFrac = 0.08, ): RangeMarker { if ( lo == null || med == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(med) || !Number.isFinite(hi) || hi <= lo ) { return { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" }; } const span = hi - lo; const pad = span * padFrac; const dMin = lo - pad; const dMax = hi + pad; const w = dMax - dMin; return { fillLeft: pctStr(((lo - dMin) / w) * 100), fillRight: pctStr(((dMax - hi) / w) * 100), dotLeft: pctStr(clamp(((med - dMin) / w) * 100, 0, 100)), }; } /** "{lo} — {hi} млн ₽" range line for a result card. Missing -> "—". */ function rangeLine( lo: number | null | undefined, hi: number | null | undefined, ): string { if ( lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi) ) { return "—"; } return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}₽`; } // ── Address parsing (TODO BE-2) ───────────────────────────────────────────── // NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the // street keyword is delimited by start/space/dot/end explicitly, not \b. const STREET_RE = /(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i; const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i; // House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд // prefix stripped by houseClean before the test. const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i; // Administrative anchor — the city token typically sits right before one of these. const ADMIN_ANCHOR_RE = /городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i; // Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name). const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/; /** * Best-effort split of a single address string into {street+house, city}. * Handles BOTH common geocoder orders (TODO BE-2 — structured address): * OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …" * DaData: "г Екатеринбург, ул Яскина, д 14/3" */ function parseAddress(full: string | null): { address: string; city: string } { if (!full || !full.trim()) return { address: "—", city: "" }; const parts = full .split(",") .map((s) => s.trim()) .filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p)); if (parts.length === 0) return { address: full.trim(), city: "" }; // City: prefer an explicit г./город token; else the token right before the // first administrative anchor (городской округ / область / край). let city = ""; const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean); if (tokenMatch) { city = tokenMatch[1].trim(); } else { const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p)); if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim(); } // Address: street token + adjacent house number (house precedes the street in // OSM order, follows it in DaData order). Fall back to the first part. const houseClean = (p: string) => p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim(); const streetIdx = parts.findIndex((p) => STREET_RE.test(p)); let address: string; if (streetIdx >= 0) { const street = parts[streetIdx]; const house = [parts[streetIdx - 1], parts[streetIdx + 1]] .filter((p): p is string => p != null) .map(houseClean) .find((p) => HOUSE_RE.test(p)); address = house ? `${street}, ${house}` : street; } else { // No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't // silently drop the house number: pair a house token with its preceding // street name; otherwise keep the full comma-joined string rather than // only parts[0] (which would lose the house). const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p))); address = houseIdx > 0 ? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}` : parts.join(", "); } return { address, city }; } function roomsLabel(rooms: number | null): string { if (rooms == null) return "—"; if (rooms <= 0) return "Студия"; if (rooms >= 5) return "5+"; return String(rooms); } function numLabel(v: number | null): string { return v == null || !Number.isFinite(v) ? "—" : String(v); } // ── Source slots (FE groupBy — TODO BE-1) ─────────────────────────────────── // Each design slot maps to one or more API source keys. count = number of // analogs + actual_deals whose .source matches; active = key present in // sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv // anchor key (no lots of its own). const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [ { name: "ЦИАН", keys: ["cian"] }, { name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] }, { name: "РОСРЕЕСТР", keys: ["rosreestr"] }, { name: "АВИТО", keys: ["avito", "avito_imv"] }, { name: "ДОМКЛИК", keys: ["domklik", "domclick"] }, ]; // #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не // учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен // (живой источник, реально даёт лоты). TOTAL_SOURCES = единый счётчик «N / M», // чтобы плитки, счётчик и текст не расходились. const TOTAL_SOURCES = SOURCE_SLOTS.length; function buildSources(e: AggregatedEstimate): SourceCard[] { const counts = new Map(); for (const lot of [...e.analogs, ...e.actual_deals]) { const s = (lot.source ?? "").toLowerCase(); if (!s) continue; counts.set(s, (counts.get(s) ?? 0) + 1); } const used = new Set(e.sources_used.map((s) => s.toLowerCase())); return SOURCE_SLOTS.map((slot) => { const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0); const active = slot.keys.some((k) => used.has(k)) || count > 0; if (!active) { return { name: slot.name, count: "—", label: "нет данных", active: false, }; } return { name: slot.name, count: count > 0 ? String(count) : "—", label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных", active: true, }; }); } /** * Canonical "active sources" count = number of filled source TILES (one per * design slot), so meta/quality/overlay all agree with what the user sees. NOT * e.sources_used.length (which can double-count alias keys / anchor-only sources). */ function activeSourceCount(e: AggregatedEstimate): number { return buildSources(e).filter((s) => s.active).length; } // ── Deal tier resolution (DKP / Росреестр) ────────────────────────────────── interface DealTier { medianRub: number; loRub: number; hiRub: number; medianPpm: number; count: number; bars: number[]; } /** * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's * own actual_deals. null when no deal data at all. */ function resolveDealTier( e: AggregatedEstimate, sd?: StreetDealsResponse | null, ): DealTier | null { if (sd && sd.count > 0 && Number.isFinite(sd.median_price_rub)) { return { medianRub: sd.median_price_rub, loRub: sd.range_low_rub, hiRub: sd.range_high_rub, medianPpm: sd.median_price_per_m2, count: sd.count, bars: bins8(sd.deals.map((d) => d.price_per_m2)), }; } const area = e.area_m2; if (e.dkp_corridor && e.dkp_corridor.count > 0 && area && area > 0) { const c = e.dkp_corridor; return { medianRub: c.median_ppm2 * area, loRub: c.low_ppm2 * area, hiRub: c.high_ppm2 * area, medianPpm: c.median_ppm2, count: c.count, bars: bins8(e.actual_deals.map((d) => d.price_per_m2)), }; } if (e.actual_deals.length > 0) { const prices = e.actual_deals.map((d) => d.price_rub); const ppms = e.actual_deals.map((d) => d.price_per_m2); const medRub = median(prices); const medPpm = median(ppms); if (medRub == null || medPpm == null) return null; return { medianRub: medRub, loRub: Math.min(...prices), hiRub: Math.max(...prices), medianPpm: medPpm, count: e.actual_deals.length, bars: bins8(ppms), }; } return null; } // ── Scatter mini (ЦЕНА × СРОК ПРОДАЖИ) ────────────────────────────────────── // Pixel box + x-tick positions taken from the design viewBox (168×140). X domain // is a fixed 0–180 days so the x-ticks stay valid; Y domain is data-driven and // the y-tick LABELS are recomputed for the chosen pixel positions. const SCATTER_BOX: ScatterBox = { left: 34, right: 158, top: 13, bottom: 112 }; const SCATTER_X_DOMAIN = { min: 0, max: 180 }; const SCATTER_X_TICKS: AxisTickX[] = [ { label: "0", x: 34 }, { label: "90", x: 96 }, { label: "180", x: 158 }, ]; const SCATTER_Y_TICK_PX = [13, 77, 112]; // top, mid, bottom function mlnTick(rub: number): string { return `${Math.round(rub / 1e6)}М`; } function buildScatterMini( e: AggregatedEstimate, sd?: StreetDealsResponse | null, ): ScatterMini { // Exposure (x-axis). days_on_market is sparsely populated in prod, so for // ACTIVE analogs fall back to "days listed so far" = today − listing_date // (a valid lower bound on time-to-sell). Deals keep requiring a real // days_on_market — today−deal-date is not exposure. TODO BE-1: populate days_on_market. const daysListed = (l: AnalogLot): number | null => { if (l.listing_date == null) return null; const t = new Date(l.listing_date).getTime(); if (Number.isNaN(t)) return null; const days = Math.round((Date.now() - t) / 86_400_000); return days >= 0 && days < 3650 ? days : null; }; const realDom = (l: AnalogLot): number | null => l.days_on_market != null && Number.isFinite(l.days_on_market) ? l.days_on_market : null; const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) => lots .map((l) => ({ x: dom(l), y: l.price_rub })) .filter( (p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y), ); const dealsRaw = toPts( sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom, ); const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l)); const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub; const subjectDays = e.est_days_on_market; const allY = [...dealsRaw, ...analogsRaw] .map((p) => p.y) .filter((y) => Number.isFinite(y)); if (Number.isFinite(subjectPrice)) allY.push(subjectPrice); const fallbackTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((y) => ({ label: "—", y, })); if (allY.length === 0) { return { deals: [], analogs: [], subject: { x: 96, y: 62, r: 3.6 }, yTicks: fallbackTicks, xTicks: SCATTER_X_TICKS, }; } let yMin = Math.min(...allY); let yMax = Math.max(...allY); if (yMin === yMax) { yMin *= 0.95; yMax *= 1.05; } const yPad = (yMax - yMin) * 0.08; const domain: ScatterDomain = { xMin: SCATTER_X_DOMAIN.min, xMax: SCATTER_X_DOMAIN.max, yMin: yMin - yPad, yMax: yMax + yPad, }; const deals = scatterProject(dealsRaw, domain, SCATTER_BOX, 2.4); const analogs = scatterProject(analogsRaw, domain, SCATTER_BOX, 3); // Subject: real est_days if known, else mid-window (90 days). const subject = Number.isFinite(subjectPrice) ? scatterProject( [{ x: subjectDays ?? 90, y: subjectPrice }], domain, SCATTER_BOX, 3.6, )[0] : { x: 96, y: 62, r: 3.6 }; const yTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((py) => { const frac = (SCATTER_BOX.bottom - py) / (SCATTER_BOX.bottom - SCATTER_BOX.top); const price = domain.yMin + frac * (domain.yMax - domain.yMin); return { label: mlnTick(price), y: py }; }); return { deals, analogs, subject, yTicks, xTicks: SCATTER_X_TICKS }; } // ── Public mappers ────────────────────────────────────────────────────────── /** Report header (HeroBar / Footer). id = first 8 of UUID; date = expires−24h. */ export function mapReport(e: AggregatedEstimate): Report { return { id: e.estimate_id.slice(0, 8), date: fmtDateShift(e.expires_at, -24), // TODO BE-1: real created_at validUntil: fmtDate(e.expires_at), }; } /** Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). */ export function mapObject(e: AggregatedEstimate): ObjectInfo { const { address, city } = parseAddress(e.target_address); return { address, city, area: numLabel(e.area_m2), rooms: roomsLabel(e.rooms), floor: numLabel(e.floor), totalFloors: numLabel(e.total_floors), year: numLabel(e.year_built), houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", balcony: e.has_balcony ?? false, locationCoef: "—", // TODO BE-3 streetView: "", // TODO BE-3 compass: "", // TODO BE-3 }; } // ── ParamsPanel map markers (analog price pins) ───────────────────────────── // Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн) // that never reflected the real estimate. We project each analog with a known // {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box. // There is no real basemap/zoom behind the SVG, so a fixed geographic half-span // (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to // the 4–96% margin. Finding #12: the closest pins overlapped on the small map, so // a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST // apart (% units). null / empty / un-geocoded estimate → [] (the map shows just // the subject pin, never the fake fixture prices). /** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */ export interface MapMarker { left: string; // e.g. "62%" top: string; // e.g. "31%" label: string; // "12,70 млн ₽" sub: string; // "54 м²" } // Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat / // 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls // analogs toward the centre, smaller pushes them to the clamped margins. const MAP_LAT_HALF_SPAN = 0.011; const MAP_LON_HALF_SPAN = 0.018; const MAP_CLAMP_LO = 4; const MAP_CLAMP_HI = 96; // Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST // from every already-kept pin (Euclidean in the 0–100% map box) so close analogs // don't stack into an unreadable blob on the small map. const MIN_PIN_DIST = 22; const KEEP_CAP = 3; // M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%), // where ParamsPanel draws the subject pin. Reserve that spot so no analog price // pin stacks under the subject's address caption (which made the subject look // labelled with an analog's area/price). const SUBJECT_POS = { left: 50, top: 50 }; /** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] { if (e == null || e.target_lat == null || e.target_lon == null) return []; const tLat = e.target_lat; const tLon = e.target_lon; const geo = e.analogs.filter( (a): a is AnalogLot & { lat: number; lon: number } => a.lat != null && a.lon != null && Number.isFinite(a.lat) && Number.isFinite(a.lon), ); if (geo.length === 0) return []; // Closest first: real distance_m when known, else a degree-based estimate // (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly. const ranked = geo .map((a) => { const fallbackM = Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320; const dist = a.distance_m != null && Number.isFinite(a.distance_m) ? a.distance_m : fallbackM; return { a, dist }; }) .sort((x, y) => x.dist - y.dist); // Greedy non-overlap pass over the closest-first list: project each candidate // to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every // pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final // map, where they become the MapMarker "NN%" strings. const kept: { left: number; top: number; a: AnalogLot }[] = []; for (const { a } of ranked) { if (kept.length >= KEEP_CAP) break; const left = clamp( 50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50, MAP_CLAMP_LO, MAP_CLAMP_HI, ); const top = clamp( 50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50, MAP_CLAMP_LO, MAP_CLAMP_HI, ); // Drop analogs that land on the subject pin (centre) — otherwise the closest // analog's "21,2 млн / 80 м²" reads as the subject's caption (M10). const onSubject = Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST; if (onSubject) continue; const overlaps = kept.some( (k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST, ); if (overlaps) continue; kept.push({ left, top, a }); } return kept.map(({ left, top, a }) => ({ left: `${round1(left)}%`, top: `${round1(top)}%`, label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}₽`, sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—", })); } /** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */ export function mapResultPanel( e: AggregatedEstimate, streetDeals?: StreetDealsResponse | null, ): ResultPanelData { const dealTier = resolveDealTier(e, streetDeals); const cards: ResultCard[] = [ { title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"], value: fmtMln(e.median_price_rub), 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)), nav: 2, }, { title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"], value: fmtMln(e.expected_sold_price_rub), unit: "млн ₽", range: rangeLine( e.expected_sold_range_low_rub, e.expected_sold_range_high_rub, ), ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`, delta: e.asking_to_sold_ratio != null ? fmtPct((e.asking_to_sold_ratio - 1) * 100) : "—", // §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса. // «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»). deltaLabel: "К ОБЪЯВЛ.", note: dealTier != null && e.expected_sold_price_rub != null && Number.isFinite(e.expected_sold_price_rub) && e.expected_sold_price_rub < dealTier.medianRub ? "Обычно ниже медианы ДКП — другая выборка и площади" : undefined, nav: 2, }, { title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"], value: dealTier ? fmtMln(dealTier.medianRub) : "—", unit: "млн ₽", range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных", ppm: dealTier ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu( dealTier.count, ["сделка", "сделки", "сделок"], )}` : "—", bars: dealTier ? dealTier.bars : [], nav: 1, }, ]; // Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ // И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show. const builtOnDeals = dealTier?.count ?? 0; const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [ "АНАЛОГУ", "АНАЛОГАМ", "АНАЛОГАМ", ])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [ "СДЕЛКЕ", "СДЕЛКАМ", "СДЕЛКАМ", ])}`; const meta: ResultMeta = { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence].toUpperCase(), cv: cvStr(e.analogs.map((a) => a.price_per_m2)), builtOn, }; const adsBar: RangeBar = { label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], median: `${fmtMln(e.median_price_rub)} млн ₽`, lo: `${fmtMln(e.range_low_rub)} млн`, hi: `${fmtMln(e.range_high_rub)} млн`, marker: rangeMarker(e.range_low_rub, e.median_price_rub, e.range_high_rub), }; const dealsBar: RangeBar = { label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"], median: dealTier ? `${fmtMln(dealTier.medianRub)} млн ₽` : "—", lo: dealTier ? `${fmtMln(dealTier.loRub)} млн` : "—", hi: dealTier ? `${fmtMln(dealTier.hiRub)} млн` : "—", marker: dealTier ? rangeMarker(dealTier.loRub, dealTier.medianRub, dealTier.hiRub) : { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" }, }; return { cards, meta, ranges: { ads: adsBar, deals: dealsBar }, scatterMini: buildScatterMini(e, streetDeals), sources: buildSources(e), }; } /** 03 СВОДКА ОБЪЕКТА: per-section totals + data-quality block. */ export function mapSummary( e: AggregatedEstimate, analytics?: HouseAnalyticsResponse | null, streetDeals?: StreetDealsResponse | null, ): Summary { const kpi = analytics?.kpi; const houseSold = kpi?.sold_count ?? null; const houseExp = kpi?.median_exposure_days ?? null; const row1Value = houseSold != null ? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}` : "—"; const analogMedian = median(e.analogs.map((a) => a.price_rub)); const row2Value = e.n_analogs > 0 ? `${e.n_analogs}${ analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : "" }` : "—"; const dealCount = streetDeals?.count ?? e.actual_deals.length; const dealMedian = streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.price_rub)); const row3Value = dealCount > 0 ? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}` : "—"; const anaExp = kpi?.median_exposure_days ?? null; const anaBargain = kpi?.median_bargain_pct ?? null; const row4Parts = [ anaExp != null ? `${anaExp} дн` : null, // Finding #10: negate the bargain so a price reduction reads "−3%" (compact // whole-percent here), not a misleading "+3%". See bargainSigned() rationale. anaBargain != null ? fmtPct(-anaBargain) : null, ].filter((p): p is string => p != null); const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—"; const rows: SummaryRow[] = [ { // Finding #9: fed by house-analytics radius data (район, radius_m up to // 300 m), NOT strictly the exact house → honest label "Продажи рядом". label: "Продажи рядом", value: row1Value, dot: houseSold && houseSold > 0 ? "accent" : "muted", nav: 1, }, { label: "Аналоги", value: row2Value, dot: e.n_analogs > 0 ? "accent" : "muted", nav: 2, }, // 'Сделки' is the design's secondary (muted) row regardless of count. { label: "Сделки", value: row3Value, dot: "muted", nav: 2 }, { label: "Аналитика", value: row4Value, dot: row4Parts.length > 0 ? "accent" : "muted", nav: 3, }, ]; return { rows, quality: { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence], cv: cvStr(e.analogs.map((a) => a.price_per_m2)), }, }; } // ════════════════════════════════════════════════════════════════════════════ // OVERLAY MAPPERS (04 ПРОДАЖИ В ДОМЕ · 05 РЫНОК · 06 АНАЛИТИКА) // // Three pure mappers feeding the SectionOverlay views. Each returns a composite // presentation shape whose fields keep the exact ./types contract the views // already render, so the Wire phase only swaps each view's fixture import for a // `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed // here from the real API objects — never copied from fixtures. Graceful nulls // degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay. // // NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/ // fmtMln): the ad/deal/DKP tables carry their unit in the COLUMN HEADER, so the // row values are unit-less grouped integers (numRu) for ₽/м², full grouped rubles // for prices, and млн only where the design shows млн (KPI medians, house-sale // from→to). Using fmtPpm/fmtMln verbatim there would double the unit or collapse // rubles to млн, breaking the pixel-perfect markup. Percent style also splits: // market deltas are whole-percent (fmtPct), торг/bargain are 1-decimal (pct1) — // matching the design. // ════════════════════════════════════════════════════════════════════════════ // ── Overlay format helpers ────────────────────────────────────────────────── /** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */ function numRu(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return "—"; return Math.round(v).toLocaleString("ru-RU"); } /** "{lo} – {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */ function mlnRangeSp( lo: number | null | undefined, hi: number | null | undefined, ): string { if ( lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi) ) { return "—"; } return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`; } /** Same style as mlnRangeSp (marketAds P25–P75), ru 2dp, NBSP en-dash. Missing -> "—". */ function mlnRangeTight( lo: number | null | undefined, hi: number | null | undefined, ): string { if ( lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi) ) { return "—"; } return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`; } /** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "−36,0%". */ function pct1(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return "—"; const r = Number(v.toFixed(1)); const sign = r < 0 ? "−" : r > 0 ? "+" : ""; return `${sign}${Math.abs(r).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1, })}%`; } /** * Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI * median_bargain_pct is the median over house_placement_history of * (start_price − last_price) / start_price × 100 * (see backend trade_in.py), so a POSITIVE value means the asking price was * REDUCED from its initial to its final value (the haggle / discount room). * Feeding that straight through pct1 renders a misleading "+3.3%" — the "+" * reads as a price INCREASE. We instead show the SIGNED change from initial → * final price, so a reduction reads "−3.3%", matching the tile sub-label * "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—". */ function bargainSigned(pct: number | null | undefined): string { if (pct == null || !Number.isFinite(pct)) return "—"; return pct1(-pct); } /** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */ function fmtDist(m: number | null | undefined): string { if (m == null || !Number.isFinite(m)) return "—"; if (m < 1000) return `${Math.round(m)}${NBSP}м`; return `${(m / 1000).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1, })}${NBSP}км`; } // Source key -> brand/RU display label (badges + link source). const SOURCE_LABEL: Record = { avito: "Avito", avito_imv: "Avito", cian: "Циан", yandex: "Яндекс", rosreestr: "Росреестр", domklik: "Домклик", domclick: "Домклик", restate: "Restate", // n1 полностью выключен (#2204) → истор. строки source='n1' идут через fallback `?? s`. }; function sourceLabel(s: string | null | undefined): string { if (!s) return "—"; return SOURCE_LABEL[s.toLowerCase()] ?? s; } /** Rosreestr deal tier -> RU caption next to the badge. */ function tierLabel(t: string | null | undefined): string { if (!t) return ""; if (t.includes("house")) return "по дому"; if (t.includes("street")) return "по улице"; return ""; } /** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */ function composeLot( rooms: number | null, area: number | null, floor: number | null, totalFloors: number | null, ): string { const parts: string[] = []; if (rooms == null) parts.push("Квартира"); else if (rooms <= 0) parts.push("Студия"); else parts.push(`${rooms}-к. квартира`); if (area != null && Number.isFinite(area)) parts.push(fmtArea(area)); if (floor != null) { parts.push( totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`, ); } return parts.join(", "); } /** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */ function adMeta(l: AnalogLot): string { const parts: string[] = []; if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2)); parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`); if (l.floor != null) { parts.push( l.total_floors != null ? `этаж ${l.floor}/${l.total_floors}` : `этаж ${l.floor}`, ); } return parts.join(" · "); } /** "38.1 м² · 1-к" for a deal row (no floor in the design). */ function dealMeta(l: AnalogLot): string { const parts: string[] = []; if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2)); parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`); return parts.join(" · "); } /** * Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two * tokens together with no separator — either a word glued onto the house number * ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая"). * Insert a space wherever a digit OR a lowercase letter is immediately followed * by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing * lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate * single-letter house litereras (14А, 5Б, д.5К — capital NOT followed by * lowercase) and do NOT touch already-separated tokens ("ул.Белинского" — the * capital is preceded by ".", not a glue char). Pure display cleanup; never * touches the comma-splitting parseAddress. */ function deglueAddr(addr: string): string { return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2"); } // ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ───────────────────────────────────────── /** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */ export interface DkpRowData extends DkpRow { addr?: string; date?: string; source?: string | null; } /** History composite + the per-street ДКП deal-vs-listing rows. */ export interface HistoryData extends History { dkpRows: DkpRowData[]; } /** * House placement history + per-street ДКП corridor (street-deals) + paired * deal↔listing rows (sales-vs-listings). Each source is independent: a null one * leaves its block at "—"/empty without blanking the others. */ export function mapHistory( placement: PlacementHistoryItem[] | null, streetDeals: StreetDealsResponse | null, salesVsListings: SalesVsListingsResponse | null, ): HistoryData { const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({ lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors), priceFrom: fmtMln(p.start_price), priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—", date: fmtDate(p.last_price_date), exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—", })); const street = streetDeals?.street ?? null; const title = street ? `ДКП-сделки на улице ${street}` : "ДКП-сделки на вашей улице"; const period = salesVsListings?.period_months != null ? `${salesVsListings.period_months} мес` : "—"; const noteParts: string[] = []; if (salesVsListings?.linkage_rate_pct != null) { noteParts.push( `${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`, ); } if (salesVsListings?.median_discount_pct != null) { noteParts.push( `медианный торг ${pct1(salesVsListings.median_discount_pct)}`, ); } const note = (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") + "Данные по улице, не по дому."; const dkpKpi = { count: streetDeals?.count != null ? String(streetDeals.count) : "—", median: streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2) ? numRu(streetDeals.median_price_per_m2) : "—", range: streetDeals != null ? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub) : "—", }; const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => { const ask = pr.listing_price_rub != null ? `${numRu(pr.listing_price_rub)} ₽${ pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0 ? ` за ${pr.days_listing_to_deal} дн до` : "" }` : "—"; const deltaColor = pr.discount_pct == null ? tokens.muted : pr.discount_pct < 0 ? tokens.success : tokens.danger; return { area: fmtArea(pr.deal_area_m2), price: `${numRu(pr.deal_price_rub)} ₽`, ppm: numRu(pr.deal_price_per_m2), ask, delta: pct1(pr.discount_pct), deltaColor, addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined, date: fmtMonthYear(pr.deal_date), source: pr.listing_source, }; }); return { houseSales, dkpHeader: { title, period, note }, dkpKpi, dkpRows }; } // ── 06 АНАЛИТИКА ДОМА (AnalyticsView) ─────────────────────────────────────── // Three KPI tiles (порядок: экспозиция · торг · доля снятых). function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] { const bargainColor = k?.median_bargain_pct != null && k.median_bargain_pct < 0 ? tokens.success : undefined; return [ { label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ", value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—", unit: "дн.", sub: "по архивным объявлениям", }, { label: "СРЕДНИЙ ТОРГ", // Finding #10: negate so a price reduction reads "−3.3%" not "+3.3%". value: bargainSigned(k?.median_bargain_pct), color: bargainColor, sub: "от начальной до итоговой цены", }, { label: "ДОЛЯ СНЯТЫХ", value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—", sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—", }, ]; } // price_premium_label -> tier caption + decorative variant. const SELLTIME_META: Record< string, { tier: string; variant: SellTimeTier["variant"] } > = { cheap: { tier: "−5% от рынка", variant: "success" }, median: { tier: "По медиане", variant: "accent" }, plus5: { tier: "+5%", variant: "gold" }, plus10: { tier: "+10%", variant: "danger" }, }; // 4-column shell kept (with "—") when sell-time data is absent. const SELLTIME_FALLBACK: SellTimeTier[] = [ { tier: "−5% от рынка", days: "—", range: "—", count: "—", variant: "success", }, { tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" }, { tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" }, { tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" }, ]; function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] { if (!s || s.buckets.length === 0) return SELLTIME_FALLBACK; return s.buckets.map((b) => { const meta = SELLTIME_META[b.price_premium_label] ?? ({ tier: b.price_premium_pct === 0 ? "По медиане" : fmtPct(b.price_premium_pct), variant: "accent", } as { tier: string; variant: SellTimeTier["variant"] }); return { tier: meta.tier, days: b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—", range: b.p25_days != null && b.p75_days != null ? `обычно ${b.p25_days}–${b.p75_days} дн.` : "—", count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`, variant: meta.variant, }; }); } // Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs // y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across // [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band) // so a narrow series like 120–160k fills the plot instead of flat-lining against // a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines. const PH_X_LEFT = 58; const PH_X_RIGHT = 885; const PH_Y_TOP = 20; // top gridline pixel (high value) const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value) const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175 const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom) // Both polylines are drawn from these source keys; the Y zoom is computed over // the union of their finite medians so the two series share one axis. const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; function phYearX(i: number, n: number): number { if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2; return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1); } function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { const empty: PriceHistory = { note: "Нет данных по истории цен", years: [], avito: "", yandex: "", yTicks: [], }; if (!a || a.price_history.length === 0) return empty; const ph = a.price_history; const yearsNum = Array.from(new Set(ph.map((p) => p.year))).sort( (x, y) => x - y, ); if (yearsNum.length === 0) return empty; const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const)); const n = yearsNum.length; // Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw // so the polylines fill the plot band instead of squashing against 0–200k. const allPpm = ph .filter( (p) => PH_SERIES_SOURCES.includes(p.source) && Number.isFinite(p.median_price_per_m2), ) .map((p) => p.median_price_per_m2); if (allPpm.length === 0) return empty; let yMin = Math.min(...allPpm); let yMax = Math.max(...allPpm); if (yMin === yMax) { yMin = yMin * 0.97; yMax = yMax * 1.03; } else { const pad = (yMax - yMin) * 0.08; yMin -= pad; yMax += pad; } yMin = Math.max(0, yMin); const span = yMax - yMin || 1; const priceY = (ppm: number): number => clamp( PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN, PH_Y_TOP, PH_Y_BOTTOM, ); const series = (src: string): string => ph .filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2)) .sort((x, y) => x.year - y.year) .map((p) => { const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n)); const y = Math.round(priceY(p.median_price_per_m2)); return `${x},${y}`; }) .join(" "); const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0); const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu( totalLots, ["лот", "лота", "лотов"], )})`; const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => { const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span; return { label: `${Math.round(v / 1000)}k`, y: gy }; }); return { note, years: yearsNum.map(String), avito: series("avito_imv"), yandex: series("yandex_valuation"), yTicks, }; } // Detail "ЦЕНА × СРОК ПРОДАЖИ" scatter (viewBox 0 0 900 300). Pixel box + x-ticks // come from the design scaffold; y-tick LABELS are recomputed for the data domain // (same approach as buildScatterMini). Exposure x reuses the listing_date // "days listed so far" fallback for active analogs (deals require real DOM). const DETAIL_BOX: ScatterBox = { left: 60, right: 880, top: 24, bottom: 252 }; const DETAIL_X_DOMAIN = { min: 0, max: 180 }; const DETAIL_X_TICKS: AxisTickX[] = [ { label: "0", x: 60 }, { label: "45", x: 265 }, { label: "90", x: 470 }, { label: "135", x: 675 }, { label: "180", x: 880 }, ]; const DETAIL_Y_TICK_PX = [24, 81, 138, 195, 252]; function detailDomReal(l: AnalogLot): number | null { return l.days_on_market != null && Number.isFinite(l.days_on_market) ? l.days_on_market : null; } function detailDomListed(l: AnalogLot): number | null { if (l.listing_date == null) return null; const t = new Date(l.listing_date).getTime(); if (Number.isNaN(t)) return null; const days = Math.round((Date.now() - t) / 86_400_000); return days >= 0 && days < 3650 ? days : null; } function detailExposurePts( lots: AnalogLot[], dom: (l: AnalogLot) => number | null, ): { x: number; y: number }[] { return lots .map((l) => ({ x: dom(l), y: l.price_rub })) .filter( (p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y), ); } function buildScatterDetail( e: AggregatedEstimate | null, radiusM: number | null, ): ScatterDetail { const fallbackTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((y) => ({ label: "—", y, })); const emptySubject: ScatterPoint = { x: 470, y: 138, r: 6 }; if (!e) { return { note: "—", deals: [], analogs: [], subject: emptySubject, yTicks: fallbackTicks, xTicks: DETAIL_X_TICKS, }; } const dealsRaw = detailExposurePts(e.actual_deals, detailDomReal); const analogsRaw = detailExposurePts( e.analogs, (l) => detailDomReal(l) ?? detailDomListed(l), ); const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub; const subjectDays = e.est_days_on_market; const nDeals = e.actual_deals.length; const nAnalogs = e.analogs.length; const note = `${nDeals} ${pluralRu(nDeals, [ "сделка", "сделки", "сделок", ])} и ${nAnalogs} ${pluralRu(nAnalogs, [ "аналог", "аналога", "аналогов", ])}${radiusM != null && radiusM > 0 ? ` в радиусе ${radiusM} м` : ""}`; const allY = [...dealsRaw, ...analogsRaw] .map((p) => p.y) .filter((y) => Number.isFinite(y)); if (Number.isFinite(subjectPrice)) allY.push(subjectPrice); if (allY.length === 0) { return { note, deals: [], analogs: [], subject: emptySubject, yTicks: fallbackTicks, xTicks: DETAIL_X_TICKS, }; } let yMin = Math.min(...allY); let yMax = Math.max(...allY); if (yMin === yMax) { yMin *= 0.95; yMax *= 1.05; } const yPad = (yMax - yMin) * 0.08; const domain: ScatterDomain = { xMin: DETAIL_X_DOMAIN.min, xMax: DETAIL_X_DOMAIN.max, yMin: yMin - yPad, yMax: yMax + yPad, }; const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5); const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5); const subject = Number.isFinite(subjectPrice) ? scatterProject( [{ x: subjectDays ?? 90, y: subjectPrice }], domain, DETAIL_BOX, 6, )[0] : emptySubject; const yTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((py) => { const frac = (DETAIL_BOX.bottom - py) / (DETAIL_BOX.bottom - DETAIL_BOX.top); const price = domain.yMin + frac * (domain.yMax - domain.yMin); return { label: mlnTick(price), y: py }; }); return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS }; } /** * 06 АНАЛИТИКА ДОМА: header + 3 KPI tiles + sell-time tiers + price-history * polyline + detail price×days scatter. analytics / sellTime degrade per-section; * the scatter is driven by the estimate's analogs + actual_deals. */ export function mapAnalytics( analytics: HouseAnalyticsResponse | null, sellTime: SellTimeSensitivityResponse | null, estimate: AggregatedEstimate | null, ): Analytics { const total = analytics?.kpi.total_lots ?? null; const ads = total != null ? `${total} ${pluralRu(total, ["объявление", "объявления", "объявлений"])}` : "—"; const radiusM = analytics?.radius_m ?? null; const radius = radiusM == null ? "—" : radiusM > 0 ? `в радиусе ${radiusM} м` : "точно по этому дому"; const sellTgt = sellTime?.target_median_price_per_m2 ?? null; const sellTimeNote = `Медиана экспозиции по архивным лотам${ sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : "" }`; return { header: { ads, radius }, kpis: buildAnalyticsKpis(analytics?.kpi ?? null), sellTime: buildSellTime(sellTime), sellTimeNote, priceHistory: buildPriceHistory(analytics), scatterDetail: buildScatterDetail(estimate, radiusM), }; } // ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ─────────────────────────────── // Real per-row source/distance/url + tier/date carried alongside the existing // display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid // as the component DEFAULT; the mapper always populates them. export interface AdRowData extends AdRow { /** Source brand label for the badge (was hardcoded "Avito"). */ source?: string; /** Distance to the subject (was hardcoded "—"). */ dist?: string; /** Original listing URL (was a hardcoded "Росреестр ↗" link). */ url?: string | null; } export interface DealRowData extends DealRow { /** Source brand label for the badge (was hardcoded "Росреестр"). */ source?: string; /** Tier caption next to the badge (was hardcoded "по улице"). */ tier?: string; /** Deal/listing date (was hardcoded "01.01.26"). */ date?: string; } export interface SourcesData { adRows: AdRowData[]; dealRows: DealRowData[]; marketAds: MarketAds; marketDeals: MarketDeals; /** Canonical active-source counter for the overlay header, e.g. "4 / 7". */ sourceCount?: string; } // Filter chips: distance corridor + rooms + analog area span (design order). function buildAdFilters(e: AggregatedEstimate | null): string[] { if (!e) return []; const filters: string[] = []; const dists = e.analogs .map((a) => a.distance_m) .filter((d): d is number => d != null && Number.isFinite(d)); if (dists.length > 0) { const maxKm = Math.max(...dists) / 1000; const km = maxKm < 1 ? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 }) : String(Math.ceil(maxKm)); filters.push(`Расстояние ≤ ${km}${NBSP}км`); } // Planning chip — derived from the rooms ACTUALLY present in the rendered // analog set (same source as the distance/area chips), NOT from the subject's // e.rooms. Analogs are selected by area band + geo proximity, NOT by room // count, so the set may span several planning types. Only claim a concrete // "N-к" filter when the analogs are genuinely homogeneous; otherwise stay // neutral ("все планировки") so the chip never asserts a filter that was not // applied to the data. const analogRooms = Array.from( new Set( e.analogs .map((a) => a.rooms) .filter((r): r is number => r != null && Number.isFinite(r)), ), ); if (analogRooms.length === 1) { const r = analogRooms[0]; filters.push(`Планировка ${r <= 0 ? "студия" : `${r}-к`}`); } else if (analogRooms.length > 1) { filters.push("Все планировки"); } const areas = e.analogs .map((a) => a.area_m2) .filter((x) => Number.isFinite(x)); if (areas.length > 0) { const lo = Math.min(...areas).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1, }); const hi = Math.max(...areas).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1, }); filters.push(`Площадь ${lo}${NBSP}–${NBSP}${hi}${NBSP}м²`); } return filters; } /** * 05 РЫНОК: analog ad rows + actual-deal rows + the two market KPI bands. Deal * aggregates prefer the per-street ДКП corridor (streetDeals), falling back to * the estimate's own actual_deals. */ export function mapSources( estimate: AggregatedEstimate | null, streetDeals: StreetDealsResponse | null, ): SourcesData { const e = estimate; const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({ addr: deglueAddr(l.address || "—"), meta: adMeta(l), ppm: numRu(l.price_per_m2), price: numRu(l.price_rub), source: sourceLabel(l.source), dist: fmtDist(l.distance_m), url: l.source_url ?? null, })); const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({ addr: deglueAddr(l.address || "—"), meta: dealMeta(l), ppm: numRu(l.price_per_m2), price: numRu(l.price_rub), source: sourceLabel(l.source), tier: tierLabel(l.tier), date: fmtMonthYear(l.listing_date), })); const marketAds: MarketAds = { kpi: { count: e != null ? String(e.n_analogs) : "—", median: e != null ? fmtMln(e.median_price_rub) : "—", ppm: e != null && Number.isFinite(e.median_price_per_m2) ? numRu(e.median_price_per_m2) : "—", range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—", cv: e != null ? cvStr(e.analogs.map((a) => a.price_per_m2)) : "—", }, filters: buildAdFilters(e), }; // Deal aggregates: street DKP corridor first, else the estimate's actual_deals. const dealPrices = e?.actual_deals.map((d) => d.price_rub) ?? []; const dealCount = streetDeals?.count ?? dealPrices.length; const dealMedianRub = streetDeals?.median_price_rub ?? median(dealPrices); const dealLo = streetDeals?.range_low_rub ?? (dealPrices.length > 0 ? Math.min(...dealPrices) : null); const dealHi = streetDeals?.range_high_rub ?? (dealPrices.length > 0 ? Math.max(...dealPrices) : null); const asking = e?.median_price_rub ?? null; const deltaPct = dealMedianRub != null && asking != null && asking > 0 ? (dealMedianRub / asking - 1) * 100 : null; const marketDeals: MarketDeals = { kpi: { count: dealCount > 0 ? String(dealCount) : "—", median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—", delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—", range: mlnRangeSp(dealLo, dealHi), }, }; return { adRows, dealRows, marketAds, marketDeals, sourceCount: estimate ? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}` : "—", }; } // ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ───────────────────────────────────────── // FE-derived ИСКЛЮЧИТЕЛЬНО из per-user списка /history (НЕ из /cache-stats — тот // глобальный/админский: estimates_total считает ВСЕХ юзеров). Несколько полей, // которых нет в /history, аппроксимируются на фронте (см. TODO BE-1): // • «ИСТОЧНИКОВ» (N / 7) требует sources_used — в /history его нет → "—". // • Статус свежести строится от 24-часового TTL кэша (ParamsPanel: // «КЭШ ПО АДРЕСУ — 24 Ч»): <18ч свежий · 18–24ч устаревает · >24ч устарел. export interface CacheData { rows: CacheRow[]; kpis: CacheKpi[]; } const CACHE_TTL_HOURS = 24; /** * created_at -> компактное ru-время для таблицы (как в дизайне): * сегодня -> "HH:MM:SS"; вчера -> "вчера HH:MM"; раньше -> "DD.MM.YYYY". */ function fmtCacheTime(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; const now = new Date(); const startOfToday = new Date( now.getFullYear(), now.getMonth(), now.getDate(), ).getTime(); const t = d.getTime(); if (t >= startOfToday) { return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", }); } if (t >= startOfToday - 86_400_000) { return `вчера ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", })}`; } return fmtDate(iso); } /** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */ function cacheStatus(iso: string | null | undefined): { status: string; statusColor: string; } { if (!iso) return { status: "—", statusColor: tokens.muted }; const d = new Date(iso); if (Number.isNaN(d.getTime())) return { status: "—", statusColor: tokens.muted }; const ageH = (Date.now() - d.getTime()) / 3_600_000; if (ageH <= CACHE_TTL_HOURS * 0.75) { return { status: "свежий", statusColor: tokens.success }; } if (ageH <= CACHE_TTL_HOURS) { return { status: "устаревает", statusColor: tokens.warn }; } return { status: "устарел", statusColor: tokens.danger }; } /** * 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на * фронте из per-user /history (null → пустая таблица + KPI с "—"). * rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1). * kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн) · * ПОВТОРНЫЕ АДРЕСА (доля переоценок = (всего − уникальных)/всего). */ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { const list = history ?? []; const rows: CacheRow[] = list.map((h) => { const { status, statusColor } = cacheStatus(h.created_at); return { addr: h.address || "—", time: fmtCacheTime(h.created_at), src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно status, statusColor, }; }); // KPI 1 — всего оценок (длина per-user списка, capped /history limit). const total = list.length; // KPI 2 — средняя цена по строкам с положительной медианой. const prices = list .map((h) => h.median_price) .filter((p): p is number => p != null && Number.isFinite(p) && p > 0); const avg = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : null; // KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов. const seen = new Map(); for (const h of list) { const key = (h.address ?? "").trim().toLowerCase(); if (!key) continue; seen.set(key, (seen.get(key) ?? 0) + 1); } const withAddr = [...seen.values()].reduce((a, b) => a + b, 0); const dupPct = withAddr > 0 ? ((withAddr - seen.size) / withAddr) * 100 : null; const kpis: CacheKpi[] = [ { label: "ВСЕГО ОЦЕНОК", value: String(total), sub: "по вашим оценкам", }, { label: "СРЕДНЯЯ ЦЕНА", value: avg != null ? fmtMln(avg) : "—", unit: avg != null ? "млн ₽" : undefined, sub: "по проведённым оценкам", }, { // fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" — // для доли это неверно; рендерим число + unit "%" (как в дизайне). label: "ПОВТОРНЫЕ АДРЕСА", value: dupPct != null ? String(Math.round(dupPct)) : "—", unit: dupPct != null ? "%" : undefined, sub: "доля переоценок", }, ]; return { rows, kpis }; }