// 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, HouseAnalyticsResponse, HouseType, RepairState, StreetDealsResponse, } from "@/types/trade-in"; import type { AxisTickX, AxisTickY, ObjectInfo, RangeBar, RangeMarker, Ranges, Report, ResultCard, ResultMeta, ScatterMini, ScatterPoint, SourceCard, Summary, SummaryRow, } from "./types"; // Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7". const TOTAL_SOURCES = 7; // ── 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) ───────────────────────────────────────────────────── /** 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")} ₽/м²`; } /** 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", }); } /** 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.toFixed(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)} — ${fmtMln(hi)} млн ₽`; } // ── 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 { address = parts[0]; } 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"] }, { name: "RESTATE", keys: ["restate"] }, { name: "N1.RU", keys: ["n1", "n1ru"] }, ]; 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, }; }); } // ── 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 }; } /** 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) : "—", deltaLabel: "К РЫНКУ", 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, }, ]; const meta: ResultMeta = { sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence].toUpperCase(), cv: cvStr(e.analogs.map((a) => a.price_per_m2)), }; 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, anaBargain != null ? fmtPct(anaBargain) : null, ].filter((p): p is string => p != null); const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—"; const rows: SummaryRow[] = [ { 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: `${e.sources_used.length} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence], cv: cvStr(e.analogs.map((a) => a.price_per_m2)), }, }; }