gendesign/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
bot-backend e23dabe4f5
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/- zoom + building image + deglue word+word
Live-browser audit (round 2): РАДИУС/+- dead, building photo missing.
- РАДИУС: backend BE-2 — optional radius_m (int 100–5000) on TradeInEstimateInput,
  threaded into estimate_quality comp search (base + fallback). None preserves the
  exact two-tier default (1000m primary / 2000m fallback) byte-identically. FE: РАДИУС
  dropdown with **Авто** default (sends no radius_m → legacy behaviour, NOT a silent
  500m narrowing) + 300/500/1000/2000 overrides; included as radius_m on submit; outer
  map ring tracks the value.
- Map +/− buttons: now a real zoom (transform: scale() on the map content layer, step
  .25, clamp .6–2.5); controls/bracket stay unscaled.
- building.png: next/image didn't apply basePath → 400; switched to plain <img> with
  NEXT_PUBLIC_BASE_PATH-prefixed src (→ 200) + onError fallback. HERO АДРЕС card шоу.
- deglueAddr: also splits word+word glue ('БольшаковаГеологическая'→'Большакова
  Геологическая'), still preserves house letters (14А, д.5К).

next build green (/v2 34.3 kB); estimator None-default byte-identical (ge=100 rules out
falsy-0); code-reviewer  after the Авто-default fix (avoided a default-radius regression).
2026-06-28 18:03:07 +03:00

1705 lines
62 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,
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 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
};
}
// ── 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 0100% 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 496% margin. Closest ~6 by distance are shown (the pin chrome only fits a
// handful). 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_MARKER_CAP = 6;
const MAP_CLAMP_LO = 4;
const MAP_CLAMP_HI = 96;
/** 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)
.slice(0, MAP_MARKER_CAP);
return ranked.map(({ a }) => {
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,
);
return {
left: `${round1(left)}%`,
top: `${round1(top)}%`,
label: `${fmtMln(a.price_rub)} млн ₽`,
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
};
});
}
/** 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,
// 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: `${e.sources_used.length} / ${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");
}
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */
function mln1(rub: number): string {
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/** "{lo} {hi}" млн range with spaces (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 `${mln1(lo)} ${mln1(hi)}`;
}
/** "{lo}{hi}" млн range, no spaces (marketAds P25P75). 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 `${mln1(lo)}${mln1(hi)}`;
}
/** Signed percent, always 1dp, "." 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).toFixed(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)} м`;
return `${(m / 1000).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} км`;
}
// Source key -> brand/RU display label (badges + link source).
const SOURCE_LABEL: Record<string, string> = {
avito: "Avito",
avito_imv: "Avito",
cian: "Циан",
yandex: "Яндекс",
rosreestr: "Росреестр",
domklik: "Домклик",
domclick: "Домклик",
restate: "Restate",
n1: "N1.RU",
n1ru: "N1.RU",
};
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(`${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(`${l.area_m2.toFixed(1)} м²`);
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(`${l.area_m2.toFixed(1)} м²`);
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: `${pr.deal_area_m2.toFixed(1)} м²`,
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: fmtDate(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; y maps 0→195 … 200k→20
// (the chart's own gridlines), x distributes the years evenly across [58,885]
// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source.
const PH_X_LEFT = 58;
const PH_X_RIGHT = 885;
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 phPriceY(ppm: number): number {
return clamp(195 - (ppm / 200000) * 175, 20, 195);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = {
note: "Нет данных по истории цен",
years: [],
avito: "",
yandex: "",
};
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;
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(phPriceY(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,
["лот", "лота", "лотов"],
)})`;
return {
note,
years: yearsNum.map(String),
avito: series("avito_imv"),
yandex: series("yandex_valuation"),
};
}
// 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;
}
// 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.toFixed(1) : String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km} км`);
}
if (e.rooms != null) {
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}`}`);
}
const areas = e.analogs.map((a) => a.area_m2).filter((x) => Number.isFinite(x));
if (areas.length > 0) {
filters.push(
`Площадь ${Math.min(...areas).toFixed(1)} ${Math.max(...areas).toFixed(1)} м²`,
);
}
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: fmtDate(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 };
}
// ── 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ч свежий · 1824ч устаревает · >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<string, number>();
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 };
}