gendesign/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
lekss361 06bf8dfada
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m18s
Deploy Trade-In / deploy (push) Successful in 51s
fix(tradein/ui): мини-карта адреса вместо статичного фото чужого дома в шапке (#2529)
2026-07-26 20:15:54 +00:00

2238 lines
85 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
// cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041)
// prefers estimate.cv and only falls back to the FE coefficient of
// variation (analog ₽/м²) for pre-#2043 cached estimates where cv is
// absent. mapReport now prefers real created_at too (falls back to the
// expires24h shift only for pre-BE-1 rows without it). mapResultPanel/
// mapSummary/mapCache still use the other pre-BE-1 approximations (FE
// source groupBy) — out of scope for #2040/#2041, left as-is.
// BE-2 target_address is a single string; street/city are split heuristically
// (parseAddress). Backend should return structured address components.
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
//
// 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,
LocationCoefResponse,
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";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
// ── Composite presentation shapes consumed by the wired components ──────────
// One `data` prop per component; each field keeps its exact ./types shape.
export interface ResultPanelData {
cards: ResultCard[];
meta: ResultMeta;
ranges: Ranges;
scatterMini: ScatterMini;
sources: SourceCard[];
}
export interface HeroBarData {
report: Report;
object: ObjectInfo;
}
export interface ObjectSummaryData {
object: ObjectInfo;
summary: Summary;
}
// ── Enum <-> RU maps ────────────────────────────────────────────────────────
// enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches
// a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU.
export const HOUSE_TYPE_RU: Record<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) ─────────────────────────────────────────────────────
// Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so
// "6,56 млн ₽" / "1,2 3,4" never wrap mid-token.
const NBSP = " ";
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
export function fmtMln(rub: number | null | undefined): string {
if (rub == null || !Number.isFinite(rub)) return "—";
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */
export function fmtPpm(ppm: number | null | undefined): string {
if (ppm == null || !Number.isFinite(ppm)) return "—";
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
}
/** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */
function fmtArea(area: number | null | undefined): string {
if (area == null || !Number.isFinite(area)) return "—";
return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`;
}
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
const RU_MONTHS = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
export function fmtMonthYear(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_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;
}
// Robust ₽/м² outlier guard (Fix #8 / audit): a single mis-scraped or
// mislabeled lot (e.g. 872 093 ₽/м² next to a 177k pool median) silently
// blows up «РАЗБРОС ЦЕН» (CV) with no visible cause. Any price_per_m2 more
// than OUTLIER_MULT× the pool's OWN median is flagged as a "возможный
// выброс" (row badge, see mapSources) AND excluded from the spread calc below
// — never silently: the exclusion count is surfaced next to the number it
// affects (see mapSources' adsFootnote).
const OUTLIER_MULT = 2.75; // midpoint of the audited 2.53× band
/**
* Splits `ppm2` (parallel to the source row array) into outlier-free values
* for spread/median math and a same-length flag array for row badges.
*/
function guardPriceOutliers(ppm2: (number | null | undefined)[]): {
clean: number[];
isOutlier: boolean[];
excludedCount: number;
} {
const finite = ppm2.filter(
(v): v is number => v != null && Number.isFinite(v) && v > 0,
);
const pool = median(finite);
const isOutlier = ppm2.map((v) =>
v != null && Number.isFinite(v) && v > 0 && pool != null && pool > 0
? v > pool * OUTLIER_MULT
: false,
);
const clean = ppm2.filter(
(v, i): v is number =>
v != null && Number.isFinite(v) && v > 0 && !isOutlier[i],
);
return { clean, isOutlier, excludedCount: isOutlier.filter(Boolean).length };
}
// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
// variation) is not surfaced to users anymore, and the витрина rounds it to a
// whole percent ("42%", not "42,2%") — the extra decimal implied a false
// precision for what is a rough spread indicator.
function cvStr(values: number[]): string {
const cv = coeffVar(values);
return cv != null
? `${cv.toLocaleString("ru-RU", {
maximumFractionDigits: 0,
})}%`
: "—";
}
/** Format a 0..1 coefficient-of-variation ratio the same way as cvStr's %. */
function cvRatioStr(cv: number): string {
return `${(cv * 100).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
}
/**
* Analog ₽/м² spread, used by #2041's marketAds.kpi.cv. Prefers the real
* backend estimate.cv (#2043/BE-1, 0..1 ratio) and only falls back to the FE
* coefficient of variation for pre-#2043 cached estimates where cv is absent
* (older persisted rows don't carry it — see the file-header TODO BE-1 note).
*/
function cvLabel(e: AggregatedEstimate | null): string {
if (e == null) return "—";
if (e.cv != null && Number.isFinite(e.cv)) return cvRatioStr(e.cv);
return cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean);
}
/**
* 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)}${NBSP}${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}`;
}
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
// NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the
// street keyword is delimited by start/space/dot/end explicitly, not \b.
const STREET_RE =
/(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i;
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i;
// House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд
// prefix stripped by houseClean before the test.
const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i;
// Administrative anchor — the city token typically sits right before one of these.
const ADMIN_ANCHOR_RE =
/городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i;
// Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name).
const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/;
/**
* Best-effort split of a single address string into {street+house, city}.
* Handles BOTH common geocoder orders (TODO BE-2 — structured address):
* OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …"
* DaData: "г Екатеринбург, ул Яскина, д 14/3"
*/
function parseAddress(full: string | null): { address: string; city: string } {
if (!full || !full.trim()) return { address: "—", city: "" };
const parts = full
.split(",")
.map((s) => s.trim())
.filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p));
if (parts.length === 0) return { address: full.trim(), city: "" };
// City: prefer an explicit г./город token; else the token right before the
// first administrative anchor (городской округ / область / край).
let city = "";
const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean);
if (tokenMatch) {
city = tokenMatch[1].trim();
} else {
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
if (anchorIdx > 0)
city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
}
// Address: street token + adjacent house number (house precedes the street in
// OSM order, follows it in DaData order). Fall back to the first part.
const houseClean = (p: string) =>
p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim();
const streetIdx = parts.findIndex((p) => STREET_RE.test(p));
let address: string;
if (streetIdx >= 0) {
const street = parts[streetIdx];
const house = [parts[streetIdx - 1], parts[streetIdx + 1]]
.filter((p): p is string => p != null)
.map(houseClean)
.find((p) => HOUSE_RE.test(p));
address = house ? `${street}, ${house}` : street;
} else {
// No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't
// silently drop the house number: pair a house token with its preceding
// street name; otherwise keep the full comma-joined string rather than
// only parts[0] (which would lose the house).
const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p)));
address =
houseIdx > 0
? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}`
: parts.join(", ");
}
return { address, city };
}
function roomsLabel(rooms: number | null): string {
if (rooms == null) return "—";
if (rooms <= 0) return "Студия";
if (rooms >= 5) return "5+";
return String(rooms);
}
function numLabel(v: number | null): string {
return v == null || !Number.isFinite(v) ? "—" : String(v);
}
// ── Source slots (FE groupBy — TODO BE-1) ───────────────────────────────────
// Each design slot maps to one or more API source keys. count = number of
// analogs + actual_deals whose .source matches; active = key present in
// sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv
// anchor key (no lots of its own).
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ЦИАН", keys: ["cian"] },
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
{ name: "РОСРЕЕСТР", keys: ["rosreestr"] },
{ name: "АВИТО", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
];
// #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не
// учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен
// (живой источник, реально даёт лоты). TOTAL_SOURCES = единый счётчик «N / M»,
// чтобы плитки, счётчик и текст не расходились.
const TOTAL_SOURCES = SOURCE_SLOTS.length;
function buildSources(e: AggregatedEstimate): SourceCard[] {
const counts = new Map<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,
};
});
}
/**
* Canonical "active sources" count = number of filled source TILES (one per
* design slot), so meta/quality/overlay all agree with what the user sees. NOT
* e.sources_used.length (which can double-count alias keys / anchor-only sources).
*/
function activeSourceCount(e: AggregatedEstimate): number {
return buildSources(e).filter((s) => s.active).length;
}
// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
interface DealTier {
medianRub: number;
loRub: number;
hiRub: number;
medianPpm: number;
count: number;
bars: number[];
}
/**
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
* own actual_deals. null when no deal data at all.
*/
function resolveDealTier(
e: AggregatedEstimate,
sd?: StreetDealsResponse | null,
): DealTier | null {
if (sd && sd.count > 0 && Number.isFinite(sd.median_price_rub)) {
return {
medianRub: sd.median_price_rub,
loRub: sd.range_low_rub,
hiRub: sd.range_high_rub,
medianPpm: sd.median_price_per_m2,
count: sd.count,
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
};
}
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,
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
),
};
}
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,
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(guardPriceOutliers(ppms).clean),
};
}
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;
// C3: clamp the domain floor to 0 (mirror buildPriceHistory) — an un-clamped
// yMin-yPad can go negative, which surfaces as a "1M" axis tick on a chart
// that only ever plots prices.
const domain: ScatterDomain = {
xMin: SCATTER_X_DOMAIN.min,
xMax: SCATTER_X_DOMAIN.max,
yMin: Math.max(0, 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 = real
* created_at (#2043/BE-1) when the estimate has one, falling back to the old
* expires24h approximation only for pre-BE-1 rows where created_at is absent.
*/
export function mapReport(e: AggregatedEstimate): Report {
return {
id: e.estimate_id.slice(0, 8),
date: e.created_at
? fmtDate(e.created_at)
: fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at
validUntil: fmtDate(e.expires_at),
};
}
/**
* Location-coefficient delta label shared by mapObject (HeroBar tile) and
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05]
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price
* deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the
* other honest deltas on this page), never a raw multiplier that would read as
* more precise than it is. "—" while absent/loading AND when
* geo_source="unavailable" (legitimate graceful fallback, not an error).
*/
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string {
if (lc == null || lc.geo_source === "unavailable") return "—";
return fmtPct((lc.coef - 1) * 100);
}
/**
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
* `coef` is the GET /trade-in/location-coef response (#2317) — optional/null
* while it is still loading or unavailable for this address.
*/
export function mapObject(
e: AggregatedEstimate,
coef?: LocationCoefResponse | null,
): 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: coefDeltaLabel(coef),
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
// null while the estimate has no geocode yet.
lat: e.target_lat,
lon: e.target_lon,
};
}
// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ─────────────────────────────
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
// backend/app/services/location_coef.py (top7 straight-line POI score). An
// unrecognised category (raw OSM tag outside that dict) falls back to a
// generic label rather than surfacing a raw enum-ish string to the user.
const POI_CATEGORY_RU: Record<string, string> = {
metro_stop: "Метро",
school: "Школа",
kindergarten: "Детский сад",
hospital: "Больница",
shop_mall: "ТРЦ",
shop_supermarket: "Супермаркет",
bus_stop: "Остановка",
park: "Парк",
pharmacy: "Аптека",
tram_stop: "Трамвайная остановка",
shop_small: "Магазин у дома",
};
const POI_CATEGORY_FALLBACK = "Объект инфраструктуры";
function poiCategoryLabel(poiType: string): string {
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
}
/** One POI row in the LocationDrawer factor list. */
export interface LocationFactorRow {
label: string; // POI name if known, else its RU category
category: string; // RU category label (always present, for the badge)
distance: string; // "150 м" / "1.2 км"
}
export interface LocationData {
// true when the backend actually computed a coefficient for this address
// (geo_source="osm_poi_ekb"), even if no POI were found within radius
// (factors=[] is then a legitimate empty result, not "no data").
available: boolean;
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽"
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽"
factors: LocationFactorRow[];
}
/**
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest
* unavailable state — never a fabricated coefficient, price or factor list
* (mirrors the backend's own graceful-fallback contract).
*/
export function mapLocation(
lc: LocationCoefResponse | null | undefined,
): LocationData {
if (lc == null || lc.geo_source === "unavailable") {
return {
available: false,
coefDelta: "—",
baseLabel: "—",
resultLabel: "—",
factors: [],
};
}
return {
available: true,
coefDelta: coefDeltaLabel(lc),
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`,
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`,
factors: lc.factors.map((f) => ({
label: f.name?.trim() || poiCategoryLabel(f.poi_type),
category: poiCategoryLabel(f.poi_type),
distance: fmtDist(f.distance_m),
})),
};
}
// ── 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. Finding #12: the closest pins overlapped on the small map, so
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
// apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
// the subject pin, never the fake fixture prices).
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
export interface MapMarker {
left: string; // e.g. "62%"
top: string; // e.g. "31%"
label: string; // "12,70 млн ₽"
sub: string; // "54 м²"
}
// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
// analogs toward the centre, smaller pushes them to the clamped margins.
const MAP_LAT_HALF_SPAN = 0.011;
const MAP_LON_HALF_SPAN = 0.018;
const MAP_CLAMP_LO = 4;
const MAP_CLAMP_HI = 96;
// Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST
// from every already-kept pin (Euclidean in the 0100% map box) so close analogs
// don't stack into an unreadable blob on the small map.
const MIN_PIN_DIST = 22;
const KEEP_CAP = 3;
// M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%),
// where ParamsPanel draws the subject pin. Reserve that spot so no analog price
// pin stacks under the subject's address caption (which made the subject look
// labelled with an analog's area/price).
const SUBJECT_POS = { left: 50, top: 50 };
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
if (e == null || e.target_lat == null || e.target_lon == null) return [];
const tLat = e.target_lat;
const tLon = e.target_lon;
const geo = e.analogs.filter(
(a): a is AnalogLot & { lat: number; lon: number } =>
a.lat != null &&
a.lon != null &&
Number.isFinite(a.lat) &&
Number.isFinite(a.lon),
);
if (geo.length === 0) return [];
// Closest first: real distance_m when known, else a degree-based estimate
// (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
const ranked = geo
.map((a) => {
const fallbackM =
Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
const dist =
a.distance_m != null && Number.isFinite(a.distance_m)
? a.distance_m
: fallbackM;
return { a, dist };
})
.sort((x, y) => x.dist - y.dist);
// Greedy non-overlap pass over the closest-first list: project each candidate
// to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every
// pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final
// map, where they become the MapMarker "NN%" strings.
const kept: { left: number; top: number; a: AnalogLot }[] = [];
for (const { a } of ranked) {
if (kept.length >= KEEP_CAP) break;
const left = clamp(
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
const top = clamp(
50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
// Drop analogs that land on the subject pin (centre) — otherwise the closest
// analog's "21,2 млн / 80 м²" reads as the subject's caption (M10).
const onSubject =
Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST;
if (onSubject) continue;
const overlaps = kept.some(
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
);
if (overlaps) continue;
kept.push({ left, top, a });
}
return kept.map(({ left, top, a }) => ({
left: `${round1(left)}%`,
top: `${round1(top)}%`,
label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}`,
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—",
}));
}
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
export function mapResultPanel(
e: AggregatedEstimate,
streetDeals?: StreetDealsResponse | null,
): ResultPanelData {
const dealTier = resolveDealTier(e, streetDeals);
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
// bare «—» when the model has no expected-sold prediction. When it is missing
// we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta;
// the view then moves the accent frame onto the «рекомендованная цена» card.
const hasExpected =
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub);
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)} · по объявлениям`,
// Fix #1/#8: bin over the SAME outlier-guarded pool the spread/CV uses
// (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
// 872k выброс this card already reports as "исключён", squashing the real
// analogs into the left bins while the number says the outlier is dropped.
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
nav: 2,
},
{
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
unit: "млн ₽",
range: hasExpected
? rangeLine(
e.expected_sold_range_low_rub,
e.expected_sold_range_high_rub,
)
: "",
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
delta:
hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: undefined,
// §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
// сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
// обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
deltaLabel: "к цене объявления",
// H1 empty-state message (only when there is no expected-sold prediction).
emptyNote: hasExpected
? undefined
: "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
note:
hasExpected &&
dealTier != null &&
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub) &&
e.expected_sold_price_rub < dealTier.medianRub
? "Обычно ниже медианы ДКП — другая выборка и площади"
: undefined,
nav: 2,
},
{
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
unit: "млн ₽",
range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
ppm: dealTier
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
dealTier.count,
["сделка", "сделки", "сделок"],
)}`
: "—",
bars: dealTier ? dealTier.bars : [],
nav: 1,
},
];
// Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ
// И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show.
const builtOnDeals = dealTier?.count ?? 0;
const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [
"АНАЛОГУ",
"АНАЛОГАМ",
"АНАЛОГАМ",
])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [
"СДЕЛКЕ",
"СДЕЛКАМ",
"СДЕЛКАМ",
])}`;
// Fix #1 — the ИСТОЧНИКИ ДАННЫХ tiles below sum lots from e.analogs (top-10
// persisted sample, backend #2087 M1), not the full e.n_analogs population
// that builtOn (above) reports. Disclose the gap so the two numbers on this
// one card never read as a single contradictory total.
const sourcesNote =
e.analogs.length < e.n_analogs
? `Разбивка — по показанным ${e.analogs.length} из ${e.n_analogs} аналогам`
: undefined;
const meta: ResultMeta = {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
builtOn,
sourcesNote,
};
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} дн` : ""}`
: "—";
// H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ
// headline (e.median_price_rub, the subject-area-normalised median). It used to
// show median(analogs.price_rub) — the RAW median of the analog LISTING totals
// across mixed areas — which for a small subject reads far higher (e.g. 14,23
// млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two
// contradictory medians on one screen. Now both surfaces read the same field.
const row2Value =
e.n_analogs > 0
? `${e.n_analogs}${
Number.isFinite(e.median_price_rub)
? ` · ${fmtMln(e.median_price_rub)} млн`
: ""
}`
: "—";
const dealCount = streetDeals?.count ?? e.actual_deals.length;
const dealMedian =
streetDeals?.median_price_rub ??
median(e.actual_deals.map((d) => d.price_rub));
const row3Value =
dealCount > 0
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
: "—";
const anaExp = kpi?.median_exposure_days ?? null;
const anaBargain = kpi?.median_bargain_pct ?? null;
const row4Parts = [
anaExp != null ? `${anaExp} дн` : null,
// Finding #10: negate the bargain so a price reduction reads "3%" (compact
// whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
anaBargain != null ? fmtPct(-anaBargain) : null,
].filter((p): p is string => p != null);
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
const rows: SummaryRow[] = [
{
// Finding #9: fed by house-analytics radius data (район, radius_m up to
// 300 m), NOT strictly the exact house → honest label "Продажи рядом".
label: "Продажи рядом",
value: row1Value,
dot: houseSold && houseSold > 0 ? "accent" : "muted",
nav: 1,
},
{
label: "Аналоги",
value: row2Value,
dot: e.n_analogs > 0 ? "accent" : "muted",
nav: 2,
},
// 'Сделки' is the design's secondary (muted) row regardless of count.
{ label: "Сделки", value: row3Value, dot: "muted", nav: 2 },
{
label: "Аналитика",
value: row4Value,
dot: row4Parts.length > 0 ? "accent" : "muted",
nav: 3,
},
];
return {
rows,
quality: {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence],
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
},
};
}
// ════════════════════════════════════════════════════════════════════════════
// 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 -> "—". */
export function numRu(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return Math.round(v).toLocaleString("ru-RU");
}
/** "{lo} {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—";
}
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
}
/** Same style as mlnRangeSp (marketAds P25P75), ru 2dp, NBSP en-dash. Missing -> "—". */
function mlnRangeTight(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—";
}
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
}
/** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "36,0%". */
function pct1(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
const r = Number(v.toFixed(1));
const sign = r < 0 ? "" : r > 0 ? "+" : "";
return `${sign}${Math.abs(r).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
}
/**
* Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
* median_bargain_pct is the median over house_placement_history of
* (start_price last_price) / start_price × 100
* (see backend trade_in.py), so a POSITIVE value means the asking price was
* REDUCED from its initial to its final value (the haggle / discount room).
* Feeding that straight through pct1 renders a misleading "+3.3%" — the "+"
* reads as a price INCREASE. We instead show the SIGNED change from initial →
* final price, so a reduction reads "3.3%", matching the tile sub-label
* "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—".
*/
function bargainSigned(pct: number | null | undefined): string {
if (pct == null || !Number.isFinite(pct)) return "—";
return pct1(-pct);
}
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
export function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—";
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
return `${(m / 1000).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}${NBSP}км`;
}
// Source key -> brand/RU display label (badges + link source). Delegates to the
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
export function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return registrySourceLabel(s.toLowerCase());
}
/** Rosreestr deal tier -> RU caption next to the badge. */
export function tierLabel(t: string | null | undefined): string {
if (!t) return "";
if (t.includes("house")) return "по дому";
if (t.includes("street")) return "по улице";
return "";
}
/** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */
function composeLot(
rooms: number | null,
area: number | null,
floor: number | null,
totalFloors: number | null,
): string {
const parts: string[] = [];
if (rooms == null) parts.push("Квартира");
else if (rooms <= 0) parts.push("Студия");
else parts.push(`${rooms}-к. квартира`);
if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
if (floor != null) {
parts.push(
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
);
}
return parts.join(", ");
}
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
export function adMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
if (l.floor != null) {
parts.push(
l.total_floors != null
? `этаж ${l.floor}/${l.total_floors}`
: `этаж ${l.floor}`,
);
}
return parts.join(" · ");
}
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
export function dealMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
return parts.join(" · ");
}
/**
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two
* tokens together with no separator — either a word glued onto the house number
* ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
* Insert a space wherever a digit OR a lowercase letter is immediately followed
* by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
* lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate
* single-letter house litereras (14А, 5Б, д.5К — capital NOT followed by
* lowercase) and do NOT touch already-separated tokens ("ул.Белинского" — the
* capital is preceded by ".", not a glue char). Pure display cleanup; never
* touches the comma-splitting parseAddress.
*/
export function deglueAddr(addr: string): string {
return (
addr
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
// §M8 — metro proximity glued straight into the address with no spaces
// ("Геологическая1115 мин.Чкаловская1620"): (a) split a Cyrillic word
// from a following digit ("Геологическая11" → "Геологическая 11"), and
// (b) separate a "мин." run from the next station name with " · "
// ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only.
.replace(/([а-яё])(\d)/g, "$1 $2")
.replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2")
);
}
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
export interface DkpRowData extends DkpRow {
addr?: string;
date?: string;
source?: string | null;
}
/** History composite + the per-street ДКП deal-vs-listing rows. */
export interface HistoryData extends History {
dkpRows: DkpRowData[];
}
/**
* House placement history + per-street ДКП corridor (street-deals) + paired
* deal↔listing rows (sales-vs-listings). Each source is independent: a null one
* leaves its block at "—"/empty without blanking the others.
*/
export function mapHistory(
placement: PlacementHistoryItem[] | null,
streetDeals: StreetDealsResponse | null,
salesVsListings: SalesVsListingsResponse | null,
): HistoryData {
const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({
lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors),
priceFrom: fmtMln(p.start_price),
priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—",
date: fmtDate(p.last_price_date),
exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—",
}));
const street = streetDeals?.street ?? null;
const title = street
? `ДКП-сделки на улице ${street}`
: "ДКП-сделки на вашей улице";
const period =
salesVsListings?.period_months != null
? `${salesVsListings.period_months} мес`
: "—";
const noteParts: string[] = [];
if (salesVsListings?.linkage_rate_pct != null) {
noteParts.push(
`${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`,
);
}
if (salesVsListings?.median_discount_pct != null) {
noteParts.push(
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
);
}
const note =
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
"Данные по улице, не по дому.";
const dkpKpi = {
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
median:
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
? numRu(streetDeals.median_price_per_m2)
: "—",
range:
streetDeals != null
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
: "—",
};
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
const ask =
pr.listing_price_rub != null
? `${numRu(pr.listing_price_rub)}${
pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0
? ` за ${pr.days_listing_to_deal} дн до`
: ""
}`
: "—";
const deltaColor =
pr.discount_pct == null
? tokens.muted
: pr.discount_pct < 0
? tokens.success
: tokens.danger;
return {
area: fmtArea(pr.deal_area_m2),
price: `${numRu(pr.deal_price_rub)}`,
ppm: numRu(pr.deal_price_per_m2),
ask,
delta: pct1(pr.discount_pct),
deltaColor,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
date: fmtMonthYear(pr.deal_date),
source: pr.listing_source,
};
});
return { houseSales, dkpHeader: { title, period, note }, dkpKpi, dkpRows };
}
// ── 06 АНАЛИТИКА ДОМА (AnalyticsView) ───────────────────────────────────────
// Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
// V1: median_bargain_pct > 0 means the asking price was REDUCED (see the
// bargainSigned doc comment below) — the common, honest "price dropped"
// case, which bargainSigned negates into the displayed "X%". That displayed
// drop is the one the fixture (analytics.kpis[1], "3.0%") colours success —
// NOT a raw negative pct (which would mean the price rose and bargainSigned
// would show a misleading "+X%" in green). The old `< 0` check had this
// backwards.
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 && Number.isFinite(k.sold_rate_pct)
? `${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,
insufficientData: b.insufficient_data === true,
};
});
}
// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
// so a narrow series like 120160k fills the plot instead of flat-lining against
// a fixed 0200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
// (M5) must share this EXACT geometry with the polyline vertices below, or a
// label drifts off its own dot. Single source of truth, no copy-pasted consts.
export const PH_X_LEFT = 58;
export const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
// Exported (not just local) so AnalyticsView's background gridlines share this
// EXACT geometry with the yTick labels below (C5) — a hardcoded copy in the view
// drifted out of sync with this array; single source of truth like phYearX/PH_X_*.
export const PH_GRID_Y = [20, 64, 108, 151, 195]; // 5 gridlines, evenly spaced (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
// NB: 'avito_imv'/'yandex_valuation' are scrape-origin discriminators on real
// archived listing rows (house_placement_history), NOT AVM model valuations —
// the card plots median ₽/m² of actual past listings. (LOW audit R2 #8, verified.)
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
export function phYearX(i: number, n: number): number {
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = {
note: "Нет данных по истории цен",
years: [],
avito: "",
yandex: "",
yTicks: [],
};
if (!a || a.price_history.length === 0) return empty;
const ph = a.price_history;
const yearsNum = Array.from(new Set(ph.map((p) => p.year))).sort(
(x, y) => x - y,
);
if (yearsNum.length === 0) return empty;
const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
const n = yearsNum.length;
// Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw
// so the polylines fill the plot band instead of squashing against 0200k.
const allPpm = ph
.filter(
(p) =>
PH_SERIES_SOURCES.includes(p.source) &&
Number.isFinite(p.median_price_per_m2),
)
.map((p) => p.median_price_per_m2);
if (allPpm.length === 0) return empty;
let yMin = Math.min(...allPpm);
let yMax = Math.max(...allPpm);
if (yMin === yMax) {
yMin = yMin * 0.97;
yMax = yMax * 1.03;
} else {
const pad = (yMax - yMin) * 0.08;
yMin -= pad;
yMax += pad;
}
yMin = Math.max(0, yMin);
const span = yMax - yMin || 1;
const priceY = (ppm: number): number =>
clamp(
PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN,
PH_Y_TOP,
PH_Y_BOTTOM,
);
const series = (src: string): string =>
ph
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
.sort((x, y) => x.year - y.year)
.map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
const y = Math.round(priceY(p.median_price_per_m2));
return `${x},${y}`;
})
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;
const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => {
const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span;
return { label: `${Math.round(v / 1000)}k`, y: gy };
});
return {
note,
years: yearsNum.map(String),
avito: series("avito_imv"),
yandex: series("yandex_valuation"),
yTicks,
};
}
// Detail "ЦЕНА × СРОК ПРОДАЖИ" scatter (viewBox 0 0 900 300). Pixel box + x-ticks
// come from the design scaffold; y-tick LABELS are recomputed for the data domain
// (same approach as buildScatterMini). Exposure x reuses the listing_date
// "days listed so far" fallback for active analogs (deals require real DOM).
const DETAIL_BOX: ScatterBox = { left: 60, right: 880, top: 24, bottom: 252 };
const DETAIL_X_DOMAIN = { min: 0, max: 180 };
const DETAIL_X_TICKS: AxisTickX[] = [
{ label: "0", x: 60 },
{ label: "45", x: 265 },
{ label: "90", x: 470 },
{ label: "135", x: 675 },
{ label: "180", x: 880 },
];
const DETAIL_Y_TICK_PX = [24, 81, 138, 195, 252];
function detailDomReal(l: AnalogLot): number | null {
return l.days_on_market != null && Number.isFinite(l.days_on_market)
? l.days_on_market
: null;
}
function detailDomListed(l: AnalogLot): number | null {
if (l.listing_date == null) return null;
const t = new Date(l.listing_date).getTime();
if (Number.isNaN(t)) return null;
const days = Math.round((Date.now() - t) / 86_400_000);
return days >= 0 && days < 3650 ? days : null;
}
function detailExposurePts(
lots: AnalogLot[],
dom: (l: AnalogLot) => number | null,
): { x: number; y: number }[] {
return lots
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
);
}
function buildScatterDetail(
e: AggregatedEstimate | null,
radiusM: number | null,
): ScatterDetail {
const fallbackTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((y) => ({
label: "—",
y,
}));
const emptySubject: ScatterPoint = { x: 470, y: 138, r: 6 };
if (!e) {
return {
note: "—",
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
const dealsRaw = detailExposurePts(e.actual_deals, detailDomReal);
const analogsRaw = detailExposurePts(
e.analogs,
(l) => detailDomReal(l) ?? detailDomListed(l),
);
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market;
const nDeals = e.actual_deals.length;
const nAnalogs = e.analogs.length;
const note = `${nDeals} ${pluralRu(nDeals, [
"сделка",
"сделки",
"сделок",
])} и ${nAnalogs} ${pluralRu(nAnalogs, [
"аналог",
"аналога",
"аналогов",
])}${radiusM != null && radiusM > 0 ? ` в радиусе ${radiusM} м` : ""}`;
const allY = [...dealsRaw, ...analogsRaw]
.map((p) => p.y)
.filter((y) => Number.isFinite(y));
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
if (allY.length === 0) {
return {
note,
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
let yMin = Math.min(...allY);
let yMax = Math.max(...allY);
if (yMin === yMax) {
yMin *= 0.95;
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
// C3: clamp the domain floor to 0 (mirror buildPriceHistory / buildScatterMini)
// — an un-clamped yMin-yPad can go negative, surfacing a "1M" axis tick.
const domain: ScatterDomain = {
xMin: DETAIL_X_DOMAIN.min,
xMax: DETAIL_X_DOMAIN.max,
yMin: Math.max(0, yMin - yPad),
yMax: yMax + yPad,
};
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
// V4: est_days_on_market is honestly unknown for a meaningful share of
// estimates. Falling back to a silent 90 plotted the subject as if its
// exposure were a real measurement. Keep the fallback (dropping the point
// entirely would lose a real, known subjectPrice) but flag it so the view
// can mark the dot as an estimate instead of presenting it as data.
const subjectApprox = Number.isFinite(subjectPrice) && subjectDays == null;
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,
subjectApprox,
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;
/** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV,
* flagged with a "возможный выброс" badge instead of silently distorting it. */
outlier?: boolean;
}
export interface DealRowData extends DealRow {
/** Source brand label for the badge (was hardcoded "Росреестр"). */
source?: string;
/** Tier caption next to the badge (was hardcoded "по улице"). */
tier?: string;
/** Deal/listing date (was hardcoded "01.01.26"). */
date?: string;
}
export interface SourcesData {
adRows: AdRowData[];
dealRows: DealRowData[];
marketAds: MarketAds;
marketDeals: MarketDeals;
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
sourceCount?: string;
/**
* Fix #1/#8 — disclosure caption under the ads table: "Показано N из M"
* (adRows.length is the top-10 display sample; marketAds.kpi.count/n_analogs
* is the true total used in calc — mirrors the deals-table footer below) plus
* an outlier-exclusion note when guardPriceOutliers flagged any row.
*/
adsFootnote?: string;
}
// Filter chips: distance corridor + rooms + analog area span (design order).
function buildAdFilters(e: AggregatedEstimate | null): string[] {
if (!e) return [];
const filters: string[] = [];
const dists = e.analogs
.map((a) => a.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
if (dists.length > 0) {
const maxKm = Math.max(...dists) / 1000;
const km =
maxKm < 1
? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
: String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
}
// Planning chip — derived from the rooms ACTUALLY present in the rendered
// analog set (same source as the distance/area chips), NOT from the subject's
// e.rooms. Analogs are selected by area band + geo proximity, NOT by room
// count, so the set may span several planning types. Only claim a concrete
// "N-к" filter when the analogs are genuinely homogeneous; otherwise stay
// neutral ("все планировки") so the chip never asserts a filter that was not
// applied to the data.
const analogRooms = Array.from(
new Set(
e.analogs
.map((a) => a.rooms)
.filter((r): r is number => r != null && Number.isFinite(r)),
),
);
if (analogRooms.length === 1) {
const r = analogRooms[0];
filters.push(`Планировка ${r <= 0 ? "студия" : `${r}`}`);
} else if (analogRooms.length > 1) {
filters.push("Все планировки");
}
const areas = e.analogs
.map((a) => a.area_m2)
.filter((x) => Number.isFinite(x));
if (areas.length > 0) {
const lo = Math.min(...areas).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const hi = Math.max(...areas).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
filters.push(`Площадь ${lo}${NBSP}${NBSP}${hi}${NBSP}м²`);
}
return filters;
}
/**
* 05 РЫНОК: analog ad rows + actual-deal rows + the two market KPI bands. Deal
* aggregates prefer the per-street ДКП corridor (streetDeals), falling back to
* the estimate's own actual_deals.
*/
export function mapSources(
estimate: AggregatedEstimate | null,
streetDeals: StreetDealsResponse | null,
): SourcesData {
const e = estimate;
// Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently
// fold them into the median/spread math).
const { isOutlier: adOutliers, excludedCount: adOutlierCount } =
guardPriceOutliers((e?.analogs ?? []).map((l) => l.price_per_m2));
const adRows: AdRowData[] = (e?.analogs ?? []).map((l, i) => ({
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,
outlier: adOutliers[i] ?? false,
}));
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
addr: deglueAddr(l.address || "—"),
meta: dealMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),
source: sourceLabel(l.source),
tier: tierLabel(l.tier),
date: fmtMonthYear(l.listing_date),
}));
const marketAds: MarketAds = {
kpi: {
count: e != null ? String(e.n_analogs) : "—",
median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm:
e != null && Number.isFinite(e.median_price_per_m2)
? numRu(e.median_price_per_m2)
: "—",
range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—",
cv: cvLabel(e),
},
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) : "—",
// §M8 — единая формулировка дельт: всё считается «относительно цен
// объявлений» (deltaPct = медиана сделок / медиана объявлений 1).
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
range: mlnRangeSp(dealLo, dealHi),
},
};
// Fix #1 — adRows is the top-10 display sample; marketAds.kpi.count/n_analogs
// is the true total used in the calc (backend: "Консьюмер должен брать счёт
// отсюда, а не из len()"). Disclose the gap the same way the deals table
// already does ("Показано N из M"), plus the outlier exclusion from Fix #8.
const outlierNote =
adOutlierCount > 0
? ` · ${adOutlierCount} ${pluralRu(adOutlierCount, [
"возможный выброс исключён",
"возможных выброса исключено",
"возможных выбросов исключено",
])} из расчёта разброса`
: "";
const adsFootnote =
e != null
? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
: undefined;
return {
adRows,
dealRows,
marketAds,
marketDeals,
sourceCount: estimate
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
: "—",
adsFootnote,
};
}
// ── 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);
}
/**
* #2420 — ПАРАМЕТРЫ column: "2 комн · 54 м²" / "2 комн · 54 м² · 5/9 эт.".
* rooms + area are independent (each omitted if unknown); floor segment ONLY
* appended when BOTH floor and total_floors are known (a lone floor without a
* total is misleading — omitted entirely, no "5/? эт."). "—" when neither
* rooms nor area is available (mirrors `addr: h.address ? … : "—"` above).
*/
function cacheParams(
rooms: number | null,
areaM2: string | null,
floor: number | null,
totalFloors: number | null,
): string {
const area = areaM2 != null ? Number(areaM2) : null;
const parts: string[] = [];
if (rooms != null) parts.push(rooms <= 0 ? "студия" : `${rooms} комн`);
if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
if (parts.length === 0) return "—";
if (floor != null && totalFloors != null) {
parts.push(`${floor}/${totalFloors} эт.`);
}
return parts.join(" · ");
}
/** Свежесть оценки 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/время/статус/params по каждой строке (src всегда "—", TODO BE-1).
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика).
*/
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const list = history ?? [];
const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at);
return {
id: h.id,
// §M8 — de-glue metro/address runs the same way as the analog/deal rows
// (these are the user's own past-estimate addresses, often geocoded with
// metro proximity jammed on with no separators).
addr: h.address ? deglueAddr(h.address) : "—",
time: fmtCacheTime(h.created_at),
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
status,
statusColor,
params: cacheParams(h.rooms, h.area_m2, h.floor, h.total_floors),
};
});
// 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;
// §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
// meaning for the end user, so it is dropped from this витрина. Only the two
// user-relevant KPIs remain.
const kpis: CacheKpi[] = [
{
label: "ВСЕГО ОЦЕНОК",
value: String(total),
// #2479-audit: useEstimateHistory() caps at 50 (trade-in-api.ts:247) — this
// is a page count, not a true lifetime total, so it must not read as one.
sub: "последние 50",
},
{
label: "СРЕДНЯЯ ЦЕНА",
value: avg != null ? fmtMln(avg) : "—",
unit: avg != null ? "млн ₽" : undefined,
sub: "по проведённым оценкам",
},
];
return { rows, kpis };
}