feat(tradein/v2): wire overlays History/Analytics/Sources to real API (FE-2/3/4) #2051
6 changed files with 905 additions and 107 deletions
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="ДИАПАЗОН P25–P75"
|
||||
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,44 +292,55 @@ export default function SourcesView() {
|
|||
<span />
|
||||
</div>
|
||||
|
||||
{adRows.map((r: AdRow, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="sv-row"
|
||||
style={{
|
||||
...tableRow,
|
||||
display: "grid",
|
||||
gridTemplateColumns: AD_GRID,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
<span style={metaSpan}>
|
||||
{" · "}
|
||||
{r.meta}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={badge}>Avito</span>
|
||||
</span>
|
||||
<span style={monoMuted}>{r.ppm}</span>
|
||||
<span style={monoInk}>{r.price}</span>
|
||||
<span style={monoMuted2}>—</span>
|
||||
<span
|
||||
className="sv-rr"
|
||||
{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"
|
||||
style={{
|
||||
fontSize: "9.5px",
|
||||
color: tokens.accent,
|
||||
border: `1px solid ${tokens.line2}`,
|
||||
borderRadius: 4,
|
||||
padding: "3px 8px",
|
||||
textAlign: "center",
|
||||
...tableRow,
|
||||
display: "grid",
|
||||
gridTemplateColumns: AD_GRID,
|
||||
}}
|
||||
>
|
||||
Росреестр ↗
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
<span style={metaSpan}>
|
||||
{" · "}
|
||||
{r.meta}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={badge}>{r.source ?? "Avito"}</span>
|
||||
</span>
|
||||
<span style={monoMuted}>{r.ppm}</span>
|
||||
<span style={monoInk}>{r.price}</span>
|
||||
<span style={monoMuted2}>{r.dist ?? "—"}</span>
|
||||
{href ? (
|
||||
<a
|
||||
className="sv-rr"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 P25–P75). 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
|
||||
* deal↔listing rows (sales-vs-listings). Each source is independent: a null one
|
||||
* leaves its block at "—"/empty without blanking the others.
|
||||
*/
|
||||
export function mapHistory(
|
||||
placement: PlacementHistoryItem[] | null,
|
||||
streetDeals: StreetDealsResponse | null,
|
||||
salesVsListings: SalesVsListingsResponse | null,
|
||||
): HistoryData {
|
||||
const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({
|
||||
lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors),
|
||||
priceFrom: fmtMln(p.start_price),
|
||||
priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—",
|
||||
date: fmtDate(p.last_price_date),
|
||||
exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—",
|
||||
}));
|
||||
|
||||
const street = streetDeals?.street ?? null;
|
||||
const title = street
|
||||
? `ДКП-сделки на улице ${street}`
|
||||
: "ДКП-сделки на вашей улице";
|
||||
const period =
|
||||
salesVsListings?.period_months != null
|
||||
? `${salesVsListings.period_months} мес`
|
||||
: "—";
|
||||
|
||||
const noteParts: string[] = [];
|
||||
if (salesVsListings?.linkage_rate_pct != null) {
|
||||
noteParts.push(
|
||||
`${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`,
|
||||
);
|
||||
}
|
||||
if (salesVsListings?.median_discount_pct != null) {
|
||||
noteParts.push(`медианный торг ${pct1(salesVsListings.median_discount_pct)}`);
|
||||
}
|
||||
const note =
|
||||
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
|
||||
"Данные по улице, не по дому.";
|
||||
|
||||
const dkpKpi = {
|
||||
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
|
||||
median:
|
||||
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
|
||||
? numRu(streetDeals.median_price_per_m2)
|
||||
: "—",
|
||||
range:
|
||||
streetDeals != null
|
||||
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
|
||||
: "—",
|
||||
};
|
||||
|
||||
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
|
||||
const ask =
|
||||
pr.listing_price_rub != null
|
||||
? `${numRu(pr.listing_price_rub)} ₽${
|
||||
pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0
|
||||
? ` за ${pr.days_listing_to_deal} дн до`
|
||||
: ""
|
||||
}`
|
||||
: "—";
|
||||
const deltaColor =
|
||||
pr.discount_pct == null
|
||||
? tokens.muted
|
||||
: pr.discount_pct < 0
|
||||
? tokens.success
|
||||
: tokens.danger;
|
||||
return {
|
||||
area: `${pr.deal_area_m2.toFixed(1)} м²`,
|
||||
price: `${numRu(pr.deal_price_rub)} ₽`,
|
||||
ppm: numRu(pr.deal_price_per_m2),
|
||||
ask,
|
||||
delta: pct1(pr.discount_pct),
|
||||
deltaColor,
|
||||
addr: pr.deal_address || 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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue