gendesign/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
bot-backend ad5c1913cb
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date (#2038)
QA on real prod estimate 701795d8 surfaced two mapper defects:
- STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never
  matched → object.address fell back to the bare house number ('14/3') and city
  mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first)
  and DaData (city-first) orders; street keyword delimited by space, not \b. Now
  'улица Яскина, 14/3' + city 'Екатеринбург'.
- days_on_market is null across prod lots → scatter-mini rendered empty. Active
  analogs now fall back to 'days listed so far' = today-listing_date (deals still
  require a real days_on_market — today-deal-date is not exposure). TODO BE-1.

Verified via tsx harness against the real estimate (25/25); next build green.
2026-06-28 15:04:28 +03:00

783 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<HouseType, string> = {
panel: "Панельный",
brick: "Кирпичный",
monolith: "Монолитный",
monolith_brick: "Монолитно-кирпичный",
other: "Блочный",
};
// enum -> display label (mapObject).
export const REPAIR_RU: Record<RepairState, string> = {
needs_repair: "Требует ремонта",
standard: "Удовлетворительный",
good: "Хороший",
excellent: "Отличный",
};
// enum -> RU lower-case (summary quality block; meta uses .toUpperCase()).
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
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<string, HouseType> = {
Панельный: "panel",
Кирпичный: "brick",
Монолитный: "monolith",
"Монолитно-кирпичный": "monolith_brick",
Блочный: "other",
Другой: "other",
};
export const REPAIR_FROM_RU: Record<string, RepairState> = {
"Без отделки": "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_at24h). */
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<number>(8).fill(0);
bars[3] = 100;
return bars;
}
const span = max - min;
const counts = new Array<number>(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<string, number>();
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 0180 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 — todaydeal-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 = expires24h. */
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)),
},
};
}