All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после honest-hero раунда — фиксы: - H1: честный fallback-state флагманской карточки при expected_sold_price=null (вместо четырёх прочерков) - H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub - M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля "-N% к цене объявления", дисклеймер разброса читаем) - M4: PDF-отчёт primary после результата (без добавления lead-CTA — открытый продуктовый вопрос, не решался) - M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) — geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах - M6: gate немонотонного "срок продажи" при n<5 аналогов - M7: сброс scroll оверлея при смене секции - M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен", P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный "Обновлено: —", метро-разделители в адресе, убран внутренний "ПОВТОРНЫЕ АДРЕСА" label) - Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1 (accent → accentDeep, существующий токен той же палитры) Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR. Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался. Refs #2266, #2262
1981 lines
73 KiB
TypeScript
1981 lines
73 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 #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/mapResultPanel/mapSummary/mapCache still use the
|
||
// pre-BE-1 approximations (created_at shift, 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 / street-view caption / compass bearing are not in
|
||
// the API → surfaced as placeholders.
|
||
//
|
||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||
// repair : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair)
|
||
// See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below.
|
||
|
||
import type {
|
||
AggregatedEstimate,
|
||
AnalogLot,
|
||
ConfidenceLevel,
|
||
EstimateHistoryItem,
|
||
HouseAnalyticsKpi,
|
||
HouseAnalyticsResponse,
|
||
HouseType,
|
||
PlacementHistoryItem,
|
||
RepairState,
|
||
SalesVsListingsResponse,
|
||
SellTimeSensitivityResponse,
|
||
StreetDealsResponse,
|
||
} from "@/types/trade-in";
|
||
import type {
|
||
AdRow,
|
||
Analytics,
|
||
AnalyticsKpi,
|
||
AxisTickX,
|
||
AxisTickY,
|
||
CacheKpi,
|
||
CacheRow,
|
||
DealRow,
|
||
DkpRow,
|
||
History,
|
||
HouseSaleRow,
|
||
MarketAds,
|
||
MarketDeals,
|
||
ObjectInfo,
|
||
PriceHistory,
|
||
RangeBar,
|
||
RangeMarker,
|
||
Ranges,
|
||
Report,
|
||
ResultCard,
|
||
ResultMeta,
|
||
ScatterDetail,
|
||
ScatterMini,
|
||
ScatterPoint,
|
||
SellTimeTier,
|
||
SourceCard,
|
||
Summary,
|
||
SummaryRow,
|
||
} from "./types";
|
||
import { tokens } from "./tokens";
|
||
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 -> "—". */
|
||
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_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;
|
||
}
|
||
|
||
// «Разброс цен» (§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(e.analogs.map((a) => a.price_per_m2));
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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
|
||
};
|
||
}
|
||
|
||
// ── 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 0–100% 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 4–96% 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 0–100% 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)} · по объявлениям`,
|
||
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
|
||
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, [
|
||
"СДЕЛКЕ",
|
||
"СДЕЛКАМ",
|
||
"СДЕЛКАМ",
|
||
])}`;
|
||
|
||
const meta: ResultMeta = {
|
||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
||
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||
builtOn,
|
||
};
|
||
|
||
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(e.analogs.map((a) => a.price_per_m2)),
|
||
},
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// OVERLAY MAPPERS (04 ПРОДАЖИ В ДОМЕ · 05 РЫНОК · 06 АНАЛИТИКА)
|
||
//
|
||
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
|
||
// presentation shape whose fields keep the exact ./types contract the views
|
||
// already render, so the Wire phase only swaps each view's fixture import for a
|
||
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
|
||
// here from the real API objects — never copied from fixtures. Graceful nulls
|
||
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
|
||
//
|
||
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
|
||
// fmtMln): the ad/deal/DKP tables carry their unit in the COLUMN HEADER, so the
|
||
// row values are unit-less grouped integers (numRu) for ₽/м², full grouped rubles
|
||
// for prices, and млн only where the design shows млн (KPI medians, house-sale
|
||
// from→to). Using fmtPpm/fmtMln verbatim there would double the unit or collapse
|
||
// rubles to млн, breaking the pixel-perfect markup. Percent style also splits:
|
||
// market deltas are whole-percent (fmtPct), торг/bargain are 1-decimal (pct1) —
|
||
// matching the design.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Overlay format helpers ──────────────────────────────────────────────────
|
||
|
||
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
|
||
function numRu(v: number | null | undefined): string {
|
||
if (v == null || !Number.isFinite(v)) return "—";
|
||
return Math.round(v).toLocaleString("ru-RU");
|
||
}
|
||
|
||
/** "{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 P25–P75), 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 -> "—". */
|
||
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.
|
||
function sourceLabel(s: string | null | undefined): string {
|
||
if (!s) return "—";
|
||
return registrySourceLabel(s.toLowerCase());
|
||
}
|
||
|
||
/** Rosreestr deal tier -> RU caption next to the badge. */
|
||
function tierLabel(t: string | null | undefined): string {
|
||
if (!t) return "";
|
||
if (t.includes("house")) return "по дому";
|
||
if (t.includes("street")) return "по улице";
|
||
return "";
|
||
}
|
||
|
||
/** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */
|
||
function composeLot(
|
||
rooms: number | null,
|
||
area: number | null,
|
||
floor: number | null,
|
||
totalFloors: number | null,
|
||
): string {
|
||
const parts: string[] = [];
|
||
if (rooms == null) parts.push("Квартира");
|
||
else if (rooms <= 0) parts.push("Студия");
|
||
else parts.push(`${rooms}-к. квартира`);
|
||
if (area != null && Number.isFinite(area)) parts.push(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. */
|
||
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). */
|
||
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.
|
||
*/
|
||
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
|
||
// ("Геологическая11–15 мин.Чкаловская16–20"): (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[] {
|
||
const bargainColor =
|
||
k?.median_bargain_pct != null && k.median_bargain_pct < 0
|
||
? tokens.success
|
||
: undefined;
|
||
return [
|
||
{
|
||
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
|
||
value:
|
||
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
|
||
unit: "дн.",
|
||
sub: "по архивным объявлениям",
|
||
},
|
||
{
|
||
label: "СРЕДНИЙ ТОРГ",
|
||
// Finding #10: negate so a price reduction reads "−3.3%" not "+3.3%".
|
||
value: bargainSigned(k?.median_bargain_pct),
|
||
color: bargainColor,
|
||
sub: "от начальной до итоговой цены",
|
||
},
|
||
{
|
||
label: "ДОЛЯ СНЯТЫХ",
|
||
value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—",
|
||
sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—",
|
||
},
|
||
];
|
||
}
|
||
|
||
// price_premium_label -> tier caption + decorative variant.
|
||
const SELLTIME_META: Record<
|
||
string,
|
||
{ tier: string; variant: SellTimeTier["variant"] }
|
||
> = {
|
||
cheap: { tier: "−5% от рынка", variant: "success" },
|
||
median: { tier: "По медиане", variant: "accent" },
|
||
plus5: { tier: "+5%", variant: "gold" },
|
||
plus10: { tier: "+10%", variant: "danger" },
|
||
};
|
||
|
||
// 4-column shell kept (with "—") when sell-time data is absent.
|
||
const SELLTIME_FALLBACK: SellTimeTier[] = [
|
||
{
|
||
tier: "−5% от рынка",
|
||
days: "—",
|
||
range: "—",
|
||
count: "—",
|
||
variant: "success",
|
||
},
|
||
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
|
||
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
|
||
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
|
||
];
|
||
|
||
function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
|
||
if (!s || s.buckets.length === 0) return SELLTIME_FALLBACK;
|
||
return s.buckets.map((b) => {
|
||
const meta =
|
||
SELLTIME_META[b.price_premium_label] ??
|
||
({
|
||
tier:
|
||
b.price_premium_pct === 0
|
||
? "По медиане"
|
||
: fmtPct(b.price_premium_pct),
|
||
variant: "accent",
|
||
} as { tier: string; variant: SellTimeTier["variant"] });
|
||
return {
|
||
tier: meta.tier,
|
||
days:
|
||
b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
|
||
range:
|
||
b.p25_days != null && b.p75_days != null
|
||
? `обычно ${b.p25_days}–${b.p75_days} дн.`
|
||
: "—",
|
||
count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
|
||
variant: meta.variant,
|
||
};
|
||
});
|
||
}
|
||
|
||
// Price-history polyline geometry. viewBox 0 0 900 220; 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 120–160k fills the plot instead of flat-lining against
|
||
// a fixed 0–200k 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
|
||
const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (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.
|
||
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 0–200k.
|
||
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;
|
||
const domain: ScatterDomain = {
|
||
xMin: DETAIL_X_DOMAIN.min,
|
||
xMax: DETAIL_X_DOMAIN.max,
|
||
yMin: yMin - yPad,
|
||
yMax: yMax + yPad,
|
||
};
|
||
|
||
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
|
||
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
|
||
const subject = Number.isFinite(subjectPrice)
|
||
? scatterProject(
|
||
[{ x: subjectDays ?? 90, y: subjectPrice }],
|
||
domain,
|
||
DETAIL_BOX,
|
||
6,
|
||
)[0]
|
||
: emptySubject;
|
||
|
||
const yTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((py) => {
|
||
const frac =
|
||
(DETAIL_BOX.bottom - py) / (DETAIL_BOX.bottom - DETAIL_BOX.top);
|
||
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
|
||
return { label: mlnTick(price), y: py };
|
||
});
|
||
|
||
return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS };
|
||
}
|
||
|
||
/**
|
||
* 06 АНАЛИТИКА ДОМА: header + 3 KPI tiles + sell-time tiers + price-history
|
||
* polyline + detail price×days scatter. analytics / sellTime degrade per-section;
|
||
* the scatter is driven by the estimate's analogs + actual_deals.
|
||
*/
|
||
export function mapAnalytics(
|
||
analytics: HouseAnalyticsResponse | null,
|
||
sellTime: SellTimeSensitivityResponse | null,
|
||
estimate: AggregatedEstimate | null,
|
||
): Analytics {
|
||
const total = analytics?.kpi.total_lots ?? null;
|
||
const ads =
|
||
total != null
|
||
? `${total} ${pluralRu(total, ["объявление", "объявления", "объявлений"])}`
|
||
: "—";
|
||
const radiusM = analytics?.radius_m ?? null;
|
||
const radius =
|
||
radiusM == null
|
||
? "—"
|
||
: radiusM > 0
|
||
? `в радиусе ${radiusM} м`
|
||
: "точно по этому дому";
|
||
|
||
const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
|
||
const sellTimeNote = `Медиана экспозиции по архивным лотам${
|
||
sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
|
||
}`;
|
||
|
||
return {
|
||
header: { ads, radius },
|
||
kpis: buildAnalyticsKpis(analytics?.kpi ?? null),
|
||
sellTime: buildSellTime(sellTime),
|
||
sellTimeNote,
|
||
priceHistory: buildPriceHistory(analytics),
|
||
scatterDetail: buildScatterDetail(estimate, radiusM),
|
||
};
|
||
}
|
||
|
||
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
|
||
|
||
// Real per-row source/distance/url + tier/date carried alongside the existing
|
||
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
|
||
// as the component DEFAULT; the mapper always populates them.
|
||
export interface AdRowData extends AdRow {
|
||
/** Source brand label for the badge (was hardcoded "Avito"). */
|
||
source?: string;
|
||
/** Distance to the subject (was hardcoded "—"). */
|
||
dist?: string;
|
||
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
|
||
url?: string | null;
|
||
}
|
||
export interface DealRowData extends DealRow {
|
||
/** Source brand label for the badge (was hardcoded "Росреестр"). */
|
||
source?: string;
|
||
/** Tier caption next to the badge (was hardcoded "по улице"). */
|
||
tier?: string;
|
||
/** Deal/listing date (was hardcoded "01.01.26"). */
|
||
date?: string;
|
||
}
|
||
|
||
export interface SourcesData {
|
||
adRows: AdRowData[];
|
||
dealRows: DealRowData[];
|
||
marketAds: MarketAds;
|
||
marketDeals: MarketDeals;
|
||
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
|
||
sourceCount?: 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;
|
||
|
||
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
||
addr: deglueAddr(l.address || "—"),
|
||
meta: adMeta(l),
|
||
ppm: numRu(l.price_per_m2),
|
||
price: numRu(l.price_rub),
|
||
source: sourceLabel(l.source),
|
||
dist: fmtDist(l.distance_m),
|
||
url: l.source_url ?? null,
|
||
}));
|
||
|
||
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
||
addr: deglueAddr(l.address || "—"),
|
||
meta: dealMeta(l),
|
||
ppm: numRu(l.price_per_m2),
|
||
price: numRu(l.price_rub),
|
||
source: sourceLabel(l.source),
|
||
tier: tierLabel(l.tier),
|
||
date: 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),
|
||
},
|
||
};
|
||
|
||
return {
|
||
adRows,
|
||
dealRows,
|
||
marketAds,
|
||
marketDeals,
|
||
sourceCount: estimate
|
||
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
|
||
: "—",
|
||
};
|
||
}
|
||
|
||
// ── 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ч свежий · 18–24ч устаревает · >24ч устарел.
|
||
|
||
export interface CacheData {
|
||
rows: CacheRow[];
|
||
kpis: CacheKpi[];
|
||
}
|
||
|
||
const CACHE_TTL_HOURS = 24;
|
||
|
||
/**
|
||
* created_at -> компактное ru-время для таблицы (как в дизайне):
|
||
* сегодня -> "HH:MM:SS"; вчера -> "вчера HH:MM"; раньше -> "DD.MM.YYYY".
|
||
*/
|
||
function fmtCacheTime(iso: string | null | undefined): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
const now = new Date();
|
||
const startOfToday = new Date(
|
||
now.getFullYear(),
|
||
now.getMonth(),
|
||
now.getDate(),
|
||
).getTime();
|
||
const t = d.getTime();
|
||
if (t >= startOfToday) {
|
||
return d.toLocaleTimeString("ru-RU", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
}
|
||
if (t >= startOfToday - 86_400_000) {
|
||
return `вчера ${d.toLocaleTimeString("ru-RU", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}`;
|
||
}
|
||
return fmtDate(iso);
|
||
}
|
||
|
||
/** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */
|
||
function cacheStatus(iso: string | null | undefined): {
|
||
status: string;
|
||
statusColor: string;
|
||
} {
|
||
if (!iso) return { status: "—", statusColor: tokens.muted };
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime()))
|
||
return { status: "—", statusColor: tokens.muted };
|
||
const ageH = (Date.now() - d.getTime()) / 3_600_000;
|
||
if (ageH <= CACHE_TTL_HOURS * 0.75) {
|
||
return { status: "свежий", statusColor: tokens.success };
|
||
}
|
||
if (ageH <= CACHE_TTL_HOURS) {
|
||
return { status: "устаревает", statusColor: tokens.warn };
|
||
}
|
||
return { status: "устарел", statusColor: tokens.danger };
|
||
}
|
||
|
||
/**
|
||
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
|
||
* фронте из per-user /history (null → пустая таблица + KPI с "—").
|
||
* rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
|
||
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
|
||
* «ПОВТОРНЫЕ АДРЕСА» убран (§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 {
|
||
// §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,
|
||
};
|
||
});
|
||
|
||
// 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),
|
||
sub: "по вашим оценкам",
|
||
},
|
||
{
|
||
label: "СРЕДНЯЯ ЦЕНА",
|
||
value: avg != null ? fmtMln(avg) : "—",
|
||
unit: avg != null ? "млн ₽" : undefined,
|
||
sub: "по проведённым оценкам",
|
||
},
|
||
];
|
||
|
||
return { rows, kpis };
|
||
}
|