Compare commits
No commits in common. "0c0efb887a140705bdedf74d6fb2251663e54849" and "510cf1cb80b891778eec985ca6d44491cbcf97fe" have entirely different histories.
0c0efb887a
...
510cf1cb80
6 changed files with 107 additions and 905 deletions
|
|
@ -23,18 +23,11 @@ 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 { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
|
import type { 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";
|
||||||
|
|
||||||
|
|
@ -47,9 +40,6 @@ import {
|
||||||
useEstimate,
|
useEstimate,
|
||||||
useEstimateHouseAnalytics,
|
useEstimateHouseAnalytics,
|
||||||
useEstimateMutation,
|
useEstimateMutation,
|
||||||
useEstimatePlacementHistory,
|
|
||||||
useEstimateSellTimeSensitivity,
|
|
||||||
useSalesVsListings,
|
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
|
|
||||||
|
|
@ -398,22 +388,9 @@ 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;
|
||||||
|
|
@ -448,24 +425,6 @@ 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>(
|
||||||
|
|
@ -621,9 +580,6 @@ 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,24 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
||||||
// Accepts one composite `data` prop (default = the design fixture); page.tsx
|
// Data comes from fixtures (no API); the price-history dots navigate to the
|
||||||
// feeds mapAnalytics(...) output. The price-history dots navigate to the
|
|
||||||
// "Аналогичные объявления" overlay via onNavigate?.(2).
|
// "Аналогичные объявления" overlay via onNavigate?.(2).
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
import { analytics } from "./fixtures";
|
||||||
import type { Analytics, SellTimeTier } from "./types";
|
import type { 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. Empty
|
// the transparent hover targets can be derived from the same source.
|
||||||
// series (graceful-degradation case) parse to [] — no dots, no hover targets.
|
|
||||||
|
|
||||||
interface Pt {
|
interface Pt {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -29,13 +26,20 @@ 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];
|
||||||
|
|
||||||
|
|
@ -78,35 +82,9 @@ const cardStyle = {
|
||||||
padding: "16px 18px",
|
padding: "16px 18px",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function AnalyticsView({
|
export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
|
||||||
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 */}
|
||||||
|
|
@ -121,7 +99,7 @@ export default function AnalyticsView({
|
||||||
Аналитика дома
|
Аналитика дома
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2 }}>
|
||||||
{data.header.ads} · {data.header.radius}
|
{analytics.header.ads} · {analytics.header.radius}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -133,7 +111,7 @@ export default function AnalyticsView({
|
||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.kpis.map((kpi, i) => (
|
{analytics.kpis.map((kpi, i) => (
|
||||||
<div key={i} style={cardStyle}>
|
<div key={i} style={cardStyle}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -176,7 +154,7 @@ export default function AnalyticsView({
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
|
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
|
||||||
>
|
>
|
||||||
{data.sellTimeNote}
|
{analytics.sellTimeNote}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -185,7 +163,7 @@ export default function AnalyticsView({
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.sellTime.map((tier, i) => {
|
{analytics.sellTime.map((tier, i) => {
|
||||||
const v = tierStyles[tier.variant];
|
const v = tierStyles[tier.variant];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -240,7 +218,7 @@ export default function AnalyticsView({
|
||||||
История цен в этом доме
|
История цен в этом доме
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
{data.priceHistory.note}
|
{analytics.priceHistory.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||||
|
|
@ -300,20 +278,20 @@ export default function AnalyticsView({
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
||||||
{data.priceHistory.years.map((yr, i) => (
|
{analytics.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={data.priceHistory.avito}
|
points={analytics.priceHistory.avito}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.accent}
|
stroke={tokens.accent}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={data.priceHistory.yandex}
|
points={analytics.priceHistory.yandex}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.gold}
|
stroke={tokens.gold}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
|
|
@ -396,7 +374,7 @@ export default function AnalyticsView({
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
Зависимость цены от срока экспозиции ·{" "}
|
Зависимость цены от срока экспозиции ·{" "}
|
||||||
{data.scatterDetail.note}
|
{analytics.scatterDetail.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||||
|
|
@ -497,24 +475,24 @@ export default function AnalyticsView({
|
||||||
opacity={0.45}
|
opacity={0.45}
|
||||||
/>
|
/>
|
||||||
<g fill={tokens.scatterDeal}>
|
<g fill={tokens.scatterDeal}>
|
||||||
{data.scatterDetail.deals.map((p, i) => (
|
{analytics.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}>
|
||||||
{data.scatterDetail.analogs.map((p, i) => (
|
{analytics.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={data.scatterDetail.subject.x}
|
cx={analytics.scatterDetail.subject.x}
|
||||||
cy={data.scatterDetail.subject.y}
|
cy={analytics.scatterDetail.subject.y}
|
||||||
r={data.scatterDetail.subject.r}
|
r={analytics.scatterDetail.subject.r}
|
||||||
fill={tokens.ink}
|
fill={tokens.ink}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={data.scatterDetail.subject.x}
|
cx={analytics.scatterDetail.subject.x}
|
||||||
cy={data.scatterDetail.subject.y}
|
cy={analytics.scatterDetail.subject.y}
|
||||||
r={11}
|
r={11}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={tokens.ink}
|
stroke={tokens.ink}
|
||||||
|
|
@ -527,7 +505,7 @@ export default function AnalyticsView({
|
||||||
fill={tokens.muted2}
|
fill={tokens.muted2}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
>
|
>
|
||||||
{data.scatterDetail.yTicks.map((t) => (
|
{analytics.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>
|
||||||
|
|
@ -539,7 +517,7 @@ export default function AnalyticsView({
|
||||||
fill={tokens.muted2}
|
fill={tokens.muted2}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
{data.scatterDetail.xTicks.map((t) => (
|
{analytics.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,25 +2,16 @@
|
||||||
// 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).
|
||||||
// Data arrives via the composite `data` prop (mapHistory); the design fixtures
|
// Static markup — data comes from fixtures, no fetch / no local state.
|
||||||
// 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";
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
export default function HistoryView() {
|
||||||
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" }}>
|
||||||
{/* ── История продаж в этом доме ───────────────────────────── */}
|
{/* ── История продаж в этом доме ───────────────────────────── */}
|
||||||
|
|
@ -58,7 +49,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.houseSales.length} ЛОТА
|
{history.houseSales.length} ЛОТА
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,7 +71,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
<span>ЭКСПОЗИЦИЯ</span>
|
<span>ЭКСПОЗИЦИЯ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.houseSales.map((row, i) => (
|
{history.houseSales.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -90,7 +81,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
padding: "13px 18px",
|
padding: "13px 18px",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
i < data.houseSales.length - 1
|
i < history.houseSales.length - 1
|
||||||
? `1px solid ${tokens.lineSoft4}`
|
? `1px solid ${tokens.lineSoft4}`
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
|
@ -151,7 +142,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
color: tokens.ink2,
|
color: tokens.ink2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.dkpHeader.title}
|
{history.dkpHeader.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -163,7 +154,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Период:{" "}
|
Период:{" "}
|
||||||
<b style={{ color: tokens.muted }}>{data.dkpHeader.period}</b>
|
<b style={{ color: tokens.muted }}>{history.dkpHeader.period}</b>
|
||||||
<br />
|
<br />
|
||||||
Источник: Росреестр + объявления
|
Источник: Росреестр + объявления
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,7 +166,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
marginTop: "8px",
|
marginTop: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.dkpHeader.note}
|
{history.dkpHeader.note}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -212,7 +203,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.dkpKpi.count}
|
{history.dkpKpi.count}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -239,7 +230,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.dkpKpi.median}{" "}
|
{history.dkpKpi.median}{" "}
|
||||||
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
||||||
₽/м²
|
₽/м²
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -264,7 +255,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.dkpKpi.range}{" "}
|
{history.dkpKpi.range}{" "}
|
||||||
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
<span style={{ fontSize: "11px", color: tokens.muted }}>
|
||||||
млн ₽
|
млн ₽
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -293,7 +284,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.dkpRows.map((r, i) => (
|
{dkpRows.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -306,7 +297,9 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: tokens.body }}>{r.addr ?? "—"}</span>
|
<span style={{ color: tokens.body }}>
|
||||||
|
Екатеринбург, Академика Ландау
|
||||||
|
</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>
|
||||||
|
|
@ -319,7 +312,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
<span
|
<span
|
||||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||||
>
|
>
|
||||||
{r.date ?? "—"}
|
01.01.26
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -342,7 +335,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
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>
|
||||||
|
|
@ -358,7 +351,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
background: tokens.surfaceTint,
|
background: tokens.surfaceTint,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок
|
Показано {dkpRows.length} из {history.dkpKpi.count} сделок
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,27 +12,17 @@ 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
|
||||||
|
|
@ -150,11 +140,9 @@ export default function SectionOverlay({
|
||||||
padding: "22px 24px 26px",
|
padding: "22px 24px 26px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{active === 1 && <HistoryView data={history} />}
|
{active === 1 && <HistoryView />}
|
||||||
{active === 2 && <SourcesView data={sources} />}
|
{active === 2 && <SourcesView />}
|
||||||
{active === 3 && (
|
{active === 3 && <AnalyticsView onNavigate={onNavigate} />}
|
||||||
<AnalyticsView data={analytics} onNavigate={onNavigate} />
|
|
||||||
)}
|
|
||||||
{active === 4 && <CacheView />}
|
{active === 4 && <CacheView />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
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 { SourcesData } from "./mappers";
|
import type { AdRow, DealRow } from "./types";
|
||||||
|
|
||||||
// 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).
|
||||||
// Markup is data-driven via the `data` prop; the design fixtures stay as the
|
// Static markup: data from fixtures, no fetching, no interactive state.
|
||||||
// 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)";
|
||||||
|
|
@ -123,18 +121,6 @@ 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,
|
||||||
|
|
@ -174,20 +160,7 @@ function KpiCell({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default presentation data (unwired/storybook usage): the existing design
|
export default function SourcesView() {
|
||||||
// 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>{`
|
||||||
|
|
@ -215,18 +188,18 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
<div style={kpiRow}>
|
<div style={kpiRow}>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
|
||||||
value={data.marketAds.kpi.count}
|
value={marketAds.kpi.count}
|
||||||
unit="шт · из 2 источников"
|
unit="шт · из 2 источников"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА"
|
label="МЕДИАНА"
|
||||||
value={data.marketAds.kpi.median}
|
value={marketAds.kpi.median}
|
||||||
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
|
unit={`млн ₽ · ${marketAds.kpi.ppm} ₽/м²`}
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ДИАПАЗОН P25–P75"
|
label="ДИАПАЗОН P25–P75"
|
||||||
value={data.marketAds.kpi.range}
|
value={marketAds.kpi.range}
|
||||||
unit={`млн · CV ${data.marketAds.kpi.cv}`}
|
unit={`млн · CV ${marketAds.kpi.cv}`}
|
||||||
valueColor={tokens.accent}
|
valueColor={tokens.accent}
|
||||||
last
|
last
|
||||||
/>
|
/>
|
||||||
|
|
@ -250,7 +223,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
>
|
>
|
||||||
ФИЛЬТР
|
ФИЛЬТР
|
||||||
</span>
|
</span>
|
||||||
{data.marketAds.filters.map((f, i) => (
|
{marketAds.filters.map((f, i) => (
|
||||||
<span key={i} style={chip}>
|
<span key={i} style={chip}>
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -292,55 +265,44 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.adRows.map((r, i) => {
|
{adRows.map((r: AdRow, i) => (
|
||||||
// safeUrl gates href against javascript:/data: schemes (XSS). url is
|
<div
|
||||||
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps
|
key={i}
|
||||||
// the storybook markup pixel-identical), null for a real row with no
|
className="sv-row"
|
||||||
// link (→ "—"), and a string when a real listing URL is available.
|
style={{
|
||||||
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
|
...tableRow,
|
||||||
return (
|
display: "grid",
|
||||||
<div
|
gridTemplateColumns: AD_GRID,
|
||||||
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={{
|
||||||
...tableRow,
|
fontSize: "9.5px",
|
||||||
display: "grid",
|
color: tokens.accent,
|
||||||
gridTemplateColumns: AD_GRID,
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 8px",
|
||||||
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: tokens.body }}>
|
Росреестр ↗
|
||||||
{r.addr}
|
</span>
|
||||||
<span style={metaSpan}>
|
</div>
|
||||||
{" · "}
|
))}
|
||||||
{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>
|
||||||
|
|
||||||
{/* фактические сделки */}
|
{/* фактические сделки */}
|
||||||
|
|
@ -362,18 +324,18 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
<div style={kpiRow}>
|
<div style={kpiRow}>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="СДЕЛОК ПО АНАЛОГАМ"
|
label="СДЕЛОК ПО АНАЛОГАМ"
|
||||||
value={data.marketDeals.kpi.count}
|
value={marketDeals.kpi.count}
|
||||||
unit="шт · Росреестр"
|
unit="шт · Росреестр"
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="МЕДИАНА СДЕЛОК"
|
label="МЕДИАНА СДЕЛОК"
|
||||||
value={data.marketDeals.kpi.median}
|
value={marketDeals.kpi.median}
|
||||||
unit={`млн ₽ · ${data.marketDeals.kpi.delta}`}
|
unit={`млн ₽ · ${marketDeals.kpi.delta}`}
|
||||||
unitColor={tokens.success}
|
unitColor={tokens.success}
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ДИАПАЗОН"
|
label="ДИАПАЗОН"
|
||||||
value={data.marketDeals.kpi.range}
|
value={marketDeals.kpi.range}
|
||||||
unit="млн"
|
unit="млн"
|
||||||
valueColor={tokens.accent}
|
valueColor={tokens.accent}
|
||||||
last
|
last
|
||||||
|
|
@ -394,7 +356,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
<span>ДАТА</span>
|
<span>ДАТА</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.dealRows.map((r, i) => (
|
{dealRows.map((r: DealRow, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="sv-row"
|
className="sv-row"
|
||||||
|
|
@ -412,14 +374,14 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
<span style={badge}>Росреестр</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}>{r.date ?? "—"}</span>
|
<span style={monoMuted2}>01.01.26</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -431,8 +393,7 @@ export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps
|
||||||
background: KPI_BG,
|
background: KPI_BG,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
Показано 10 фактических сделок
|
||||||
фактических сделок
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,44 +26,27 @@ 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;
|
||||||
|
|
@ -798,660 +781,3 @@ 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