feat(tradein/v2): карта вторички — сделки Росреестра + объявления по источникам (SourcesView) #2478
5 changed files with 540 additions and 9 deletions
|
|
@ -974,6 +974,7 @@ export default function TradeInV2Page() {
|
||||||
analytics={analyticsViewData}
|
analytics={analyticsViewData}
|
||||||
sources={sourcesData}
|
sources={sourcesData}
|
||||||
cache={cacheData}
|
cache={cacheData}
|
||||||
|
estimate={estimate}
|
||||||
onSelectEstimate={(id) => {
|
onSelectEstimate={(id) => {
|
||||||
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
|
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
|
||||||
// above (line ~649); this file uses replace() consistently for
|
// above (line ~649); this file uses replace() consistently for
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import AnalyticsView from "./AnalyticsView";
|
||||||
import { CacheView } from "./CacheView";
|
import { CacheView } from "./CacheView";
|
||||||
import type { CacheData, HistoryData, SourcesData } from "./mappers";
|
import type { CacheData, HistoryData, SourcesData } from "./mappers";
|
||||||
import type { Analytics } from "./types";
|
import type { Analytics } from "./types";
|
||||||
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
|
||||||
interface SectionOverlayProps {
|
interface SectionOverlayProps {
|
||||||
active: number;
|
active: number;
|
||||||
|
|
@ -28,6 +29,11 @@ interface SectionOverlayProps {
|
||||||
sources?: SourcesData;
|
sources?: SourcesData;
|
||||||
analytics?: Analytics;
|
analytics?: Analytics;
|
||||||
cache?: CacheData;
|
cache?: CacheData;
|
||||||
|
// Raw current estimate — threaded straight to SourcesView's SourcesMap
|
||||||
|
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
|
||||||
|
// strings with no geometry). Optional/null: no estimate yet -> map degrades
|
||||||
|
// to its own honest-empty (renders nothing, tables still show).
|
||||||
|
estimate?: AggregatedEstimate | null;
|
||||||
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
|
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
|
||||||
// that historical estimate. Optional (threaded straight to CacheView's own
|
// that historical estimate. Optional (threaded straight to CacheView's own
|
||||||
// optional onSelectRow — unwired usage leaves rows inert).
|
// optional onSelectRow — unwired usage leaves rows inert).
|
||||||
|
|
@ -47,6 +53,7 @@ export default function SectionOverlay({
|
||||||
sources,
|
sources,
|
||||||
analytics,
|
analytics,
|
||||||
cache,
|
cache,
|
||||||
|
estimate = null,
|
||||||
onSelectEstimate,
|
onSelectEstimate,
|
||||||
}: SectionOverlayProps) {
|
}: SectionOverlayProps) {
|
||||||
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
|
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
|
||||||
|
|
@ -317,7 +324,7 @@ export default function SectionOverlay({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{active === 1 && <HistoryView data={history} />}
|
{active === 1 && <HistoryView data={history} />}
|
||||||
{active === 2 && <SourcesView data={sources} />}
|
{active === 2 && <SourcesView data={sources} estimate={estimate} />}
|
||||||
{active === 3 && (
|
{active === 3 && (
|
||||||
<AnalyticsView data={analytics} onNavigate={onNavigate} />
|
<AnalyticsView data={analytics} onNavigate={onNavigate} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
513
tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx
Normal file
513
tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
"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 === "&" ? "&" : 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<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}>
|
||||||
|
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
|
||||||
|
{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;
|
||||||
|
|
@ -2,9 +2,11 @@ import { Fragment } from "react";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
||||||
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
||||||
|
import { SourcesMap } from "./SourcesMap";
|
||||||
|
|
||||||
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
||||||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||||
|
|
@ -447,10 +449,16 @@ const SOURCES_FIXTURE: SourcesData = {
|
||||||
|
|
||||||
interface SourcesViewProps {
|
interface SourcesViewProps {
|
||||||
data?: SourcesData;
|
data?: SourcesData;
|
||||||
|
// Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
|
||||||
|
// display strings with no geometry, so SourcesMap needs the source object
|
||||||
|
// directly. Optional: unwired/storybook usage (no estimate) renders the
|
||||||
|
// tables only, same honest-empty contract as the rest of the mappers.
|
||||||
|
estimate?: AggregatedEstimate | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourcesView({
|
export default function SourcesView({
|
||||||
data = SOURCES_FIXTURE,
|
data = SOURCES_FIXTURE,
|
||||||
|
estimate = null,
|
||||||
}: SourcesViewProps) {
|
}: SourcesViewProps) {
|
||||||
const adCols = visibleCols(AD_COLS, data.adRows);
|
const adCols = visibleCols(AD_COLS, data.adRows);
|
||||||
const adGrid = adCols.map((c) => c.fr).join(" ");
|
const adGrid = adCols.map((c) => c.fr).join(" ");
|
||||||
|
|
@ -466,6 +474,8 @@ export default function SourcesView({
|
||||||
.sv-rr:hover { background: ${BADGE_BG}; border-color: ${tokens.accent}; }
|
.sv-rr:hover { background: ${BADGE_BG}; border-color: ${tokens.accent}; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
<SourcesMap estimate={estimate} />
|
||||||
|
|
||||||
{/* объявления */}
|
{/* объявления */}
|
||||||
<div style={card}>
|
<div style={card}>
|
||||||
<div style={cardHeader}>
|
<div style={cardHeader}>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ const RU_MONTHS = [
|
||||||
"дек",
|
"дек",
|
||||||
];
|
];
|
||||||
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
|
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
|
||||||
function fmtMonthYear(iso: string | null | undefined): string {
|
export function fmtMonthYear(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
|
@ -1180,7 +1180,7 @@ export function mapSummary(
|
||||||
// ── Overlay format helpers ──────────────────────────────────────────────────
|
// ── Overlay format helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
|
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
|
||||||
function numRu(v: number | null | undefined): string {
|
export function numRu(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return "—";
|
if (v == null || !Number.isFinite(v)) return "—";
|
||||||
return Math.round(v).toLocaleString("ru-RU");
|
return Math.round(v).toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
@ -1245,7 +1245,7 @@ function bargainSigned(pct: number | null | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
||||||
function fmtDist(m: number | null | undefined): string {
|
export function fmtDist(m: number | null | undefined): string {
|
||||||
if (m == null || !Number.isFinite(m)) return "—";
|
if (m == null || !Number.isFinite(m)) return "—";
|
||||||
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
|
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
|
||||||
return `${(m / 1000).toLocaleString("ru-RU", {
|
return `${(m / 1000).toLocaleString("ru-RU", {
|
||||||
|
|
@ -1258,13 +1258,13 @@ function fmtDist(m: number | null | undefined): string {
|
||||||
// single source-registry roster (#2211) instead of a local hardcoded map — an
|
// single source-registry roster (#2211) instead of a local hardcoded map — an
|
||||||
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
|
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
|
||||||
// via the registry's own fallback rather than inventing a label.
|
// via the registry's own fallback rather than inventing a label.
|
||||||
function sourceLabel(s: string | null | undefined): string {
|
export function sourceLabel(s: string | null | undefined): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
return registrySourceLabel(s.toLowerCase());
|
return registrySourceLabel(s.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rosreestr deal tier -> RU caption next to the badge. */
|
/** Rosreestr deal tier -> RU caption next to the badge. */
|
||||||
function tierLabel(t: string | null | undefined): string {
|
export function tierLabel(t: string | null | undefined): string {
|
||||||
if (!t) return "";
|
if (!t) return "";
|
||||||
if (t.includes("house")) return "по дому";
|
if (t.includes("house")) return "по дому";
|
||||||
if (t.includes("street")) return "по улице";
|
if (t.includes("street")) return "по улице";
|
||||||
|
|
@ -1292,7 +1292,7 @@ function composeLot(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
|
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
|
||||||
function adMeta(l: AnalogLot): string {
|
export function adMeta(l: AnalogLot): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
|
|
@ -1307,7 +1307,7 @@ function adMeta(l: AnalogLot): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
|
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
|
||||||
function dealMeta(l: AnalogLot): string {
|
export function dealMeta(l: AnalogLot): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||||
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
|
||||||
|
|
@ -1326,7 +1326,7 @@ function dealMeta(l: AnalogLot): string {
|
||||||
* capital is preceded by ".", not a glue char). Pure display cleanup; never
|
* capital is preceded by ".", not a glue char). Pure display cleanup; never
|
||||||
* touches the comma-splitting parseAddress.
|
* touches the comma-splitting parseAddress.
|
||||||
*/
|
*/
|
||||||
function deglueAddr(addr: string): string {
|
export function deglueAddr(addr: string): string {
|
||||||
return (
|
return (
|
||||||
addr
|
addr
|
||||||
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
|
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue