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
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
"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}>
|
||
{/* 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;
|