Merge pull request 'feat(tradein/v2): wire overlays History/Analytics/Sources to real API (FE-2/3/4)' (#2051) from feat/tradein-v2-overlays into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m49s
Deploy Trade-In / deploy (push) Successful in 49s

This commit is contained in:
lekss361 2026-06-28 12:46:49 +00:00
commit 0c0efb887a
6 changed files with 905 additions and 107 deletions

View file

@ -23,11 +23,18 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
import type { ObjectInfo, Report } from "@/components/trade-in/v2/types";
import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
import type {
HistoryData,
SourcesData,
} from "@/components/trade-in/v2/mappers";
import {
mapAnalytics,
mapHistory,
mapObject,
mapReport,
mapResultPanel,
mapSources,
mapSummary,
} from "@/components/trade-in/v2/mappers";
@ -40,6 +47,9 @@ import {
useEstimate,
useEstimateHouseAnalytics,
useEstimateMutation,
useEstimatePlacementHistory,
useEstimateSellTimeSensitivity,
useSalesVsListings,
useStreetDeals,
} from "@/lib/trade-in-api";
@ -388,9 +398,22 @@ export default function TradeInV2Page() {
estimate?.rooms ?? null,
);
const analytics = useEstimateHouseAnalytics(currentEstimateId);
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
// contract: each resolves independently; a pending/errored one degrades its
// overlay section to an honest empty via the mapper (null input).
const placementHistory = useEstimatePlacementHistory(currentEstimateId);
const salesVsListings = useSalesVsListings(
estimate?.target_address ?? null,
estimate?.area_m2 ?? null,
estimate?.rooms ?? null,
);
const sellTime = useEstimateSellTimeSensitivity(currentEstimateId);
const streetDealsData = streetDeals.data ?? null;
const analyticsData = analytics.data ?? null;
const placementHistoryData = placementHistory.data ?? null;
const salesVsListingsData = salesVsListings.data ?? null;
const sellTimeData = sellTime.data ?? null;
// ── Derived state flags ───────────────────────────────────────────────────
const restoreActive = urlId !== null && freshResult === null;
@ -425,6 +448,24 @@ export default function TradeInV2Page() {
[estimate, analyticsData, streetDealsData],
);
// ── Overlay (section) mapped data. Computed unconditionally: the mappers
// accept nulls and degrade per-section, so an overlay opened before/without
// an estimate shows an honest empty shell — never the design fixtures as if
// they were a real report. ─────────────────────────────────────────────
const historyData = useMemo<HistoryData>(
() =>
mapHistory(placementHistoryData, streetDealsData, salesVsListingsData),
[placementHistoryData, streetDealsData, salesVsListingsData],
);
const analyticsViewData = useMemo<Analytics>(
() => mapAnalytics(analyticsData, sellTimeData, estimate),
[analyticsData, sellTimeData, estimate],
);
const sourcesData = useMemo<SourcesData>(
() => mapSources(estimate, streetDealsData),
[estimate, streetDealsData],
);
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
// estimate arrives async (useState reads initialValues only on mount).
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
@ -580,6 +621,9 @@ export default function TradeInV2Page() {
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
/>
)}
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />

View file

@ -1,21 +1,24 @@
"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
// Data comes from fixtures (no API); the price-history dots navigate to the
// Accepts one composite `data` prop (default = the design fixture); page.tsx
// feeds mapAnalytics(...) output. The price-history dots navigate to the
// "Аналогичные объявления" overlay via onNavigate?.(2).
import { useState } from "react";
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics } from "./fixtures";
import type { SellTimeTier } from "./types";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
data?: Analytics;
onNavigate?: (i: number) => void;
}
// ---- price-history geometry -----------------------------------------------
// Parse the SVG polyline point strings into coordinates so the markers and
// the transparent hover targets can be derived from the same source.
// the transparent hover targets can be derived from the same source. Empty
// series (graceful-degradation case) parse to [] — no dots, no hover targets.
interface Pt {
x: number;
@ -26,20 +29,13 @@ function parsePoints(s: string): Pt[] {
return s
.trim()
.split(/\s+/)
.filter((pair) => pair.length > 0)
.map((pair) => {
const [x, y] = pair.split(",").map(Number);
return { x, y };
});
}
const avitoPts = parsePoints(analytics.priceHistory.avito);
const yandexPts = parsePoints(analytics.priceHistory.yandex);
// Яндекс only draws dots where its series diverges from Avito (последние 3 года).
const yandexDots = yandexPts.filter(
(p, i) => !avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y,
);
const hoverPts: Pt[] = [...avitoPts, ...yandexDots];
// X positions of the year axis labels (chart scaffolding, not in fixtures).
const histYearX = [50, 250, 460, 670, 860];
@ -82,9 +78,35 @@ const cardStyle = {
padding: "16px 18px",
} as const;
export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
// Price-history series + hover targets, derived from the data prop. Яндекс
// only draws dots where its series diverges from Avito (последние 3 года).
const avitoPts = useMemo(
() => parsePoints(data.priceHistory.avito),
[data.priceHistory.avito],
);
const yandexPts = useMemo(
() => parsePoints(data.priceHistory.yandex),
[data.priceHistory.yandex],
);
const yandexDots = useMemo(
() =>
yandexPts.filter(
(p, i) =>
!avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y,
),
[yandexPts, avitoPts],
);
const hoverPts = useMemo<Pt[]>(
() => [...avitoPts, ...yandexDots],
[avitoPts, yandexDots],
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* header */}
@ -99,7 +121,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
Аналитика дома
</div>
<div style={{ fontSize: 10, color: tokens.muted2 }}>
{analytics.header.ads} · {analytics.header.radius}
{data.header.ads} · {data.header.radius}
</div>
</div>
@ -111,7 +133,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
gap: 16,
}}
>
{analytics.kpis.map((kpi, i) => (
{data.kpis.map((kpi, i) => (
<div key={i} style={cardStyle}>
<div
style={{
@ -154,7 +176,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
<div
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
>
{analytics.sellTimeNote}
{data.sellTimeNote}
</div>
<div
style={{
@ -163,7 +185,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
gap: 12,
}}
>
{analytics.sellTime.map((tier, i) => {
{data.sellTime.map((tier, i) => {
const v = tierStyles[tier.variant];
return (
<div
@ -218,7 +240,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{analytics.priceHistory.note}
{data.priceHistory.note}
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
@ -278,20 +300,20 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
</text>
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{analytics.priceHistory.years.map((yr, i) => (
{data.priceHistory.years.map((yr, i) => (
<text key={yr} x={histYearX[i]} y={214}>
{yr}
</text>
))}
</g>
<polyline
points={analytics.priceHistory.avito}
points={data.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={analytics.priceHistory.yandex}
points={data.priceHistory.yandex}
fill="none"
stroke={tokens.gold}
strokeWidth={2.5}
@ -374,7 +396,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции ·{" "}
{analytics.scatterDetail.note}
{data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
@ -475,24 +497,24 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
opacity={0.45}
/>
<g fill={tokens.scatterDeal}>
{analytics.scatterDetail.deals.map((p, i) => (
{data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={tokens.accent}>
{analytics.scatterDetail.analogs.map((p, i) => (
{data.scatterDetail.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={analytics.scatterDetail.subject.x}
cy={analytics.scatterDetail.subject.y}
r={analytics.scatterDetail.subject.r}
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={analytics.scatterDetail.subject.x}
cy={analytics.scatterDetail.subject.y}
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
@ -505,7 +527,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
fill={tokens.muted2}
textAnchor="end"
>
{analytics.scatterDetail.yTicks.map((t) => (
{data.scatterDetail.yTicks.map((t) => (
<text key={t.label} x={52} y={t.y}>
{t.label}
</text>
@ -517,7 +539,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
fill={tokens.muted2}
textAnchor="middle"
>
{analytics.scatterDetail.xTicks.map((t) => (
{data.scatterDetail.xTicks.map((t) => (
<text key={t.label} x={t.x} y={268}>
{t.label}
</text>

View file

@ -2,16 +2,25 @@
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
// lines 457-489): "История продаж в этом доме" card (house sales) +
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
// Static markup — data comes from fixtures, no fetch / no local state.
// Data arrives via the composite `data` prop (mapHistory); the design fixtures
// are the default for unwired/storybook usage. No fetch / no local state.
import { tokens } from "./tokens";
import { history, dkpRows } from "./fixtures";
import type { HistoryData } from "./mappers";
// Shared grid templates (kept verbatim from the design column tracks).
const houseGrid = "2fr 1.4fr 1fr 1fr";
const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr";
export default function HistoryView() {
// Default presentation data (unwired usage): the existing design fixtures.
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
interface HistoryViewProps {
data?: HistoryData;
}
export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
{/* ── История продаж в этом доме ───────────────────────────── */}
@ -49,7 +58,7 @@ export default function HistoryView() {
color: tokens.muted2,
}}
>
{history.houseSales.length} ЛОТА
{data.houseSales.length} ЛОТА
</span>
</div>
@ -71,7 +80,7 @@ export default function HistoryView() {
<span>ЭКСПОЗИЦИЯ</span>
</div>
{history.houseSales.map((row, i) => (
{data.houseSales.map((row, i) => (
<div
key={i}
style={{
@ -81,7 +90,7 @@ export default function HistoryView() {
padding: "13px 18px",
fontSize: "12px",
borderBottom:
i < history.houseSales.length - 1
i < data.houseSales.length - 1
? `1px solid ${tokens.lineSoft4}`
: undefined,
}}
@ -142,7 +151,7 @@ export default function HistoryView() {
color: tokens.ink2,
}}
>
{history.dkpHeader.title}
{data.dkpHeader.title}
</div>
</div>
<div
@ -154,7 +163,7 @@ export default function HistoryView() {
}}
>
Период:{" "}
<b style={{ color: tokens.muted }}>{history.dkpHeader.period}</b>
<b style={{ color: tokens.muted }}>{data.dkpHeader.period}</b>
<br />
Источник: Росреестр + объявления
</div>
@ -166,7 +175,7 @@ export default function HistoryView() {
marginTop: "8px",
}}
>
{history.dkpHeader.note}
{data.dkpHeader.note}
</div>
</div>
@ -203,7 +212,7 @@ export default function HistoryView() {
marginTop: "4px",
}}
>
{history.dkpKpi.count}
{data.dkpKpi.count}
</div>
</div>
<div
@ -230,7 +239,7 @@ export default function HistoryView() {
marginTop: "4px",
}}
>
{history.dkpKpi.median}{" "}
{data.dkpKpi.median}{" "}
<span style={{ fontSize: "11px", color: tokens.muted }}>
/м²
</span>
@ -255,7 +264,7 @@ export default function HistoryView() {
marginTop: "4px",
}}
>
{history.dkpKpi.range}{" "}
{data.dkpKpi.range}{" "}
<span style={{ fontSize: "11px", color: tokens.muted }}>
млн
</span>
@ -284,7 +293,7 @@ export default function HistoryView() {
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
</div>
{dkpRows.map((r, i) => (
{data.dkpRows.map((r, i) => (
<div
key={i}
style={{
@ -297,9 +306,7 @@ export default function HistoryView() {
alignItems: "center",
}}
>
<span style={{ color: tokens.body }}>
Екатеринбург, Академика Ландау
</span>
<span style={{ color: tokens.body }}>{r.addr ?? "—"}</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.area}
</span>
@ -312,7 +319,7 @@ export default function HistoryView() {
<span
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
01.01.26
{r.date ?? "—"}
</span>
<span
style={{
@ -335,7 +342,7 @@ export default function HistoryView() {
color: tokens.accent,
}}
>
Яндекс
{r.source ?? "—"}
</span>
{r.ask}
<span style={{ color: r.deltaColor }}>{r.delta}</span>
@ -351,7 +358,7 @@ export default function HistoryView() {
background: tokens.surfaceTint,
}}
>
Показано {dkpRows.length} из {history.dkpKpi.count} сделок
Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок
</div>
</div>
</div>

View file

@ -12,17 +12,27 @@ import HistoryView from "./HistoryView";
import SourcesView from "./SourcesView";
import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView";
import type { HistoryData, SourcesData } from "./mappers";
import type { Analytics } from "./types";
interface SectionOverlayProps {
active: number;
onClose: () => void;
onNavigate: (i: number) => void;
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics).
// Optional so unwired/storybook usage falls back to each view's fixture default.
history?: HistoryData;
sources?: SourcesData;
analytics?: Analytics;
}
export default function SectionOverlay({
active,
onClose,
onNavigate,
history,
sources,
analytics,
}: SectionOverlayProps) {
return (
<div
@ -140,9 +150,11 @@ export default function SectionOverlay({
padding: "22px 24px 26px",
}}
>
{active === 1 && <HistoryView />}
{active === 2 && <SourcesView />}
{active === 3 && <AnalyticsView onNavigate={onNavigate} />}
{active === 1 && <HistoryView data={history} />}
{active === 2 && <SourcesView data={sources} />}
{active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} />
)}
{active === 4 && <CacheView />}
</div>
</div>

View file

@ -1,14 +1,16 @@
import type { CSSProperties } from "react";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { AdRow, DealRow } from "./types";
import type { SourcesData } from "./mappers";
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table).
// Static markup: data from fixtures, no fetching, no interactive state.
// Markup is data-driven via the `data` prop; the design fixtures stay as the
// DEFAULT so unwired/storybook usage still renders pixel-identically.
// One-off tints not present in the v2 palette (kept literal, faithful to design).
const KPI_BG = "rgba(238,244,250,.5)";
@ -121,6 +123,18 @@ const monoMuted2: CSSProperties = {
color: tokens.muted2,
};
// Shared "open original ↗" cell style (anchor + fixture-fallback span render
// identically; textDecoration:none keeps the anchor visually a button).
const linkBtn: CSSProperties = {
fontSize: "9.5px",
color: tokens.accent,
border: `1px solid ${tokens.line2}`,
borderRadius: 4,
padding: "3px 8px",
textAlign: "center",
textDecoration: "none",
};
function KpiCell({
label,
value,
@ -160,7 +174,20 @@ function KpiCell({
);
}
export default function SourcesView() {
// Default presentation data (unwired/storybook usage): the existing design
// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
const SOURCES_FIXTURE: SourcesData = {
adRows,
dealRows,
marketAds,
marketDeals,
};
interface SourcesViewProps {
data?: SourcesData;
}
export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<style>{`
@ -188,18 +215,18 @@ export default function SourcesView() {
<div style={kpiRow}>
<KpiCell
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
value={marketAds.kpi.count}
value={data.marketAds.kpi.count}
unit="шт · из 2 источников"
/>
<KpiCell
label="МЕДИАНА"
value={marketAds.kpi.median}
unit={`млн ₽ · ${marketAds.kpi.ppm} ₽/м²`}
value={data.marketAds.kpi.median}
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
/>
<KpiCell
label="ДИАПАЗОН P25P75"
value={marketAds.kpi.range}
unit={`млн · CV ${marketAds.kpi.cv}`}
value={data.marketAds.kpi.range}
unit={`млн · CV ${data.marketAds.kpi.cv}`}
valueColor={tokens.accent}
last
/>
@ -223,7 +250,7 @@ export default function SourcesView() {
>
ФИЛЬТР
</span>
{marketAds.filters.map((f, i) => (
{data.marketAds.filters.map((f, i) => (
<span key={i} style={chip}>
{f}
</span>
@ -265,7 +292,13 @@ export default function SourcesView() {
<span />
</div>
{adRows.map((r: AdRow, i) => (
{data.adRows.map((r, i) => {
// safeUrl gates href against javascript:/data: schemes (XSS). url is
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps
// the storybook markup pixel-identical), null for a real row with no
// link (→ "—"), and a string when a real listing URL is available.
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
return (
<div
key={i}
className="sv-row"
@ -283,26 +316,31 @@ export default function SourcesView() {
</span>
</span>
<span>
<span style={badge}>Avito</span>
<span style={badge}>{r.source ?? "Avito"}</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}></span>
<span
<span style={monoMuted2}>{r.dist ?? "—"}</span>
{href ? (
<a
className="sv-rr"
style={{
fontSize: "9.5px",
color: tokens.accent,
border: `1px solid ${tokens.line2}`,
borderRadius: 4,
padding: "3px 8px",
textAlign: "center",
}}
href={href}
target="_blank"
rel="noopener noreferrer"
style={linkBtn}
>
Источник
</a>
) : r.url === null ? (
<span style={monoMuted2}></span>
) : (
<span className="sv-rr" style={linkBtn}>
Росреестр
</span>
)}
</div>
))}
);
})}
</div>
{/* фактические сделки */}
@ -324,18 +362,18 @@ export default function SourcesView() {
<div style={kpiRow}>
<KpiCell
label="СДЕЛОК ПО АНАЛОГАМ"
value={marketDeals.kpi.count}
value={data.marketDeals.kpi.count}
unit="шт · Росреестр"
/>
<KpiCell
label="МЕДИАНА СДЕЛОК"
value={marketDeals.kpi.median}
unit={`млн ₽ · ${marketDeals.kpi.delta}`}
value={data.marketDeals.kpi.median}
unit={`млн ₽ · ${data.marketDeals.kpi.delta}`}
unitColor={tokens.success}
/>
<KpiCell
label="ДИАПАЗОН"
value={marketDeals.kpi.range}
value={data.marketDeals.kpi.range}
unit="млн"
valueColor={tokens.accent}
last
@ -356,7 +394,7 @@ export default function SourcesView() {
<span>ДАТА</span>
</div>
{dealRows.map((r: DealRow, i) => (
{data.dealRows.map((r, i) => (
<div
key={i}
className="sv-row"
@ -374,14 +412,14 @@ export default function SourcesView() {
</span>
</span>
<span>
<span style={badge}>Росреестр</span>{" "}
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
по улице
{r.tier ?? "по улице"}
</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}>01.01.26</span>
<span style={monoMuted2}>{r.date ?? "—"}</span>
</div>
))}
@ -393,7 +431,8 @@ export default function SourcesView() {
background: KPI_BG,
}}
>
Показано 10 фактических сделок
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
фактических сделок
</div>
</div>
</div>

View file

@ -26,27 +26,44 @@ import type {
AggregatedEstimate,
AnalogLot,
ConfidenceLevel,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseType,
PlacementHistoryItem,
RepairState,
SalesVsListingsResponse,
SellTimeSensitivityResponse,
StreetDealsResponse,
} from "@/types/trade-in";
import type {
AdRow,
Analytics,
AnalyticsKpi,
AxisTickX,
AxisTickY,
DealRow,
DkpRow,
History,
HouseSaleRow,
MarketAds,
MarketDeals,
ObjectInfo,
PriceHistory,
RangeBar,
RangeMarker,
Ranges,
Report,
ResultCard,
ResultMeta,
ScatterDetail,
ScatterMini,
ScatterPoint,
SellTimeTier,
SourceCard,
Summary,
SummaryRow,
} from "./types";
import { tokens } from "./tokens";
// Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7".
const TOTAL_SOURCES = 7;
@ -781,3 +798,660 @@ export function mapSummary(
},
};
}
// ════════════════════════════════════════════════════════════════════════════
// OVERLAY MAPPERS (04 ПРОДАЖИ В ДОМЕ · 05 РЫНОК · 06 АНАЛИТИКА)
//
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
// presentation shape whose fields keep the exact ./types contract the views
// already render, so the Wire phase only swaps each view's fixture import for a
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
// here from the real API objects — never copied from fixtures. Graceful nulls
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
//
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
// fmtMln): the ad/deal/DKP tables carry their unit in the COLUMN HEADER, so the
// row values are unit-less grouped integers (numRu) for ₽/м², full grouped rubles
// for prices, and млн only where the design shows млн (KPI medians, house-sale
// from→to). Using fmtPpm/fmtMln verbatim there would double the unit or collapse
// rubles to млн, breaking the pixel-perfect markup. Percent style also splits:
// market deltas are whole-percent (fmtPct), торг/bargain are 1-decimal (pct1) —
// matching the design.
// ════════════════════════════════════════════════════════════════════════════
// ── Overlay format helpers ──────────────────────────────────────────────────
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
function numRu(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return Math.round(v).toLocaleString("ru-RU");
}
/** rub -> млн, ru, exactly 1dp. 4700000 -> "4,7". (compact KPI range parts) */
function mln1(rub: number): string {
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/** "{lo} {hi}" млн range with spaces (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
return "—";
}
return `${mln1(lo)} ${mln1(hi)}`;
}
/** "{lo}{hi}" млн range, no spaces (marketAds P25P75). Missing -> "—". */
function mlnRangeTight(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
return "—";
}
return `${mln1(lo)}${mln1(hi)}`;
}
/** Signed percent, always 1dp, "." separator + ru minus. -36 -> "36.0%". */
function pct1(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
const r = Number(v.toFixed(1));
const sign = r < 0 ? "" : r > 0 ? "+" : "";
return `${sign}${Math.abs(r).toFixed(1)}%`;
}
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—";
if (m < 1000) return `${Math.round(m)} м`;
return `${(m / 1000).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} км`;
}
// Source key -> brand/RU display label (badges + link source).
const SOURCE_LABEL: Record<string, string> = {
avito: "Avito",
avito_imv: "Avito",
cian: "Циан",
yandex: "Яндекс",
rosreestr: "Росреестр",
domklik: "Домклик",
domclick: "Домклик",
restate: "Restate",
n1: "N1.RU",
n1ru: "N1.RU",
};
function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return SOURCE_LABEL[s.toLowerCase()] ?? s;
}
/** Rosreestr deal tier -> RU caption next to the badge. */
function tierLabel(t: string | null | undefined): string {
if (!t) return "";
if (t.includes("house")) return "по дому";
if (t.includes("street")) return "по улице";
return "";
}
/** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */
function composeLot(
rooms: number | null,
area: number | null,
floor: number | null,
totalFloors: number | null,
): string {
const parts: string[] = [];
if (rooms == null) parts.push("Квартира");
else if (rooms <= 0) parts.push("Студия");
else parts.push(`${rooms}-к. квартира`);
if (area != null && Number.isFinite(area)) parts.push(`${area} м²`);
if (floor != null) {
parts.push(totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`);
}
return parts.join(", ");
}
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
function adMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
if (l.floor != null) {
parts.push(l.total_floors != null ? `этаж ${l.floor}/${l.total_floors}` : `этаж ${l.floor}`);
}
return parts.join(" · ");
}
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
function dealMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(`${l.area_m2.toFixed(1)} м²`);
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
return parts.join(" · ");
}
// ── 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
* deallisting rows (sales-vs-listings). Each source is independent: a null one
* leaves its block at "—"/empty without blanking the others.
*/
export function mapHistory(
placement: PlacementHistoryItem[] | null,
streetDeals: StreetDealsResponse | null,
salesVsListings: SalesVsListingsResponse | null,
): HistoryData {
const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({
lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors),
priceFrom: fmtMln(p.start_price),
priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—",
date: fmtDate(p.last_price_date),
exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—",
}));
const street = streetDeals?.street ?? null;
const title = street
? `ДКП-сделки на улице ${street}`
: "ДКП-сделки на вашей улице";
const period =
salesVsListings?.period_months != null
? `${salesVsListings.period_months} мес`
: "—";
const noteParts: string[] = [];
if (salesVsListings?.linkage_rate_pct != null) {
noteParts.push(
`${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`,
);
}
if (salesVsListings?.median_discount_pct != null) {
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`);
}
const note =
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
"Данные по улице, не по дому.";
const dkpKpi = {
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
median:
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
? numRu(streetDeals.median_price_per_m2)
: "—",
range:
streetDeals != null
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
: "—",
};
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
const ask =
pr.listing_price_rub != null
? `${numRu(pr.listing_price_rub)}${
pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0
? ` за ${pr.days_listing_to_deal} дн до`
: ""
}`
: "—";
const deltaColor =
pr.discount_pct == null
? tokens.muted
: pr.discount_pct < 0
? tokens.success
: tokens.danger;
return {
area: `${pr.deal_area_m2.toFixed(1)} м²`,
price: `${numRu(pr.deal_price_rub)}`,
ppm: numRu(pr.deal_price_per_m2),
ask,
delta: pct1(pr.discount_pct),
deltaColor,
addr: pr.deal_address || undefined,
date: fmtDate(pr.deal_date),
source: pr.listing_source,
};
});
return { houseSales, dkpHeader: { title, period, note }, dkpKpi, dkpRows };
}
// ── 06 АНАЛИТИКА ДОМА (AnalyticsView) ───────────────────────────────────────
// Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
const bargainColor =
k?.median_bargain_pct != null && k.median_bargain_pct < 0
? tokens.success
: undefined;
return [
{
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value: k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
unit: "дн.",
sub: "по архивным объявлениям",
},
{
label: "СРЕДНИЙ ТОРГ",
value: pct1(k?.median_bargain_pct),
color: bargainColor,
sub: "от начальной до итоговой цены",
},
{
label: "ДОЛЯ СНЯТЫХ",
value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—",
sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—",
},
];
}
// price_premium_label -> tier caption + decorative variant.
const SELLTIME_META: Record<
string,
{ tier: string; variant: SellTimeTier["variant"] }
> = {
cheap: { tier: "5% от рынка", variant: "success" },
median: { tier: "По медиане", variant: "accent" },
plus5: { tier: "+5%", variant: "gold" },
plus10: { tier: "+10%", variant: "danger" },
};
// 4-column shell kept (with "—") when sell-time data is absent.
const SELLTIME_FALLBACK: SellTimeTier[] = [
{ tier: "5% от рынка", days: "—", range: "—", count: "—", variant: "success" },
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
];
function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
if (!s || s.buckets.length === 0) return SELLTIME_FALLBACK;
return s.buckets.map((b) => {
const meta =
SELLTIME_META[b.price_premium_label] ??
({
tier: b.price_premium_pct === 0 ? "По медиане" : fmtPct(b.price_premium_pct),
variant: "accent",
} as { tier: string; variant: SellTimeTier["variant"] });
return {
tier: meta.tier,
days: b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
range:
b.p25_days != null && b.p75_days != null
? `обычно ${b.p25_days}${b.p75_days} дн.`
: "—",
count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
variant: meta.variant,
};
});
}
// Price-history polyline geometry. viewBox 0 0 900 220; y maps 0→195 … 200k→20
// (the chart's own gridlines), x distributes the years evenly across [58,885]
// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source.
const PH_X_LEFT = 58;
const PH_X_RIGHT = 885;
function phYearX(i: number, n: number): number {
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
}
function phPriceY(ppm: number): number {
return clamp(195 - (ppm / 200000) * 175, 20, 195);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = {
note: "Нет данных по истории цен",
years: [],
avito: "",
yandex: "",
};
if (!a || a.price_history.length === 0) return empty;
const ph = a.price_history;
const yearsNum = Array.from(new Set(ph.map((p) => p.year))).sort(
(x, y) => x - y,
);
if (yearsNum.length === 0) return empty;
const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
const n = yearsNum.length;
const series = (src: string): string =>
ph
.filter(
(p) => p.source === src && Number.isFinite(p.median_price_per_m2),
)
.sort((x, y) => x.year - y.year)
.map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
const y = Math.round(phPriceY(p.median_price_per_m2));
return `${x},${y}`;
})
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;
return {
note,
years: yearsNum.map(String),
avito: series("avito_imv"),
yandex: series("yandex_valuation"),
};
}
// Detail "ЦЕНА × СРОК ПРОДАЖИ" scatter (viewBox 0 0 900 300). Pixel box + x-ticks
// come from the design scaffold; y-tick LABELS are recomputed for the data domain
// (same approach as buildScatterMini). Exposure x reuses the listing_date
// "days listed so far" fallback for active analogs (deals require real DOM).
const DETAIL_BOX: ScatterBox = { left: 60, right: 880, top: 24, bottom: 252 };
const DETAIL_X_DOMAIN = { min: 0, max: 180 };
const DETAIL_X_TICKS: AxisTickX[] = [
{ label: "0", x: 60 },
{ label: "45", x: 265 },
{ label: "90", x: 470 },
{ label: "135", x: 675 },
{ label: "180", x: 880 },
];
const DETAIL_Y_TICK_PX = [24, 81, 138, 195, 252];
function detailDomReal(l: AnalogLot): number | null {
return l.days_on_market != null && Number.isFinite(l.days_on_market)
? l.days_on_market
: null;
}
function detailDomListed(l: AnalogLot): number | null {
if (l.listing_date == null) return null;
const t = new Date(l.listing_date).getTime();
if (Number.isNaN(t)) return null;
const days = Math.round((Date.now() - t) / 86_400_000);
return days >= 0 && days < 3650 ? days : null;
}
function detailExposurePts(
lots: AnalogLot[],
dom: (l: AnalogLot) => number | null,
): { x: number; y: number }[] {
return lots
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } =>
p.x != null && Number.isFinite(p.y),
);
}
function buildScatterDetail(
e: AggregatedEstimate | null,
radiusM: number | null,
): ScatterDetail {
const fallbackTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((y) => ({
label: "—",
y,
}));
const emptySubject: ScatterPoint = { x: 470, y: 138, r: 6 };
if (!e) {
return {
note: "—",
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
const dealsRaw = detailExposurePts(e.actual_deals, detailDomReal);
const analogsRaw = detailExposurePts(
e.analogs,
(l) => detailDomReal(l) ?? detailDomListed(l),
);
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market;
const nDeals = e.actual_deals.length;
const nAnalogs = e.analogs.length;
const note = `${nDeals} ${pluralRu(nDeals, [
"сделка",
"сделки",
"сделок",
])} и ${nAnalogs} ${pluralRu(nAnalogs, [
"аналог",
"аналога",
"аналогов",
])}${radiusM != null && radiusM > 0 ? ` в радиусе ${radiusM} м` : ""}`;
const allY = [...dealsRaw, ...analogsRaw]
.map((p) => p.y)
.filter((y) => Number.isFinite(y));
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
if (allY.length === 0) {
return {
note,
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
let yMin = Math.min(...allY);
let yMax = Math.max(...allY);
if (yMin === yMax) {
yMin *= 0.95;
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
const domain: ScatterDomain = {
xMin: DETAIL_X_DOMAIN.min,
xMax: DETAIL_X_DOMAIN.max,
yMin: yMin - yPad,
yMax: yMax + yPad,
};
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
const subject = Number.isFinite(subjectPrice)
? scatterProject(
[{ x: subjectDays ?? 90, y: subjectPrice }],
domain,
DETAIL_BOX,
6,
)[0]
: emptySubject;
const yTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((py) => {
const frac =
(DETAIL_BOX.bottom - py) / (DETAIL_BOX.bottom - DETAIL_BOX.top);
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
return { label: mlnTick(price), y: py };
});
return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS };
}
/**
* 06 АНАЛИТИКА ДОМА: header + 3 KPI tiles + sell-time tiers + price-history
* polyline + detail price×days scatter. analytics / sellTime degrade per-section;
* the scatter is driven by the estimate's analogs + actual_deals.
*/
export function mapAnalytics(
analytics: HouseAnalyticsResponse | null,
sellTime: SellTimeSensitivityResponse | null,
estimate: AggregatedEstimate | null,
): Analytics {
const total = analytics?.kpi.total_lots ?? null;
const ads =
total != null
? `${total} ${pluralRu(total, ["объявление", "объявления", "объявлений"])}`
: "—";
const radiusM = analytics?.radius_m ?? null;
const radius =
radiusM == null
? "—"
: radiusM > 0
? `в радиусе ${radiusM} м`
: "точно по этому дому";
const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
const sellTimeNote = `Медиана экспозиции по архивным лотам${
sellTgt != null ? ` · эталон ${fmtPpm(sellTgt)}` : ""
}`;
return {
header: { ads, radius },
kpis: buildAnalyticsKpis(analytics?.kpi ?? null),
sellTime: buildSellTime(sellTime),
sellTimeNote,
priceHistory: buildPriceHistory(analytics),
scatterDetail: buildScatterDetail(estimate, radiusM),
};
}
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
// Real per-row source/distance/url + tier/date carried alongside the existing
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
// as the component DEFAULT; the mapper always populates them.
export interface AdRowData extends AdRow {
/** Source brand label for the badge (was hardcoded "Avito"). */
source?: string;
/** Distance to the subject (was hardcoded "—"). */
dist?: string;
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
url?: string | null;
}
export interface DealRowData extends DealRow {
/** Source brand label for the badge (was hardcoded "Росреестр"). */
source?: string;
/** Tier caption next to the badge (was hardcoded "по улице"). */
tier?: string;
/** Deal/listing date (was hardcoded "01.01.26"). */
date?: string;
}
export interface SourcesData {
adRows: AdRowData[];
dealRows: DealRowData[];
marketAds: MarketAds;
marketDeals: MarketDeals;
}
// Filter chips: distance corridor + rooms + analog area span (design order).
function buildAdFilters(e: AggregatedEstimate | null): string[] {
if (!e) return [];
const filters: string[] = [];
const dists = e.analogs
.map((a) => a.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
if (dists.length > 0) {
const maxKm = Math.max(...dists) / 1000;
const km = maxKm < 1 ? maxKm.toFixed(1) : String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km} км`);
}
if (e.rooms != null) {
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}`}`);
}
const areas = e.analogs.map((a) => a.area_m2).filter((x) => Number.isFinite(x));
if (areas.length > 0) {
filters.push(
`Площадь ${Math.min(...areas).toFixed(1)} ${Math.max(...areas).toFixed(1)} м²`,
);
}
return filters;
}
/**
* 05 РЫНОК: analog ad rows + actual-deal rows + the two market KPI bands. Deal
* aggregates prefer the per-street ДКП corridor (streetDeals), falling back to
* the estimate's own actual_deals.
*/
export function mapSources(
estimate: AggregatedEstimate | null,
streetDeals: StreetDealsResponse | null,
): SourcesData {
const e = estimate;
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
addr: 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: l.address || "—",
meta: dealMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),
source: sourceLabel(l.source),
tier: tierLabel(l.tier),
date: fmtDate(l.listing_date),
}));
const marketAds: MarketAds = {
kpi: {
count: e != null ? String(e.n_analogs) : "—",
median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm:
e != null && Number.isFinite(e.median_price_per_m2)
? numRu(e.median_price_per_m2)
: "—",
range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—",
cv: e != null ? cvStr(e.analogs.map((a) => a.price_per_m2)) : "—",
},
filters: buildAdFilters(e),
};
// Deal aggregates: street DKP corridor first, else the estimate's actual_deals.
const dealPrices = e?.actual_deals.map((d) => d.price_rub) ?? [];
const dealCount = streetDeals?.count ?? dealPrices.length;
const dealMedianRub = streetDeals?.median_price_rub ?? median(dealPrices);
const dealLo =
streetDeals?.range_low_rub ??
(dealPrices.length > 0 ? Math.min(...dealPrices) : null);
const dealHi =
streetDeals?.range_high_rub ??
(dealPrices.length > 0 ? Math.max(...dealPrices) : null);
const asking = e?.median_price_rub ?? null;
const deltaPct =
dealMedianRub != null && asking != null && asking > 0
? (dealMedianRub / asking - 1) * 100
: null;
const marketDeals: MarketDeals = {
kpi: {
count: dealCount > 0 ? String(dealCount) : "—",
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—",
range: mlnRangeSp(dealLo, dealHi),
},
};
return { adRows, dealRows, marketAds, marketDeals };
}