feat(tradein/v2): карта вторички — сделки Росреестра + объявления по источникам (SourcesView) #2478
5 changed files with 540 additions and 9 deletions
|
|
@ -974,6 +974,7 @@ export default function TradeInV2Page() {
|
|||
analytics={analyticsViewData}
|
||||
sources={sourcesData}
|
||||
cache={cacheData}
|
||||
estimate={estimate}
|
||||
onSelectEstimate={(id) => {
|
||||
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
|
||||
// above (line ~649); this file uses replace() consistently for
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import AnalyticsView from "./AnalyticsView";
|
|||
import { CacheView } from "./CacheView";
|
||||
import type { CacheData, HistoryData, SourcesData } from "./mappers";
|
||||
import type { Analytics } from "./types";
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
|
||||
interface SectionOverlayProps {
|
||||
active: number;
|
||||
|
|
@ -28,6 +29,11 @@ interface SectionOverlayProps {
|
|||
sources?: SourcesData;
|
||||
analytics?: Analytics;
|
||||
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
|
||||
// that historical estimate. Optional (threaded straight to CacheView's own
|
||||
// optional onSelectRow — unwired usage leaves rows inert).
|
||||
|
|
@ -47,6 +53,7 @@ export default function SectionOverlay({
|
|||
sources,
|
||||
analytics,
|
||||
cache,
|
||||
estimate = null,
|
||||
onSelectEstimate,
|
||||
}: SectionOverlayProps) {
|
||||
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
|
||||
|
|
@ -317,7 +324,7 @@ export default function SectionOverlay({
|
|||
}}
|
||||
>
|
||||
{active === 1 && <HistoryView data={history} />}
|
||||
{active === 2 && <SourcesView data={sources} />}
|
||||
{active === 2 && <SourcesView data={sources} estimate={estimate} />}
|
||||
{active === 3 && (
|
||||
<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 { safeUrl } from "@/lib/safeUrl";
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
import { tokens } from "./tokens";
|
||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
||||
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
||||
import { SourcesMap } from "./SourcesMap";
|
||||
|
||||
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
||||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||
|
|
@ -447,10 +449,16 @@ const SOURCES_FIXTURE: SourcesData = {
|
|||
|
||||
interface SourcesViewProps {
|
||||
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({
|
||||
data = SOURCES_FIXTURE,
|
||||
estimate = null,
|
||||
}: SourcesViewProps) {
|
||||
const adCols = visibleCols(AD_COLS, data.adRows);
|
||||
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}; }
|
||||
`}</style>
|
||||
|
||||
<SourcesMap estimate={estimate} />
|
||||
|
||||
{/* объявления */}
|
||||
<div style={card}>
|
||||
<div style={cardHeader}>
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ const RU_MONTHS = [
|
|||
"дек",
|
||||
];
|
||||
/** 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 "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
|
|
@ -1180,7 +1180,7 @@ export function mapSummary(
|
|||
// ── Overlay format helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** 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 "—";
|
||||
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 -> "—". */
|
||||
function fmtDist(m: number | null | undefined): string {
|
||||
export function fmtDist(m: number | null | undefined): string {
|
||||
if (m == null || !Number.isFinite(m)) return "—";
|
||||
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
|
||||
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
|
||||
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
|
||||
// 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 "—";
|
||||
return registrySourceLabel(s.toLowerCase());
|
||||
}
|
||||
|
||||
/** 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.includes("house")) return "по дому";
|
||||
if (t.includes("street")) return "по улице";
|
||||
|
|
@ -1292,7 +1292,7 @@ function composeLot(
|
|||
}
|
||||
|
||||
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
|
||||
function adMeta(l: AnalogLot): string {
|
||||
export function adMeta(l: AnalogLot): string {
|
||||
const parts: string[] = [];
|
||||
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||
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). */
|
||||
function dealMeta(l: AnalogLot): string {
|
||||
export function dealMeta(l: AnalogLot): string {
|
||||
const parts: string[] = [];
|
||||
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
|
||||
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
|
||||
* touches the comma-splitting parseAddress.
|
||||
*/
|
||||
function deglueAddr(addr: string): string {
|
||||
export function deglueAddr(addr: string): string {
|
||||
return (
|
||||
addr
|
||||
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue