gendesign/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx
bot-backend 338ccbb78f
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m55s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
fix(tradein/v2): честность+консистентность — реконсиляция счётчика аналогов, убрать фейк-donut/линейку, outlier-guard, created_at, дисклеймеры (#2481)
2026-07-12 15:09:10 +00:00

519 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* SourcesMap — overlay "05 АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ" (SourcesView): real OSM
* basemap plotting every analog listing (estimate.analogs) + Rosreestr ДКП
* deal (estimate.actual_deals) that carries lat/lon, coloured per listing
* source with a legend, target-property pin + analog-radius circle.
*
* PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1
* app/page.tsx tree, dead in prod — 307-redirected to /v2) — copied rather
* than imported so v2 stays a self-contained port with no runtime dependency
* on the legacy tree, and no npm Leaflet dep (same as MapCard).
*
* Deal honesty: deals are geocoded to STREET CENTROIDS (backend
* scripts/geocode_deals_from_houses.py) — several deals on the same street
* can collapse onto ONE point. Coincident points are grouped and fanned out
* with a small deterministic jitter + a "×N" badge instead of silently
* overlapping, and a visible caption states the approximation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. MapCard.tsx) */
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import {
adMeta,
dealMeta,
deglueAddr,
fmtDate,
fmtDist,
fmtMonthYear,
numRu,
pluralRu,
sourceLabel,
tierLabel,
} from "./mappers";
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=";
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard.tsx) */
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);
});
}
// ── Per-source categorical palette ──────────────────────────────────────────
// dataviz-skill categorical order (fixed, never cycled), validated against the
// v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow
// slots (cian/yandex) dip below 3:1 text-contrast, so they are NEVER
// colour-alone — every legend entry and popup keeps its RU label alongside
// the dot. Leaflet needs raw hex strings, not CSS vars (see MapCard.tsx
// comment) — kept literal, same as the rest of this map's Leaflet colours.
const SOURCE_COLORS: Record<string, string> = {
avito: "#2a78d6",
cian: "#1baf7a",
yandex: "#eda100",
domklik: "#008300",
n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET
// Сделки не соревнуются за 6-й категориальный оттенок — они отличаются ФОРМОЙ
// (ромб) и нейтральным тёмным цветом, а не ещё одним hue из палитры источников.
const COLOR_DEAL = tokens.ink;
const DOT_STROKE = "#fff";
function colorForSource(source: string | null | undefined): string {
if (!source) return FALLBACK_SOURCE_COLOR;
return SOURCE_COLORS[source.toLowerCase()] ?? FALLBACK_SOURCE_COLOR;
}
/** Безопасное экранирование для вставки в HTML popup (Leaflet вставляет как raw HTML). */
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
);
}
interface GeoLot {
lot: AnalogLot;
lat: number;
lon: number;
}
function withCoords(lots: AnalogLot[]): GeoLot[] {
return lots
.filter(
(l): l is AnalogLot & { lat: number; lon: number } =>
typeof l.lat === "number" && typeof l.lon === "number",
)
.map((l) => ({ lot: l, lat: l.lat, lon: l.lon }));
}
/** Группирует точки с ИДЕНТИЧНЫМИ координатами (street-centroid stacking). */
function groupByCoord(points: GeoLot[]): GeoLot[][] {
const groups = new Map<string, GeoLot[]>();
for (const p of points) {
const key = `${p.lat.toFixed(6)},${p.lon.toFixed(6)}`;
const arr = groups.get(key);
if (arr) arr.push(p);
else groups.set(key, [p]);
}
return Array.from(groups.values());
}
// Небольшой детерминированный jitter — чисто визуальный (карта и так подписана
// как street-centroid approximation, честность не меняется). ~0.00018° на
// широте Екатеринбурга (~56.8°) ≈ 12-15 м; поправка на cos(lat) держит разлёт
// круглым, а не сплюснутым по долготе.
const JITTER_DEG = 0.00018;
function jitter(
lat: number,
lon: number,
index: number,
count: number,
): [number, number] {
if (count <= 1) return [lat, lon];
const angle = (index / count) * 2 * Math.PI;
const r = JITTER_DEG * (1 + Math.min(count, 8) * 0.15);
const latRad = (lat * Math.PI) / 180;
return [
lat + r * Math.sin(angle),
lon + (r * Math.cos(angle)) / Math.max(0.2, Math.cos(latRad)),
];
}
function analogPopupHtml(l: AnalogLot): string {
const href = safeUrl(l.source_url);
const exposure =
l.days_on_market != null ? ` · ${l.days_on_market} дн. на рынке` : "";
const dist = l.distance_m != null ? ` · ${fmtDist(l.distance_m)} от квартиры` : "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} ₽/м² · <b>${numRu(l.price_rub)} ₽</b></div>
<div style="color:${tokens.muted};margin-top:4px">${esc(fmtDate(l.listing_date) + exposure + dist)}</div>
${
href
? `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px;font-size:10.5px;color:${tokens.accent};border:1px solid ${tokens.line2};border-radius:4px;padding:3px 8px;text-decoration:none">Источник ↗</a>`
: ""
}
</div>`;
}
function dealPopupHtml(l: AnalogLot, stackCount: number): string {
const tier = l.tier ? tierLabel(l.tier) : "";
const stackNote =
stackCount > 1
? `<div style="margin-top:6px;color:${tokens.warn};font-size:10.5px">Ещё ${stackCount - 1} ${esc(
pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]),
)} на этой точке — геопривязка по улице/кварталу, не по дому</div>`
: "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">Сделка · ${esc(sourceLabel(l.source))}${
tier ? ` · ${esc(tier)}` : ""
}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} ₽/м² · <b>${numRu(l.price_rub)} ₽</b></div>
<div style="color:${tokens.muted}">${esc(dealMeta(l))}</div>
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
fmtMonthYear(l.listing_date),
)} (месяц, Росреестр)</div>
${stackNote}
</div>`;
}
function targetPopupHtml(estimate: AggregatedEstimate): string {
return `<div style="font-family:${tokens.font.sans};font-size:12px">
<div style="font-weight:600;color:${tokens.ink2}">Ваша квартира</div>
<div style="color:${tokens.muted};margin-top:2px">${esc(estimate.target_address ?? "—")}</div>
</div>`;
}
/** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */
function dealDivIcon(L: any, count: number): any {
const size = Math.min(28, 14 + count * 2);
const badge =
count > 1
? `<div style="position:absolute;top:-7px;right:-7px;background:${tokens.ink2};color:#fff;font-family:${tokens.font.mono};font-size:9px;line-height:14px;min-width:14px;height:14px;border-radius:7px;text-align:center;padding:0 3px;box-shadow:0 0 0 1px #fff">${count}</div>`
: "";
const html = `<div style="position:relative;width:${size}px;height:${size}px">
<div style="position:absolute;inset:3px;background:${COLOR_DEAL};border:2px solid ${DOT_STROKE};border-radius:2px;transform:rotate(45deg);box-shadow:0 1px 2px rgba(0,0,0,.3)"></div>
${badge}
</div>`;
return L.divIcon({ html, className: "", iconSize: [size, size], iconAnchor: [size / 2, size / 2] });
}
// ── Card chrome — mirrors SourcesView's local style consts (v2 has no shared
// card component; each view file owns its own, see AnalyticsView/LeadForm). ──
const card: CSSProperties = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
overflow: "hidden",
};
const cardHeader: CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
};
const eyebrow: CSSProperties = {
fontSize: "8.5px",
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 5,
};
const cardTitle: CSSProperties = {
fontSize: "12px",
fontWeight: 600,
color: tokens.ink2,
};
const headerRight: CSSProperties = {
textAlign: "right",
fontSize: "9.5px",
color: tokens.muted2,
lineHeight: 1.6,
};
const legendRow: CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: 14,
alignItems: "center",
padding: "10px 18px",
borderTop: `1px solid ${tokens.lineSoft3}`,
fontSize: "10px",
color: tokens.body2,
};
const legendItem: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
};
const legendDot: CSSProperties = {
width: 9,
height: 9,
borderRadius: "50%",
display: "inline-block",
boxShadow: "0 0 0 1px #fff, 0 0 0 1.5px rgba(0,0,0,.15)",
};
const legendDiamond: CSSProperties = {
width: 8,
height: 8,
background: COLOR_DEAL,
border: "1.5px solid #fff",
boxShadow: `0 0 0 1px ${tokens.line2}`,
transform: "rotate(45deg)",
display: "inline-block",
};
const honestyFoot: CSSProperties = {
padding: "10px 18px",
fontSize: "9.5px",
color: tokens.hint,
background: "rgba(238,244,250,.5)", // тот же one-off tint, что и SourcesView KPI_BG
borderTop: `1px solid ${tokens.lineSoft3}`,
};
interface Props {
estimate: AggregatedEstimate | null | undefined;
}
export function SourcesMap({ estimate }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState(false);
const analogPoints = useMemo(
() => (estimate ? withCoords(estimate.analogs) : []),
[estimate],
);
const dealPoints = useMemo(
() => (estimate ? withCoords(estimate.actual_deals) : []),
[estimate],
);
const hasTarget =
!!estimate &&
typeof estimate.target_lat === "number" &&
typeof estimate.target_lon === "number";
// Радиус круга = тот же максимум distance_m по аналогам, что уже заявлен в
// фильтр-чипе таблицы объявлений выше в этом overlay ("Расстояние ≤ N км",
// mappers.ts buildAdFilters) — карта не должна утверждать другой радиус.
const radiusM = useMemo(() => {
const ds = analogPoints
.map((p) => p.lot.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
return ds.length ? Math.max(...ds) : null;
}, [analogPoints]);
const presentSources = useMemo(() => {
const set = new Set<string>();
for (const p of analogPoints) if (p.lot.source) set.add(p.lot.source.toLowerCase());
return Array.from(set);
}, [analogPoints]);
const totalPins = analogPoints.length + dealPoints.length + (hasTarget ? 1 : 0);
useEffect(() => {
if (!estimate || totalPins === 0) return;
let map: any = null;
let cancelled = false;
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
const firstPin = analogPoints[0] ?? dealPoints[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, firstPin.lon];
}
if (!center) return; // guarded by totalPins>0, но удовлетворяет TS
// scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay
// (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
map = L.map(mapRef.current, { scrollWheelZoom: false }).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][] = [];
// Объявления — цвет по источнику, стек в одной точке — jitter по кругу.
for (const group of groupByCoord(analogPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.circleMarker([lat, lon], {
radius: 7,
color: DOT_STROKE,
weight: 2,
fillColor: colorForSource(p.lot.source),
fillOpacity: 0.9,
})
.addTo(map)
.bindPopup(analogPopupHtml(p.lot));
});
}
// ДКП-сделки — ромб, нейтральный цвет, size+badge по числу в стеке.
for (const group of groupByCoord(dealPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.marker([lat, lon], { icon: dealDivIcon(L, group.length) })
.addTo(map)
.bindPopup(dealPopupHtml(p.lot, group.length));
});
}
// Целевая квартира — крупный янтарный маркер + радиус-круг (та же
// семантика/паттерн, что и MapCard.tsx).
if (hasTarget) {
const ll: [number, number] = [
estimate.target_lat as number,
estimate.target_lon as number,
];
bounds.push(ll);
if (radiusM != null && radiusM > 0) {
L.circle(ll, {
radius: radiusM,
color: COLOR_TARGET,
weight: 1,
fillColor: COLOR_TARGET,
fillOpacity: 0.05,
// interactive:false — декоративный круг не должен перехватывать
// клики по пинам под ним (см. MapCard.tsx / issue #786).
interactive: false,
}).addTo(map);
}
L.circleMarker(ll, {
radius: 9,
color: COLOR_TARGET,
weight: 3,
fillColor: "#fff",
fillOpacity: 1,
})
.addTo(map)
.bindPopup(targetPopupHtml(estimate));
}
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 -- пересоздаём карту только при смене оценки/состава пинов, не на каждый ре-рендер
}, [estimate?.estimate_id, totalPins]);
// Честный empty state: нет оценки или нет геоточек вообще — карточку не
// показываем (то же поведение, что и MapCard.tsx), таблицы ниже остаются.
if (!estimate || totalPins === 0) return null;
return (
<article style={card}>
<div style={cardHeader}>
<div>
<div style={eyebrow}>РЫНОК · КАРТА</div>
<div style={cardTitle}>Аналоги и сделки на карте</div>
</div>
<div style={headerRight}>
{/* Fix #1 — analogPoints is the top-10 display sample (only what the
backend returns coords for); estimate.n_analogs is the true total
used in the calc. "N из M" mirrors the deals-table "Показано N из
M" pattern so this never contradicts the market KPI band above. */}
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
{" из "}
{estimate.n_analogs}
{dealPoints.length > 0 && (
<>
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
</>
)}
</div>
</div>
{mapError ? (
<div
style={{
padding: "24px 18px",
textAlign: "center",
color: tokens.muted,
fontSize: 12,
}}
>
Не удалось загрузить карту. Проверьте интернет-соединение.
</div>
) : (
<div
ref={mapRef}
style={{ height: 400, width: "100%", background: tokens.mapBg }}
aria-label="Карта аналогов и сделок"
/>
)}
<div style={legendRow}>
{presentSources.map((s) => (
<span key={s} style={legendItem}>
<span style={{ ...legendDot, background: colorForSource(s) }} />
{sourceLabel(s)}
</span>
))}
{dealPoints.length > 0 && (
<span style={legendItem}>
<span style={legendDiamond} />
Сделки (Росреестр)
</span>
)}
{hasTarget && (
<span style={legendItem}>
<span style={{ ...legendDot, background: "#fff", boxShadow: `0 0 0 2px ${COLOR_TARGET}` }} />
Ваша квартира
</span>
)}
</div>
<div style={honestyFoot}>
Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по
конкретному дому совпадающие точки визуально разнесены и подписаны
«×N». Клик по пину детали.
</div>
</article>
);
}
export default SourcesMap;