feat(tradein): web-native analytics cards on estimate result (map, distribution, trend, exposure) #688
6 changed files with 673 additions and 0 deletions
|
|
@ -25,6 +25,10 @@ import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
|||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
||||
import { MapCard } from "@/components/trade-in/MapCard";
|
||||
import { DistributionCard } from "@/components/trade-in/DistributionCard";
|
||||
import { PriceTrendCard } from "@/components/trade-in/PriceTrendCard";
|
||||
import { ExposureCard } from "@/components/trade-in/ExposureCard";
|
||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||
import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel";
|
||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||
|
|
@ -257,6 +261,11 @@ export default function TradeInPage() {
|
|||
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||||
<DealsCard estimate={resultData.estimate} />
|
||||
<StreetDealsCard estimate={resultData.estimate} />
|
||||
{/* ANALYTICS cards — каждая с graceful guard «render only if data present» */}
|
||||
<MapCard estimate={resultData.estimate} />
|
||||
<DistributionCard estimate={resultData.estimate} />
|
||||
<PriceTrendCard estimate={resultData.estimate} />
|
||||
<ExposureCard estimate={resultData.estimate} />
|
||||
<OfferCard estimate={resultData.estimate} brandSlug={activeBrandSlug} />
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DistributionCard — гистограмма распределения ₽/м² по аналогам с маркером
|
||||
* «ваша квартира здесь» на median_price_per_m2 оценки. Reuses recharts (как
|
||||
* PriceHistoryChart.tsx) — никаких новых зависимостей.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
}
|
||||
|
||||
const BIN_COUNT = 12;
|
||||
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
|
||||
|
||||
interface Bin {
|
||||
/** Левая граница бина, ₽/м². */
|
||||
start: number;
|
||||
/** Правая граница бина, ₽/м². */
|
||||
end: number;
|
||||
/** Центр бина (X-координата столбца). */
|
||||
mid: number;
|
||||
count: number;
|
||||
/** True если в этот бин попадает median_price_per_m2 оценки. */
|
||||
isSubject: boolean;
|
||||
}
|
||||
|
||||
export function DistributionCard({ estimate }: Props) {
|
||||
const subject = estimate.median_price_per_m2;
|
||||
|
||||
const bins = useMemo<Bin[]>(() => {
|
||||
const values = estimate.analogs
|
||||
.map((l) => l.price_per_m2)
|
||||
.filter((v) => typeof v === "number" && v > 0);
|
||||
if (values.length < 3) return [];
|
||||
|
||||
const min = Math.min(...values, subject);
|
||||
const max = Math.max(...values, subject);
|
||||
if (max <= min) return [];
|
||||
const width = (max - min) / BIN_COUNT;
|
||||
|
||||
const out: Bin[] = Array.from({ length: BIN_COUNT }, (_, i) => {
|
||||
const start = min + i * width;
|
||||
const end = i === BIN_COUNT - 1 ? max : start + width;
|
||||
return { start, end, mid: (start + end) / 2, count: 0, isSubject: false };
|
||||
});
|
||||
|
||||
for (const v of values) {
|
||||
let idx = Math.floor((v - min) / width);
|
||||
if (idx >= BIN_COUNT) idx = BIN_COUNT - 1;
|
||||
if (idx < 0) idx = 0;
|
||||
out[idx].count += 1;
|
||||
}
|
||||
|
||||
let subjIdx = Math.floor((subject - min) / width);
|
||||
if (subjIdx >= BIN_COUNT) subjIdx = BIN_COUNT - 1;
|
||||
if (subjIdx < 0) subjIdx = 0;
|
||||
out[subjIdx].isSubject = true;
|
||||
|
||||
return out;
|
||||
}, [estimate.analogs, subject]);
|
||||
|
||||
// Empty state: меньше 3 валидных аналогов — распределение не показываем.
|
||||
if (bins.length === 0) return null;
|
||||
|
||||
const n = estimate.analogs.filter((l) => l.price_per_m2 > 0).length;
|
||||
|
||||
return (
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<div className="section-kicker">Аналитика · Распределение</div>
|
||||
<h2>Где ваша квартира в локальном рынке</h2>
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<div>
|
||||
По <b className="mono">{n}</b> аналогам
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>₽/м² · гистограмма</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: "100%", height: 260, padding: "12px 8px 0" }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={bins} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="mid"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={fmtK}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--surface-3)" }}
|
||||
formatter={(value: number) => [`${value} лот.`, "В диапазоне"]}
|
||||
labelFormatter={(mid: number) => {
|
||||
const b = bins.find((x) => x.mid === mid);
|
||||
return b
|
||||
? `${Math.round(b.start).toLocaleString("ru-RU")}–${Math.round(
|
||||
b.end,
|
||||
).toLocaleString("ru-RU")} ₽/м²`
|
||||
: `${Math.round(mid).toLocaleString("ru-RU")} ₽/м²`;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
||||
{bins.map((b, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={b.isSubject ? "var(--accent-2)" : "var(--accent)"}
|
||||
fillOpacity={b.isSubject ? 1 : 0.55}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<ReferenceLine
|
||||
x={subject}
|
||||
stroke="var(--accent-2)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 2"
|
||||
label={{
|
||||
value: "ваша квартира",
|
||||
position: "top",
|
||||
fontSize: 11,
|
||||
fill: "var(--accent-2)",
|
||||
}}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Ваша оценка:{" "}
|
||||
<b style={{ color: "var(--fg)" }}>{subject.toLocaleString("ru-RU")} ₽/м²</b> ·
|
||||
оранжевый столбец — ваш диапазон
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
133
tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx
Normal file
133
tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ExposureCard — ликвидность: scatter цена (₽) × срок экспозиции (дней) по
|
||||
* аналогам. Дешевле → быстрее продаётся. Цена оценки отмечена reference-линией.
|
||||
* Reuses recharts (как PriceHistoryChart.tsx). Degrade gracefully: рендерим
|
||||
* только если ≥3 аналогов имеют непустой days_on_market.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ZAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
}
|
||||
|
||||
const fmtM = (n: number) => `${(n / 1_000_000).toFixed(1)}`;
|
||||
|
||||
interface Point {
|
||||
price: number; // ₽
|
||||
days: number; // срок экспозиции
|
||||
address: string;
|
||||
}
|
||||
|
||||
export function ExposureCard({ estimate }: Props) {
|
||||
const points = useMemo<Point[]>(
|
||||
() =>
|
||||
estimate.analogs
|
||||
.filter(
|
||||
(l) =>
|
||||
typeof l.days_on_market === "number" &&
|
||||
(l.days_on_market as number) > 0 &&
|
||||
l.price_rub > 0,
|
||||
)
|
||||
.map((l) => ({
|
||||
price: l.price_rub,
|
||||
days: l.days_on_market as number,
|
||||
address: l.address,
|
||||
})),
|
||||
[estimate.analogs],
|
||||
);
|
||||
|
||||
// Degrade gracefully: <3 точек со сроком — карточку не показываем
|
||||
// (сводится в DistributionCard, который не требует days_on_market).
|
||||
if (points.length < 3) return null;
|
||||
|
||||
const subjectPrice = estimate.median_price_rub;
|
||||
|
||||
return (
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<div className="section-kicker">Аналитика · Ликвидность</div>
|
||||
<h2>Цена и срок экспозиции</h2>
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<div>
|
||||
По <b className="mono">{points.length}</b> аналогам
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>дешевле → быстрее</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: "100%", height: 280, padding: "12px 8px 0" }}>
|
||||
<ResponsiveContainer>
|
||||
<ScatterChart margin={{ top: 8, right: 20, left: 8, bottom: 16 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="price"
|
||||
name="Цена"
|
||||
tickFormatter={fmtM}
|
||||
tick={{ fontSize: 11 }}
|
||||
domain={["auto", "auto"]}
|
||||
label={{ value: "млн ₽", position: "insideBottomRight", offset: -8, fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="days"
|
||||
name="Срок"
|
||||
tick={{ fontSize: 11 }}
|
||||
label={{ value: "дней", angle: -90, position: "insideLeft", fontSize: 11 }}
|
||||
/>
|
||||
<ZAxis range={[60, 60]} />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: "3 3" }}
|
||||
formatter={(value: number, name: string) =>
|
||||
name === "Цена"
|
||||
? [`${value.toLocaleString("ru-RU")} ₽`, name]
|
||||
: [`${value} дн.`, name]
|
||||
}
|
||||
/>
|
||||
<Scatter
|
||||
data={points}
|
||||
fill="var(--accent)"
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={subjectPrice}
|
||||
stroke="var(--accent-2)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 2"
|
||||
label={{
|
||||
value: "ваша цена",
|
||||
position: "top",
|
||||
fontSize: 11,
|
||||
fill: "var(--accent-2)",
|
||||
}}
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Каждая точка — аналог в продаже · вертикаль — ваша оценка{" "}
|
||||
<b style={{ color: "var(--fg)" }}>{fmtM(subjectPrice)} млн ₽</b>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
250
tradein-mvp/frontend/src/components/trade-in/MapCard.tsx
Normal file
250
tradein-mvp/frontend/src/components/trade-in/MapCard.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* MapCard — карта аналитики: целевая квартира + аналоги (asking) + ДКП-сделки.
|
||||
* Reuses the same CDN-loaded Leaflet + OSM approach as MapPicker.tsx (no npm dep).
|
||||
* Пины с null lat/lon пропускаются; карточка не рендерится без гео-точек.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||
|
||||
const LEAFLET_VER = "1.9.4";
|
||||
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||
|
||||
// Цвета пинов: asking (синий) / ДКП (зелёный) / target (оранжевый акцент).
|
||||
const COLOR_ANALOG = "#2563eb";
|
||||
const COLOR_DEAL = "#16a34a";
|
||||
const COLOR_TARGET = "#ea580c";
|
||||
|
||||
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapPicker) */
|
||||
function loadLeaflet(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = window as any;
|
||||
if (w.L) {
|
||||
resolve(w.L);
|
||||
return;
|
||||
}
|
||||
if (!document.querySelector(`link[data-leaflet]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = LEAFLET_CSS;
|
||||
link.integrity = LEAFLET_CSS_SRI;
|
||||
link.crossOrigin = "anonymous";
|
||||
link.setAttribute("data-leaflet", "1");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve(w.L));
|
||||
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = LEAFLET_JS;
|
||||
script.integrity = LEAFLET_JS_SRI;
|
||||
script.crossOrigin = "anonymous";
|
||||
script.setAttribute("data-leaflet", "1");
|
||||
script.onload = () => resolve(w.L);
|
||||
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
}
|
||||
|
||||
function fmtRub(v: number): string {
|
||||
return v.toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
/** Безопасное экранирование для вставки в HTML popup. */
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'",
|
||||
);
|
||||
}
|
||||
|
||||
function popupHtml(lot: AnalogLot, kind: string): string {
|
||||
const expo =
|
||||
lot.days_on_market !== null && lot.days_on_market !== undefined
|
||||
? `${lot.days_on_market} дн. экспозиции`
|
||||
: "срок не указан";
|
||||
return `<div style="font-size:12px;line-height:1.5">
|
||||
<b>${esc(lot.address)}</b><br/>
|
||||
<span style="color:#6b7280">${kind}</span><br/>
|
||||
${fmtRub(lot.price_per_m2)} ₽/м² · ${fmtRub(lot.price_rub)} ₽<br/>
|
||||
${esc(expo)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function MapCard({ estimate }: Props) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const [mapError, setMapError] = useState(false);
|
||||
|
||||
const analogs = useMemo(
|
||||
() =>
|
||||
estimate.analogs.filter(
|
||||
(l) => typeof l.lat === "number" && typeof l.lon === "number",
|
||||
),
|
||||
[estimate.analogs],
|
||||
);
|
||||
const deals = useMemo(
|
||||
() =>
|
||||
estimate.actual_deals.filter(
|
||||
(l) => typeof l.lat === "number" && typeof l.lon === "number",
|
||||
),
|
||||
[estimate.actual_deals],
|
||||
);
|
||||
|
||||
const hasTarget =
|
||||
typeof estimate.target_lat === "number" && typeof estimate.target_lon === "number";
|
||||
const totalPins = analogs.length + deals.length + (hasTarget ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalPins === 0) return;
|
||||
let map: any = null;
|
||||
let cancelled = false;
|
||||
|
||||
loadLeaflet()
|
||||
.then((L) => {
|
||||
if (cancelled || !mapRef.current) return;
|
||||
const firstPin = analogs[0] ?? deals[0];
|
||||
let center: [number, number] | null = null;
|
||||
if (hasTarget) {
|
||||
center = [estimate.target_lat as number, estimate.target_lon as number];
|
||||
} else if (firstPin) {
|
||||
center = [firstPin.lat as number, firstPin.lon as number];
|
||||
}
|
||||
if (!center) return; // guarded by totalPins>0, but satisfies TS
|
||||
map = L.map(mapRef.current).setView(center, 14);
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap",
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
setTimeout(() => map && map.invalidateSize(), 120);
|
||||
|
||||
const bounds: [number, number][] = [];
|
||||
|
||||
// Аналоги (asking) — синие.
|
||||
for (const l of analogs) {
|
||||
const ll: [number, number] = [l.lat as number, l.lon as number];
|
||||
bounds.push(ll);
|
||||
L.circleMarker(ll, {
|
||||
radius: 6,
|
||||
color: COLOR_ANALOG,
|
||||
weight: 2,
|
||||
fillColor: COLOR_ANALOG,
|
||||
fillOpacity: 0.55,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(l, "Объявление (asking)"));
|
||||
}
|
||||
|
||||
// ДКП-сделки — зелёные.
|
||||
for (const l of deals) {
|
||||
const ll: [number, number] = [l.lat as number, l.lon as number];
|
||||
bounds.push(ll);
|
||||
L.circleMarker(ll, {
|
||||
radius: 6,
|
||||
color: COLOR_DEAL,
|
||||
weight: 2,
|
||||
fillColor: COLOR_DEAL,
|
||||
fillOpacity: 0.55,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(l, "Сделка ДКП"));
|
||||
}
|
||||
|
||||
// Целевая квартира — крупный оранжевый маркер + радиус-круг.
|
||||
if (hasTarget) {
|
||||
const ll: [number, number] = [
|
||||
estimate.target_lat as number,
|
||||
estimate.target_lon as number,
|
||||
];
|
||||
bounds.push(ll);
|
||||
L.circle(ll, {
|
||||
radius: 2000,
|
||||
color: COLOR_TARGET,
|
||||
weight: 1,
|
||||
fillColor: COLOR_TARGET,
|
||||
fillOpacity: 0.05,
|
||||
}).addTo(map);
|
||||
L.circleMarker(ll, {
|
||||
radius: 9,
|
||||
color: COLOR_TARGET,
|
||||
weight: 3,
|
||||
fillColor: "#fff",
|
||||
fillOpacity: 1,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindPopup(
|
||||
`<div style="font-size:12px"><b>Ваша квартира</b><br/>${esc(
|
||||
estimate.target_address ?? "—",
|
||||
)}</div>`,
|
||||
)
|
||||
.openPopup();
|
||||
}
|
||||
|
||||
if (bounds.length > 1) {
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 16 });
|
||||
}
|
||||
})
|
||||
.catch(() => setMapError(true));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (map) map.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- лоты стабильны для одной оценки; пересоздаём map только при смене estimate
|
||||
}, [estimate.estimate_id, totalPins]);
|
||||
|
||||
// Empty state: нет ни одной гео-точки — карточку не показываем вовсе.
|
||||
if (totalPins === 0) return null;
|
||||
|
||||
return (
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<div className="section-kicker">Аналитика · Карта</div>
|
||||
<h2>Аналоги и сделки на карте</h2>
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<div>
|
||||
<span style={{ color: COLOR_ANALOG }}>●</span> объявления · {analogs.length}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span style={{ color: COLOR_DEAL }}>●</span> сделки ДКП · {deals.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapError ? (
|
||||
<div className="card-body">
|
||||
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
|
||||
Не удалось загрузить карту. Проверьте интернет-соединение.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{ height: 420, width: "100%", background: "var(--surface-2)" }}
|
||||
aria-label="Карта аналогов и сделок"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
<span style={{ color: COLOR_TARGET }}>●</span> ваша квартира ·{" "}
|
||||
<span style={{ color: COLOR_ANALOG }}>●</span> asking ·{" "}
|
||||
<span style={{ color: COLOR_DEAL }}>●</span> ДКП · клик по пину — детали
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
107
tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx
Normal file
107
tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PriceTrendCard — линия динамики ₽/м² по месяцам для здания/района.
|
||||
* Reuses recharts LineChart in the same style as PriceHistoryChart.tsx.
|
||||
* Рендерится только при ≥2 точках price_trend.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
}
|
||||
|
||||
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
|
||||
|
||||
/** "2025-03" → "мар ’25" (компактная подпись оси). */
|
||||
const MONTHS_RU = [
|
||||
"янв", "фев", "мар", "апр", "май", "июн",
|
||||
"июл", "авг", "сен", "окт", "ноя", "дек",
|
||||
];
|
||||
function fmtMonth(ym: string): string {
|
||||
const [y, m] = ym.split("-");
|
||||
const idx = Number(m) - 1;
|
||||
const name = idx >= 0 && idx < 12 ? MONTHS_RU[idx] : m;
|
||||
return `${name} ’${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
export function PriceTrendCard({ estimate }: Props) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
(estimate.price_trend ?? [])
|
||||
.filter((p) => p && typeof p.ppm2 === "number" && p.ppm2 > 0 && p.month)
|
||||
.map((p) => ({ month: p.month, ppm2: p.ppm2, label: fmtMonth(p.month) }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month)),
|
||||
[estimate.price_trend],
|
||||
);
|
||||
|
||||
// Empty / degraded state: <2 точек — карточку не рендерим.
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const first = data[0].ppm2;
|
||||
const last = data[data.length - 1].ppm2;
|
||||
const changePct = first > 0 ? ((last - first) / first) * 100 : 0;
|
||||
const up = changePct >= 0;
|
||||
|
||||
return (
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<div className="section-kicker">Аналитика · Тренд</div>
|
||||
<h2>Динамика ₽/м²</h2>
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<div>
|
||||
За <b className="mono">{data.length}</b> мес.
|
||||
</div>
|
||||
<div style={{ marginTop: 4, color: up ? "var(--viz-3)" : "var(--accent-2)" }}>
|
||||
{up ? "▲" : "▼"} {Math.abs(changePct).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: "100%", height: 240, padding: "12px 8px 0" }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} domain={["auto", "auto"]} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
`${value.toLocaleString("ru-RU")} ₽/м²`,
|
||||
"Медиана",
|
||||
]}
|
||||
labelFormatter={(label: string) => label}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ppm2"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
name="₽/м²"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Медиана ₽/м² по месяцам · {fmtMonth(data[0].month)} →{" "}
|
||||
{fmtMonth(data[data.length - 1].month)}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,18 @@ export interface AnalogLot {
|
|||
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
||||
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
||||
tier?: string | null;
|
||||
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
|
||||
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
|
||||
// пины с null lat/lon.
|
||||
lat?: number | null;
|
||||
lon?: number | null;
|
||||
}
|
||||
|
||||
// ── ANALYTICS surface — точка тренда ₽/м² по месяцам (PriceTrendCard) ──
|
||||
// month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району.
|
||||
export interface PriceTrendPoint {
|
||||
month: string; // "YYYY-MM"
|
||||
ppm2: number; // ₽/м²
|
||||
}
|
||||
|
||||
export interface CianValuationSummary {
|
||||
|
|
@ -144,6 +156,13 @@ export interface AggregatedEstimate {
|
|||
// null при отсутствии данных (graceful, common case без регрессий).
|
||||
avito_imv?: AvitoImvSummary | null;
|
||||
dkp_corridor?: DkpCorridor | null;
|
||||
// ── ANALYTICS surface (web-native cards) ──
|
||||
// price_trend — динамика медианы ₽/м² по месяцам для здания/района
|
||||
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
|
||||
// last_scraped_at — ISO datetime последнего скрейпа данных аналитики.
|
||||
// Оба optional + nullable: старые оценки их не содержат (graceful).
|
||||
price_trend?: PriceTrendPoint[] | null;
|
||||
last_scraped_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Stage 4a/4b response types ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue