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
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:
commit
0c0efb887a
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 { Footer } from "@/components/trade-in/v2/Footer";
|
||||||
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
||||||
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
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 {
|
import {
|
||||||
|
mapAnalytics,
|
||||||
|
mapHistory,
|
||||||
mapObject,
|
mapObject,
|
||||||
mapReport,
|
mapReport,
|
||||||
mapResultPanel,
|
mapResultPanel,
|
||||||
|
mapSources,
|
||||||
mapSummary,
|
mapSummary,
|
||||||
} from "@/components/trade-in/v2/mappers";
|
} from "@/components/trade-in/v2/mappers";
|
||||||
|
|
||||||
|
|
@ -40,6 +47,9 @@ import {
|
||||||
useEstimate,
|
useEstimate,
|
||||||
useEstimateHouseAnalytics,
|
useEstimateHouseAnalytics,
|
||||||
useEstimateMutation,
|
useEstimateMutation,
|
||||||
|
useEstimatePlacementHistory,
|
||||||
|
useEstimateSellTimeSensitivity,
|
||||||
|
useSalesVsListings,
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
|
|
||||||
|
|
@ -388,9 +398,22 @@ export default function TradeInV2Page() {
|
||||||
estimate?.rooms ?? null,
|
estimate?.rooms ?? null,
|
||||||
);
|
);
|
||||||
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
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 streetDealsData = streetDeals.data ?? null;
|
||||||
const analyticsData = analytics.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 ───────────────────────────────────────────────────
|
// ── Derived state flags ───────────────────────────────────────────────────
|
||||||
const restoreActive = urlId !== null && freshResult === null;
|
const restoreActive = urlId !== null && freshResult === null;
|
||||||
|
|
@ -425,6 +448,24 @@ export default function TradeInV2Page() {
|
||||||
[estimate, analyticsData, streetDealsData],
|
[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
|
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
|
||||||
// estimate arrives async (useState reads initialValues only on mount).
|
// estimate arrives async (useState reads initialValues only on mount).
|
||||||
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
||||||
|
|
@ -580,6 +621,9 @@ export default function TradeInV2Page() {
|
||||||
active={nav}
|
active={nav}
|
||||||
onClose={() => setNav(0)}
|
onClose={() => setNav(0)}
|
||||||
onNavigate={setNav}
|
onNavigate={setNav}
|
||||||
|
history={historyData}
|
||||||
|
analytics={analyticsViewData}
|
||||||
|
sources={sourcesData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
// 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).
|
// "Аналогичные объявления" overlay via onNavigate?.(2).
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { analytics } from "./fixtures";
|
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
||||||
import type { SellTimeTier } from "./types";
|
import type { Analytics, SellTimeTier } from "./types";
|
||||||
|
|
||||||
interface AnalyticsViewProps {
|
interface AnalyticsViewProps {
|
||||||
|
data?: Analytics;
|
||||||
onNavigate?: (i: number) => void;
|
onNavigate?: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- price-history geometry -----------------------------------------------
|
// ---- price-history geometry -----------------------------------------------
|
||||||
// Parse the SVG polyline point strings into coordinates so the markers and
|
// 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 {
|
interface Pt {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -26,20 +29,13 @@ function parsePoints(s: string): Pt[] {
|
||||||
return s
|
return s
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
|
.filter((pair) => pair.length > 0)
|
||||||
.map((pair) => {
|
.map((pair) => {
|
||||||
const [x, y] = pair.split(",").map(Number);
|
const [x, y] = pair.split(",").map(Number);
|
||||||
return { x, y };
|
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).
|
// X positions of the year axis labels (chart scaffolding, not in fixtures).
|
||||||
const histYearX = [50, 250, 460, 670, 860];
|
const histYearX = [50, 250, 460, 670, 860];
|
||||||
|
|
||||||
|
|
@ -82,9 +78,35 @@ const cardStyle = {
|
||||||
padding: "16px 18px",
|
padding: "16px 18px",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
export default function AnalyticsView({
|
||||||
|
data = ANALYTICS_FIXTURE,
|
||||||
|
onNavigate,
|
||||||
|
}: AnalyticsViewProps) {
|
||||||
const [hoverHist, setHoverHist] = useState(false);
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
{/* header */}
|
{/* header */}
|
||||||
|
|
@ -99,7 +121,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
Аналитика дома
|
Аналитика дома
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2 }}>
|
||||||
{analytics.header.ads} · {analytics.header.radius}
|
{data.header.ads} · {data.header.radius}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -111,7 +133,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{analytics.kpis.map((kpi, i) => (
|
{data.kpis.map((kpi, i) => (
|
||||||
<div key={i} style={cardStyle}>
|
<div key={i} style={cardStyle}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -154,7 +176,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
|
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
|
||||||
>
|
>
|
||||||
{analytics.sellTimeNote}
|
{data.sellTimeNote}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -163,7 +185,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{analytics.sellTime.map((tier, i) => {
|
{data.sellTime.map((tier, i) => {
|
||||||
const v = tierStyles[tier.variant];
|
const v = tierStyles[tier.variant];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -218,7 +240,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
История цен в этом доме
|
История цен в этом доме
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
{analytics.priceHistory.note}
|
{data.priceHistory.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||||
|
|
@ -278,20 +300,20 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
<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}>
|
<text key={yr} x={histYearX[i]} y={214}>
|
||||||
{yr}
|
{yr}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<polyline
|
<polyline
|
||||||
points={analytics.priceHistory.avito}
|
points={data.priceHistory.avito}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.accent}
|
stroke={tokens.accent}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={analytics.priceHistory.yandex}
|
points={data.priceHistory.yandex}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.gold}
|
stroke={tokens.gold}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
|
|
@ -374,7 +396,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
Зависимость цены от срока экспозиции ·{" "}
|
Зависимость цены от срока экспозиции ·{" "}
|
||||||
{analytics.scatterDetail.note}
|
{data.scatterDetail.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||||
|
|
@ -475,24 +497,24 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
opacity={0.45}
|
opacity={0.45}
|
||||||
/>
|
/>
|
||||||
<g fill={tokens.scatterDeal}>
|
<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} />
|
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<g fill={tokens.accent}>
|
<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} />
|
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<circle
|
<circle
|
||||||
cx={analytics.scatterDetail.subject.x}
|
cx={data.scatterDetail.subject.x}
|
||||||
cy={analytics.scatterDetail.subject.y}
|
cy={data.scatterDetail.subject.y}
|
||||||
r={analytics.scatterDetail.subject.r}
|
r={data.scatterDetail.subject.r}
|
||||||
fill={tokens.ink}
|
fill={tokens.ink}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={analytics.scatterDetail.subject.x}
|
cx={data.scatterDetail.subject.x}
|
||||||
cy={analytics.scatterDetail.subject.y}
|
cy={data.scatterDetail.subject.y}
|
||||||
r={11}
|
r={11}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.ink}
|
stroke={tokens.ink}
|
||||||
|
|
@ -505,7 +527,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
fill={tokens.muted2}
|
fill={tokens.muted2}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
>
|
>
|
||||||
{analytics.scatterDetail.yTicks.map((t) => (
|
{data.scatterDetail.yTicks.map((t) => (
|
||||||
<text key={t.label} x={52} y={t.y}>
|
<text key={t.label} x={52} y={t.y}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -517,7 +539,7 @@ export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
fill={tokens.muted2}
|
fill={tokens.muted2}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
{analytics.scatterDetail.xTicks.map((t) => (
|
{data.scatterDetail.xTicks.map((t) => (
|
||||||
<text key={t.label} x={t.x} y={268}>
|
<text key={t.label} x={t.x} y={268}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</text>
|
</text>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,25 @@
|
||||||
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
|
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
|
||||||
// lines 457-489): "История продаж в этом доме" card (house sales) +
|
// lines 457-489): "История продаж в этом доме" card (house sales) +
|
||||||
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
|
// "ДКП-сделки на улице" 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 { tokens } from "./tokens";
|
||||||
import { history, dkpRows } from "./fixtures";
|
import { history, dkpRows } from "./fixtures";
|
||||||
|
import type { HistoryData } from "./mappers";
|
||||||
|
|
||||||
// Shared grid templates (kept verbatim from the design column tracks).
|
// Shared grid templates (kept verbatim from the design column tracks).
|
||||||
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
||||||
const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr";
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
|
||||||
{/* ── История продаж в этом доме ───────────────────────────── */}
|
{/* ── История продаж в этом доме ───────────────────────────── */}
|
||||||
|
|
@ -49,7 +58,7 @@ export default function HistoryView() {
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.houseSales.length} ЛОТА
|
{data.houseSales.length} ЛОТА
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -71,7 +80,7 @@ export default function HistoryView() {
|
||||||
<span>ЭКСПОЗИЦИЯ</span>
|
<span>ЭКСПОЗИЦИЯ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{history.houseSales.map((row, i) => (
|
{data.houseSales.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -81,7 +90,7 @@ export default function HistoryView() {
|
||||||
padding: "13px 18px",
|
padding: "13px 18px",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
i < history.houseSales.length - 1
|
i < data.houseSales.length - 1
|
||||||
? `1px solid ${tokens.lineSoft4}`
|
? `1px solid ${tokens.lineSoft4}`
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
|
@ -142,7 +151,7 @@ export default function HistoryView() {
|
||||||
color: tokens.ink2,
|
color: tokens.ink2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.dkpHeader.title}
|
{data.dkpHeader.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 />
|
<br />
|
||||||
Источник: Росреестр + объявления
|
Источник: Росреестр + объявления
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,7 +175,7 @@ export default function HistoryView() {
|
||||||
marginTop: "8px",
|
marginTop: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.dkpHeader.note}
|
{data.dkpHeader.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -203,7 +212,7 @@ export default function HistoryView() {
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.dkpKpi.count}
|
{data.dkpKpi.count}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -230,7 +239,7 @@ export default function HistoryView() {
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.dkpKpi.median}{" "}
|
{data.dkpKpi.median}{" "}
|
||||||
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
||||||
₽/м²
|
₽/м²
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -255,7 +264,7 @@ export default function HistoryView() {
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.dkpKpi.range}{" "}
|
{data.dkpKpi.range}{" "}
|
||||||
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
||||||
млн ₽
|
млн ₽
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -284,7 +293,7 @@ export default function HistoryView() {
|
||||||
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dkpRows.map((r, i) => (
|
{data.dkpRows.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -297,9 +306,7 @@ export default function HistoryView() {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: tokens.body }}>
|
<span style={{ color: tokens.body }}>{r.addr ?? "—"}</span>
|
||||||
Екатеринбург, Академика Ландау
|
|
||||||
</span>
|
|
||||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||||
{r.area}
|
{r.area}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -312,7 +319,7 @@ export default function HistoryView() {
|
||||||
<span
|
<span
|
||||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||||
>
|
>
|
||||||
01.01.26
|
{r.date ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -335,7 +342,7 @@ export default function HistoryView() {
|
||||||
color: tokens.accent,
|
color: tokens.accent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Яндекс
|
{r.source ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{r.ask}
|
{r.ask}
|
||||||
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
||||||
|
|
@ -351,7 +358,7 @@ export default function HistoryView() {
|
||||||
background: tokens.surfaceTint,
|
background: tokens.surfaceTint,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показано {dkpRows.length} из {history.dkpKpi.count} сделок
|
Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,27 @@ import HistoryView from "./HistoryView";
|
||||||
import SourcesView from "./SourcesView";
|
import SourcesView from "./SourcesView";
|
||||||
import AnalyticsView from "./AnalyticsView";
|
import AnalyticsView from "./AnalyticsView";
|
||||||
import { CacheView } from "./CacheView";
|
import { CacheView } from "./CacheView";
|
||||||
|
import type { HistoryData, SourcesData } from "./mappers";
|
||||||
|
import type { Analytics } from "./types";
|
||||||
|
|
||||||
interface SectionOverlayProps {
|
interface SectionOverlayProps {
|
||||||
active: number;
|
active: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate: (i: number) => 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({
|
export default function SectionOverlay({
|
||||||
active,
|
active,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
history,
|
||||||
|
sources,
|
||||||
|
analytics,
|
||||||
}: SectionOverlayProps) {
|
}: SectionOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -140,9 +150,11 @@ export default function SectionOverlay({
|
||||||
padding: "22px 24px 26px",
|
padding: "22px 24px 26px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{active === 1 && <HistoryView />}
|
{active === 1 && <HistoryView data={history} />}
|
||||||
{active === 2 && <SourcesView />}
|
{active === 2 && <SourcesView data={sources} />}
|
||||||
{active === 3 && <AnalyticsView onNavigate={onNavigate} />}
|
{active === 3 && (
|
||||||
|
<AnalyticsView data={analytics} onNavigate={onNavigate} />
|
||||||
|
)}
|
||||||
{active === 4 && <CacheView />}
|
{active === 4 && <CacheView />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
||||||
import type { AdRow, DealRow } from "./types";
|
import type { SourcesData } from "./mappers";
|
||||||
|
|
||||||
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
||||||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||||
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
||||||
// Card B: фактические сделки (3 KPI + dealRows 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).
|
// One-off tints not present in the v2 palette (kept literal, faithful to design).
|
||||||
const KPI_BG = "rgba(238,244,250,.5)";
|
const KPI_BG = "rgba(238,244,250,.5)";
|
||||||
|
|
@ -121,6 +123,18 @@ const monoMuted2: CSSProperties = {
|
||||||
color: tokens.muted2,
|
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({
|
function KpiCell({
|
||||||
label,
|
label,
|
||||||
value,
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -188,18 +215,18 @@ export default function SourcesView() {
|
||||||
<div style={kpiRow}>
|
<div style={kpiRow}>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
||||||
value={marketAds.kpi.count}
|
value={data.marketAds.kpi.count}
|
||||||
unit="шт · из 2 источников"
|
unit="шт · из 2 источников"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА"
|
label="МЕДИАНА"
|
||||||
value={marketAds.kpi.median}
|
value={data.marketAds.kpi.median}
|
||||||
unit={`млн ₽ · ${marketAds.kpi.ppm} ₽/м²`}
|
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ДИАПАЗОН P25–P75"
|
label="ДИАПАЗОН P25–P75"
|
||||||
value={marketAds.kpi.range}
|
value={data.marketAds.kpi.range}
|
||||||
unit={`млн · CV ${marketAds.kpi.cv}`}
|
unit={`млн · CV ${data.marketAds.kpi.cv}`}
|
||||||
valueColor={tokens.accent}
|
valueColor={tokens.accent}
|
||||||
last
|
last
|
||||||
/>
|
/>
|
||||||
|
|
@ -223,7 +250,7 @@ export default function SourcesView() {
|
||||||
>
|
>
|
||||||
ФИЛЬТР
|
ФИЛЬТР
|
||||||
</span>
|
</span>
|
||||||
{marketAds.filters.map((f, i) => (
|
{data.marketAds.filters.map((f, i) => (
|
||||||
<span key={i} style={chip}>
|
<span key={i} style={chip}>
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -265,44 +292,55 @@ export default function SourcesView() {
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adRows.map((r: AdRow, i) => (
|
{data.adRows.map((r, i) => {
|
||||||
<div
|
// safeUrl gates href against javascript:/data: schemes (XSS). url is
|
||||||
key={i}
|
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps
|
||||||
className="sv-row"
|
// the storybook markup pixel-identical), null for a real row with no
|
||||||
style={{
|
// link (→ "—"), and a string when a real listing URL is available.
|
||||||
...tableRow,
|
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
|
||||||
display: "grid",
|
return (
|
||||||
gridTemplateColumns: AD_GRID,
|
<div
|
||||||
}}
|
key={i}
|
||||||
>
|
className="sv-row"
|
||||||
<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"
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: "9.5px",
|
...tableRow,
|
||||||
color: tokens.accent,
|
display: "grid",
|
||||||
border: `1px solid ${tokens.line2}`,
|
gridTemplateColumns: AD_GRID,
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 8px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Росреестр ↗
|
<span style={{ color: tokens.body }}>
|
||||||
</span>
|
{r.addr}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* фактические сделки */}
|
{/* фактические сделки */}
|
||||||
|
|
@ -324,18 +362,18 @@ export default function SourcesView() {
|
||||||
<div style={kpiRow}>
|
<div style={kpiRow}>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="СДЕЛОК ПО АНАЛОГАМ"
|
label="СДЕЛОК ПО АНАЛОГАМ"
|
||||||
value={marketDeals.kpi.count}
|
value={data.marketDeals.kpi.count}
|
||||||
unit="шт · Росреестр"
|
unit="шт · Росреестр"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА СДЕЛОК"
|
label="МЕДИАНА СДЕЛОК"
|
||||||
value={marketDeals.kpi.median}
|
value={data.marketDeals.kpi.median}
|
||||||
unit={`млн ₽ · ${marketDeals.kpi.delta}`}
|
unit={`млн ₽ · ${data.marketDeals.kpi.delta}`}
|
||||||
unitColor={tokens.success}
|
unitColor={tokens.success}
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ДИАПАЗОН"
|
label="ДИАПАЗОН"
|
||||||
value={marketDeals.kpi.range}
|
value={data.marketDeals.kpi.range}
|
||||||
unit="млн"
|
unit="млн"
|
||||||
valueColor={tokens.accent}
|
valueColor={tokens.accent}
|
||||||
last
|
last
|
||||||
|
|
@ -356,7 +394,7 @@ export default function SourcesView() {
|
||||||
<span>ДАТА</span>
|
<span>ДАТА</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dealRows.map((r: DealRow, i) => (
|
{data.dealRows.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="sv-row"
|
className="sv-row"
|
||||||
|
|
@ -374,14 +412,14 @@ export default function SourcesView() {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span style={badge}>Росреестр</span>{" "}
|
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
||||||
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
||||||
по улице
|
{r.tier ?? "по улице"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span style={monoMuted}>{r.ppm}</span>
|
<span style={monoMuted}>{r.ppm}</span>
|
||||||
<span style={monoInk}>{r.price}</span>
|
<span style={monoInk}>{r.price}</span>
|
||||||
<span style={monoMuted2}>01.01.26</span>
|
<span style={monoMuted2}>{r.date ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -393,7 +431,8 @@ export default function SourcesView() {
|
||||||
background: KPI_BG,
|
background: KPI_BG,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показано 10 фактических сделок
|
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
||||||
|
фактических сделок
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,27 +26,44 @@ import type {
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
ConfidenceLevel,
|
ConfidenceLevel,
|
||||||
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseType,
|
HouseType,
|
||||||
|
PlacementHistoryItem,
|
||||||
RepairState,
|
RepairState,
|
||||||
|
SalesVsListingsResponse,
|
||||||
|
SellTimeSensitivityResponse,
|
||||||
StreetDealsResponse,
|
StreetDealsResponse,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
import type {
|
import type {
|
||||||
|
AdRow,
|
||||||
|
Analytics,
|
||||||
|
AnalyticsKpi,
|
||||||
AxisTickX,
|
AxisTickX,
|
||||||
AxisTickY,
|
AxisTickY,
|
||||||
|
DealRow,
|
||||||
|
DkpRow,
|
||||||
|
History,
|
||||||
|
HouseSaleRow,
|
||||||
|
MarketAds,
|
||||||
|
MarketDeals,
|
||||||
ObjectInfo,
|
ObjectInfo,
|
||||||
|
PriceHistory,
|
||||||
RangeBar,
|
RangeBar,
|
||||||
RangeMarker,
|
RangeMarker,
|
||||||
Ranges,
|
Ranges,
|
||||||
Report,
|
Report,
|
||||||
ResultCard,
|
ResultCard,
|
||||||
ResultMeta,
|
ResultMeta,
|
||||||
|
ScatterDetail,
|
||||||
ScatterMini,
|
ScatterMini,
|
||||||
ScatterPoint,
|
ScatterPoint,
|
||||||
|
SellTimeTier,
|
||||||
SourceCard,
|
SourceCard,
|
||||||
Summary,
|
Summary,
|
||||||
SummaryRow,
|
SummaryRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { tokens } from "./tokens";
|
||||||
|
|
||||||
// Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7".
|
// Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7".
|
||||||
const TOTAL_SOURCES = 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