feat(tradein): web-native analytics cards on estimate result (map, distribution, trend, exposure) (#688)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 35s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-30 07:50:40 +00:00 committed by bot-reviewer
parent 22287b8b64
commit 0e6eea9bef
6 changed files with 673 additions and 0 deletions

View file

@ -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} />
</>
) : (

View file

@ -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>
);
}

View 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>
);
}

View 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
);
}
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>
);
}

View 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>
);
}

View file

@ -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 ──