"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 { 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); }); } // ── 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 = { 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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'", ); } 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(); 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 `
${esc(deglueAddr(l.address || "—"))}
${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}
${numRu(l.price_per_m2)} ₽/м² · ${numRu(l.price_rub)} ₽
${esc(fmtDate(l.listing_date) + exposure + dist)}
${ href ? `Источник ↗` : "" }
`; } function dealPopupHtml(l: AnalogLot, stackCount: number): string { const tier = l.tier ? tierLabel(l.tier) : ""; const stackNote = stackCount > 1 ? `
Ещё ${stackCount - 1} ${esc( pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]), )} на этой точке — геопривязка по улице/кварталу, не по дому
` : ""; return `
${esc(deglueAddr(l.address || "—"))}
Сделка · ${esc(sourceLabel(l.source))}${ tier ? ` · ${esc(tier)}` : "" }
${numRu(l.price_per_m2)} ₽/м² · ${numRu(l.price_rub)} ₽
${esc(dealMeta(l))}
Период сделки: ${esc( fmtMonthYear(l.listing_date), )} (месяц, Росреестр)
${stackNote}
`; } function targetPopupHtml(estimate: AggregatedEstimate): string { return `
Ваша квартира
${esc(estimate.target_address ?? "—")}
`; } /** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */ function dealDivIcon(L: any, count: number): any { const size = Math.min(28, 14 + count * 2); const badge = count > 1 ? `
${count}
` : ""; const html = `
${badge}
`; 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(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(); 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 (
РЫНОК · КАРТА
Аналоги и сделки на карте
{/* 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. */} Объявлений: {analogPoints.length} {" из "} {estimate.n_analogs} {dealPoints.length > 0 && ( <> {" · "}сделок: {dealPoints.length} )}
{mapError ? (
Не удалось загрузить карту. Проверьте интернет-соединение.
) : (
)}
{presentSources.map((s) => ( {sourceLabel(s)} ))} {dealPoints.length > 0 && ( Сделки (Росреестр) )} {hasTarget && ( Ваша квартира )}
Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по конкретному дому — совпадающие точки визуально разнесены и подписаны «×N». Клик по пину — детали.
); } export default SourcesMap;