From 7407848068d36f0b421856bcfa83fa00d6dccaf3 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 30 May 2026 10:45:25 +0300 Subject: [PATCH] feat(tradein): web-native analytics cards on estimate result (map, distribution, trend, exposure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 graceful analytics cards (web advantage over a static PDF): - MapCard (Leaflet CDN, MapPicker pattern): analogs (blue) vs ДКП (green) pins + target marker & 2km radius; popup address·₽/м²·цена·срок. - DistributionCard (recharts): ₽/м² histogram with «ваша квартира» marker at median_price_per_m2. - PriceTrendCard (recharts): ₽/м² line by month from price_trend. - ExposureCard (recharts): price↔срок scatter (ликвидность) with «ваша цена». Mirrors new /estimate fields in TS types (lat/lon, price_trend, last_scraped_at). Reuses existing libs only; brand-themed, responsive, each render-guarded on data. tsc --noEmit clean. HeroSummary untouched. Refs #647 #667 --- tradein-mvp/frontend/src/app/page.tsx | 9 + .../components/trade-in/DistributionCard.tsx | 155 +++++++++++ .../src/components/trade-in/ExposureCard.tsx | 133 ++++++++++ .../src/components/trade-in/MapCard.tsx | 250 ++++++++++++++++++ .../components/trade-in/PriceTrendCard.tsx | 107 ++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 19 ++ 6 files changed, 673 insertions(+) create mode 100644 tradein-mvp/frontend/src/components/trade-in/DistributionCard.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/MapCard.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 13f615b4..2d8e49a8 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -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() { + {/* ANALYTICS cards — каждая с graceful guard «render only if data present» */} + + + + ) : ( diff --git a/tradein-mvp/frontend/src/components/trade-in/DistributionCard.tsx b/tradein-mvp/frontend/src/components/trade-in/DistributionCard.tsx new file mode 100644 index 00000000..2be7d03e --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/DistributionCard.tsx @@ -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(() => { + 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 ( +
+
+
+
Аналитика · Распределение
+

Где ваша квартира в локальном рынке

+
+
+
+ По {n} аналогам +
+
₽/м² · гистограмма
+
+
+ +
+ + + + + + [`${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")} ₽/м²`; + }} + /> + + {bins.map((b, i) => ( + + ))} + + + + +
+ +
+ + Ваша оценка:{" "} + {subject.toLocaleString("ru-RU")} ₽/м² · + оранжевый столбец — ваш диапазон + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx new file mode 100644 index 00000000..a5f13941 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/ExposureCard.tsx @@ -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( + () => + 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 ( +
+
+
+
Аналитика · Ликвидность
+

Цена и срок экспозиции

+
+
+
+ По {points.length} аналогам +
+
дешевле → быстрее
+
+
+ +
+ + + + + + + + name === "Цена" + ? [`${value.toLocaleString("ru-RU")} ₽`, name] + : [`${value} дн.`, name] + } + /> + + + + +
+ +
+ + Каждая точка — аналог в продаже · вертикаль — ваша оценка{" "} + {fmtM(subjectPrice)} млн ₽ + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx new file mode 100644 index 00000000..5e4d95a3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx @@ -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 { + 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(`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 `
+ ${esc(lot.address)}
+ ${kind}
+ ${fmtRub(lot.price_per_m2)} ₽/м² · ${fmtRub(lot.price_rub)} ₽
+ ${esc(expo)} +
`; +} + +export function MapCard({ estimate }: Props) { + const mapRef = useRef(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( + `
Ваша квартира
${esc( + estimate.target_address ?? "—", + )}
`, + ) + .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 ( +
+
+
+
Аналитика · Карта
+

Аналоги и сделки на карте

+
+
+
+ объявления · {analogs.length} +
+
+ сделки ДКП · {deals.length} +
+
+
+ + {mapError ? ( +
+

+ Не удалось загрузить карту. Проверьте интернет-соединение. +

+
+ ) : ( +
+ )} + +
+ + ваша квартира ·{" "} + asking ·{" "} + ДКП · клик по пину — детали + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx b/tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx new file mode 100644 index 00000000..ff313db3 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/PriceTrendCard.tsx @@ -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 ( +
+
+
+
Аналитика · Тренд
+

Динамика ₽/м²

+
+
+
+ За {data.length} мес. +
+
+ {up ? "▲" : "▼"} {Math.abs(changePct).toFixed(1)}% +
+
+
+ +
+ + + + + + [ + `${value.toLocaleString("ru-RU")} ₽/м²`, + "Медиана", + ]} + labelFormatter={(label: string) => label} + /> + + + +
+ +
+ + Медиана ₽/м² по месяцам · {fmtMonth(data[0].month)} →{" "} + {fmtMonth(data[data.length - 1].month)} + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index c72f6efd..80a394ea 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -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 ── -- 2.45.3