QA on real prod estimate 701795d8 surfaced two mapper defects:
- STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never
matched → object.address fell back to the bare house number ('14/3') and city
mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first)
and DaData (city-first) orders; street keyword delimited by space, not \b. Now
'улица Яскина, 14/3' + city 'Екатеринбург'.
- days_on_market is null across prod lots → scatter-mini rendered empty. Active
analogs now fall back to 'days listed so far' = today-listing_date (deals still
require a real days_on_market — today-deal-date is not exposure). TODO BE-1.
Verified via tsx harness against the real estimate (25/25); next build green.
783 lines
28 KiB
TypeScript
783 lines
28 KiB
TypeScript
// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
|
||
//
|
||
// These functions take the real trade-in API objects (AggregatedEstimate,
|
||
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
|
||
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
|
||
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
|
||
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
|
||
// renders. ALL money/format/geometry lives here — never copy fixture literals.
|
||
//
|
||
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
|
||
// BE-1 created_at, cv, per-source lot counts are NOT in the API response.
|
||
// Report date is derived as expires_at − 24h; cv is the FE coefficient
|
||
// of variation of analog ₽/м²; source counts are an FE groupBy of
|
||
// analogs + actual_deals by .source.
|
||
// BE-2 target_address is a single string; street/city are split heuristically
|
||
// (parseAddress). Backend should return structured address components.
|
||
// BE-3 location coefficient / street-view caption / compass bearing are not in
|
||
// the API → surfaced as placeholders.
|
||
//
|
||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||
// repair : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair)
|
||
// See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below.
|
||
|
||
import type {
|
||
AggregatedEstimate,
|
||
AnalogLot,
|
||
ConfidenceLevel,
|
||
HouseAnalyticsResponse,
|
||
HouseType,
|
||
RepairState,
|
||
StreetDealsResponse,
|
||
} from "@/types/trade-in";
|
||
import type {
|
||
AxisTickX,
|
||
AxisTickY,
|
||
ObjectInfo,
|
||
RangeBar,
|
||
RangeMarker,
|
||
Ranges,
|
||
Report,
|
||
ResultCard,
|
||
ResultMeta,
|
||
ScatterMini,
|
||
ScatterPoint,
|
||
SourceCard,
|
||
Summary,
|
||
SummaryRow,
|
||
} from "./types";
|
||
|
||
// Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7".
|
||
const TOTAL_SOURCES = 7;
|
||
|
||
// ── Composite presentation shapes consumed by the wired components ──────────
|
||
// One `data` prop per component; each field keeps its exact ./types shape.
|
||
|
||
export interface ResultPanelData {
|
||
cards: ResultCard[];
|
||
meta: ResultMeta;
|
||
ranges: Ranges;
|
||
scatterMini: ScatterMini;
|
||
sources: SourceCard[];
|
||
}
|
||
|
||
export interface HeroBarData {
|
||
report: Report;
|
||
object: ObjectInfo;
|
||
}
|
||
|
||
export interface ObjectSummaryData {
|
||
object: ObjectInfo;
|
||
summary: Summary;
|
||
}
|
||
|
||
// ── Enum <-> RU maps ────────────────────────────────────────────────────────
|
||
|
||
// enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches
|
||
// a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU.
|
||
export const HOUSE_TYPE_RU: Record<HouseType, string> = {
|
||
panel: "Панельный",
|
||
brick: "Кирпичный",
|
||
monolith: "Монолитный",
|
||
monolith_brick: "Монолитно-кирпичный",
|
||
other: "Блочный",
|
||
};
|
||
|
||
// enum -> display label (mapObject).
|
||
export const REPAIR_RU: Record<RepairState, string> = {
|
||
needs_repair: "Требует ремонта",
|
||
standard: "Удовлетворительный",
|
||
good: "Хороший",
|
||
excellent: "Отличный",
|
||
};
|
||
|
||
// enum -> RU lower-case (summary quality block; meta uses .toUpperCase()).
|
||
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
||
low: "низкая",
|
||
medium: "средняя",
|
||
high: "высокая",
|
||
};
|
||
|
||
// RU dropdown label -> enum (Wire-phase form submit). Reconciles the design's
|
||
// extra options that have no enum value (see header comment).
|
||
export const HOUSE_TYPE_FROM_RU: Record<string, HouseType> = {
|
||
Панельный: "panel",
|
||
Кирпичный: "brick",
|
||
Монолитный: "monolith",
|
||
"Монолитно-кирпичный": "monolith_brick",
|
||
Блочный: "other",
|
||
Другой: "other",
|
||
};
|
||
|
||
export const REPAIR_FROM_RU: Record<string, RepairState> = {
|
||
"Без отделки": "needs_repair",
|
||
"Требует ремонта": "needs_repair",
|
||
Удовлетворительный: "standard",
|
||
Хороший: "good",
|
||
Отличный: "excellent",
|
||
};
|
||
|
||
// ── Format helpers (ru) ─────────────────────────────────────────────────────
|
||
|
||
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
|
||
export function fmtMln(rub: number | null | undefined): string {
|
||
if (rub == null || !Number.isFinite(rub)) return "—";
|
||
return (rub / 1e6).toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
});
|
||
}
|
||
|
||
/** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */
|
||
export function fmtPpm(ppm: number | null | undefined): string {
|
||
if (ppm == null || !Number.isFinite(ppm)) return "—";
|
||
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
|
||
}
|
||
|
||
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
|
||
export function fmtDate(iso: string | null | undefined): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
return d.toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
|
||
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */
|
||
function fmtDateShift(iso: string | null | undefined, hours: number): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
d.setHours(d.getHours() + hours);
|
||
return d.toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
|
||
/** Signed percent with proper ru minus. -3 -> "−3%", 5 -> "+5%", 0 -> "0%". */
|
||
export function fmtPct(v: number | null | undefined, digits = 0): string {
|
||
if (v == null || !Number.isFinite(v)) return "—";
|
||
const rounded = digits > 0 ? Number(v.toFixed(digits)) : Math.round(v);
|
||
const sign = rounded < 0 ? "−" : rounded > 0 ? "+" : "";
|
||
return `${sign}${Math.abs(rounded)}%`;
|
||
}
|
||
|
||
/** Russian plural picker. forms = [one, few, many]. */
|
||
export function pluralRu(n: number, forms: [string, string, string]): string {
|
||
const a = Math.abs(n) % 100;
|
||
const b = a % 10;
|
||
if (a > 10 && a < 20) return forms[2];
|
||
if (b > 1 && b < 5) return forms[1];
|
||
if (b === 1) return forms[0];
|
||
return forms[2];
|
||
}
|
||
|
||
// ── Numeric helpers ─────────────────────────────────────────────────────────
|
||
|
||
function clamp(v: number, lo: number, hi: number): number {
|
||
return Math.max(lo, Math.min(hi, v));
|
||
}
|
||
|
||
function round1(v: number): number {
|
||
return Math.round(v * 10) / 10;
|
||
}
|
||
|
||
function median(values: number[]): number | null {
|
||
const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
||
if (clean.length === 0) return null;
|
||
const mid = Math.floor(clean.length / 2);
|
||
return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2;
|
||
}
|
||
|
||
/** Coefficient of variation (%) of positive values; null if <2 samples. */
|
||
function coeffVar(values: number[]): number | null {
|
||
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
|
||
if (clean.length < 2) return null;
|
||
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
|
||
if (mean === 0) return null;
|
||
const variance =
|
||
clean.reduce((a, b) => a + (b - mean) ** 2, 0) / clean.length;
|
||
return (Math.sqrt(variance) / mean) * 100;
|
||
}
|
||
|
||
function cvStr(values: number[]): string {
|
||
const cv = coeffVar(values);
|
||
return cv != null ? `${cv.toFixed(1)}%` : "—";
|
||
}
|
||
|
||
/**
|
||
* Bin `values` into 8 buckets across [min,max]; return per-bucket height as
|
||
* count/maxCount*100 (the mini-histogram bar heights). Empty input -> [].
|
||
*/
|
||
export function bins8(values: number[]): number[] {
|
||
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
|
||
if (clean.length === 0) return [];
|
||
const min = Math.min(...clean);
|
||
const max = Math.max(...clean);
|
||
if (max === min) {
|
||
// Degenerate: all equal -> single central bar.
|
||
const bars = new Array<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 0–180 days so the x-ticks stay valid; Y domain is data-driven and
|
||
// the y-tick LABELS are recomputed for the chosen pixel positions.
|
||
const SCATTER_BOX: ScatterBox = { left: 34, right: 158, top: 13, bottom: 112 };
|
||
const SCATTER_X_DOMAIN = { min: 0, max: 180 };
|
||
const SCATTER_X_TICKS: AxisTickX[] = [
|
||
{ label: "0", x: 34 },
|
||
{ label: "90", x: 96 },
|
||
{ label: "180", x: 158 },
|
||
];
|
||
const SCATTER_Y_TICK_PX = [13, 77, 112]; // top, mid, bottom
|
||
|
||
function mlnTick(rub: number): string {
|
||
return `${Math.round(rub / 1e6)}М`;
|
||
}
|
||
|
||
function buildScatterMini(
|
||
e: AggregatedEstimate,
|
||
sd?: StreetDealsResponse | null,
|
||
): ScatterMini {
|
||
// Exposure (x-axis). days_on_market is sparsely populated in prod, so for
|
||
// ACTIVE analogs fall back to "days listed so far" = today − listing_date
|
||
// (a valid lower bound on time-to-sell). Deals keep requiring a real
|
||
// days_on_market — today−deal-date is not exposure. TODO BE-1: populate days_on_market.
|
||
const daysListed = (l: AnalogLot): number | null => {
|
||
if (l.listing_date == null) return null;
|
||
const t = new Date(l.listing_date).getTime();
|
||
if (Number.isNaN(t)) return null;
|
||
const days = Math.round((Date.now() - t) / 86_400_000);
|
||
return days >= 0 && days < 3650 ? days : null;
|
||
};
|
||
const realDom = (l: AnalogLot): number | null =>
|
||
l.days_on_market != null && Number.isFinite(l.days_on_market)
|
||
? l.days_on_market
|
||
: null;
|
||
const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) =>
|
||
lots
|
||
.map((l) => ({ x: dom(l), y: l.price_rub }))
|
||
.filter(
|
||
(p): p is { x: number; y: number } =>
|
||
p.x != null && Number.isFinite(p.y),
|
||
);
|
||
|
||
const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom);
|
||
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
|
||
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
||
const subjectDays = e.est_days_on_market;
|
||
|
||
const allY = [...dealsRaw, ...analogsRaw]
|
||
.map((p) => p.y)
|
||
.filter((y) => Number.isFinite(y));
|
||
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
|
||
|
||
const fallbackTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((y) => ({
|
||
label: "—",
|
||
y,
|
||
}));
|
||
|
||
if (allY.length === 0) {
|
||
return {
|
||
deals: [],
|
||
analogs: [],
|
||
subject: { x: 96, y: 62, r: 3.6 },
|
||
yTicks: fallbackTicks,
|
||
xTicks: SCATTER_X_TICKS,
|
||
};
|
||
}
|
||
|
||
let yMin = Math.min(...allY);
|
||
let yMax = Math.max(...allY);
|
||
if (yMin === yMax) {
|
||
yMin *= 0.95;
|
||
yMax *= 1.05;
|
||
}
|
||
const yPad = (yMax - yMin) * 0.08;
|
||
const domain: ScatterDomain = {
|
||
xMin: SCATTER_X_DOMAIN.min,
|
||
xMax: SCATTER_X_DOMAIN.max,
|
||
yMin: yMin - yPad,
|
||
yMax: yMax + yPad,
|
||
};
|
||
|
||
const deals = scatterProject(dealsRaw, domain, SCATTER_BOX, 2.4);
|
||
const analogs = scatterProject(analogsRaw, domain, SCATTER_BOX, 3);
|
||
// Subject: real est_days if known, else mid-window (90 days).
|
||
const subject = Number.isFinite(subjectPrice)
|
||
? scatterProject(
|
||
[{ x: subjectDays ?? 90, y: subjectPrice }],
|
||
domain,
|
||
SCATTER_BOX,
|
||
3.6,
|
||
)[0]
|
||
: { x: 96, y: 62, r: 3.6 };
|
||
|
||
const yTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((py) => {
|
||
const frac =
|
||
(SCATTER_BOX.bottom - py) / (SCATTER_BOX.bottom - SCATTER_BOX.top);
|
||
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
|
||
return { label: mlnTick(price), y: py };
|
||
});
|
||
|
||
return { deals, analogs, subject, yTicks, xTicks: SCATTER_X_TICKS };
|
||
}
|
||
|
||
// ── Public mappers ──────────────────────────────────────────────────────────
|
||
|
||
/** Report header (HeroBar / Footer). id = first 8 of UUID; date = expires−24h. */
|
||
export function mapReport(e: AggregatedEstimate): Report {
|
||
return {
|
||
id: e.estimate_id.slice(0, 8),
|
||
date: fmtDateShift(e.expires_at, -24), // TODO BE-1: real created_at
|
||
validUntil: fmtDate(e.expires_at),
|
||
};
|
||
}
|
||
|
||
/** Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). */
|
||
export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||
const { address, city } = parseAddress(e.target_address);
|
||
return {
|
||
address,
|
||
city,
|
||
area: numLabel(e.area_m2),
|
||
rooms: roomsLabel(e.rooms),
|
||
floor: numLabel(e.floor),
|
||
totalFloors: numLabel(e.total_floors),
|
||
year: numLabel(e.year_built),
|
||
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||
balcony: e.has_balcony ?? false,
|
||
locationCoef: "—", // TODO BE-3
|
||
streetView: "", // TODO BE-3
|
||
compass: "", // TODO BE-3
|
||
};
|
||
}
|
||
|
||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||
export function mapResultPanel(
|
||
e: AggregatedEstimate,
|
||
streetDeals?: StreetDealsResponse | null,
|
||
): ResultPanelData {
|
||
const dealTier = resolveDealTier(e, streetDeals);
|
||
|
||
const cards: ResultCard[] = [
|
||
{
|
||
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||
value: fmtMln(e.median_price_rub),
|
||
unit: "млн ₽",
|
||
range: rangeLine(e.range_low_rub, e.range_high_rub),
|
||
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
|
||
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
|
||
nav: 2,
|
||
},
|
||
{
|
||
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
|
||
value: fmtMln(e.expected_sold_price_rub),
|
||
unit: "млн ₽",
|
||
range: rangeLine(e.expected_sold_range_low_rub, e.expected_sold_range_high_rub),
|
||
ppm: fmtPpm(e.expected_sold_per_m2),
|
||
delta:
|
||
e.asking_to_sold_ratio != null
|
||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||
: "—",
|
||
deltaLabel: "К РЫНКУ",
|
||
nav: 2,
|
||
},
|
||
{
|
||
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
|
||
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
|
||
unit: "млн ₽",
|
||
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных",
|
||
ppm: dealTier
|
||
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
|
||
dealTier.count,
|
||
["сделка", "сделки", "сделок"],
|
||
)}`
|
||
: "—",
|
||
bars: dealTier ? dealTier.bars : [],
|
||
nav: 1,
|
||
},
|
||
];
|
||
|
||
const meta: ResultMeta = {
|
||
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
||
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
||
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||
};
|
||
|
||
const adsBar: RangeBar = {
|
||
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
|
||
median: `${fmtMln(e.median_price_rub)} млн ₽`,
|
||
lo: `${fmtMln(e.range_low_rub)} млн`,
|
||
hi: `${fmtMln(e.range_high_rub)} млн`,
|
||
marker: rangeMarker(e.range_low_rub, e.median_price_rub, e.range_high_rub),
|
||
};
|
||
|
||
const dealsBar: RangeBar = {
|
||
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
|
||
median: dealTier ? `${fmtMln(dealTier.medianRub)} млн ₽` : "—",
|
||
lo: dealTier ? `${fmtMln(dealTier.loRub)} млн` : "—",
|
||
hi: dealTier ? `${fmtMln(dealTier.hiRub)} млн` : "—",
|
||
marker: dealTier
|
||
? rangeMarker(dealTier.loRub, dealTier.medianRub, dealTier.hiRub)
|
||
: { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" },
|
||
};
|
||
|
||
return {
|
||
cards,
|
||
meta,
|
||
ranges: { ads: adsBar, deals: dealsBar },
|
||
scatterMini: buildScatterMini(e, streetDeals),
|
||
sources: buildSources(e),
|
||
};
|
||
}
|
||
|
||
/** 03 СВОДКА ОБЪЕКТА: per-section totals + data-quality block. */
|
||
export function mapSummary(
|
||
e: AggregatedEstimate,
|
||
analytics?: HouseAnalyticsResponse | null,
|
||
streetDeals?: StreetDealsResponse | null,
|
||
): Summary {
|
||
const kpi = analytics?.kpi;
|
||
|
||
const houseSold = kpi?.sold_count ?? null;
|
||
const houseExp = kpi?.median_exposure_days ?? null;
|
||
const row1Value =
|
||
houseSold != null
|
||
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
|
||
: "—";
|
||
|
||
const analogMedian = median(e.analogs.map((a) => a.price_rub));
|
||
const row2Value =
|
||
e.n_analogs > 0
|
||
? `${e.n_analogs}${
|
||
analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : ""
|
||
}`
|
||
: "—";
|
||
|
||
const dealCount = streetDeals?.count ?? e.actual_deals.length;
|
||
const dealMedian =
|
||
streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.price_rub));
|
||
const row3Value =
|
||
dealCount > 0
|
||
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
|
||
: "—";
|
||
|
||
const anaExp = kpi?.median_exposure_days ?? null;
|
||
const anaBargain = kpi?.median_bargain_pct ?? null;
|
||
const row4Parts = [
|
||
anaExp != null ? `${anaExp} дн` : null,
|
||
anaBargain != null ? fmtPct(anaBargain) : null,
|
||
].filter((p): p is string => p != null);
|
||
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
||
|
||
const rows: SummaryRow[] = [
|
||
{
|
||
label: "Продажи в доме",
|
||
value: row1Value,
|
||
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
||
nav: 1,
|
||
},
|
||
{
|
||
label: "Аналоги",
|
||
value: row2Value,
|
||
dot: e.n_analogs > 0 ? "accent" : "muted",
|
||
nav: 2,
|
||
},
|
||
// 'Сделки' is the design's secondary (muted) row regardless of count.
|
||
{ label: "Сделки", value: row3Value, dot: "muted", nav: 2 },
|
||
{
|
||
label: "Аналитика",
|
||
value: row4Value,
|
||
dot: row4Parts.length > 0 ? "accent" : "muted",
|
||
nav: 3,
|
||
},
|
||
];
|
||
|
||
return {
|
||
rows,
|
||
quality: {
|
||
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
||
confidence: CONFIDENCE_RU[e.confidence],
|
||
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||
},
|
||
};
|
||
}
|