"use client"; import { useEffect, useRef, useState } from "react"; import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup, useMapEvents, } from "react-leaflet"; import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; import type { ParcelAnalysis, ParcelAnalysisCompetitor, PipelineObject, } from "@/types/site-finder"; import type { ConnectionPointsResponse, EngineeringStructure, OpportunityParcel, RedLine, RiskZone, } from "@/types/nspd"; import type { CustomPoi } from "@/types/customPoi"; import { MarketLayers, MARKET_COLORS, } from "@/components/site-finder/MarketLayers"; import { ConnectionPointsLayer, CP_ALL_CATEGORIES, groupStructuresByCategory, type CpCategory, } from "@/components/site-finder/ConnectionPointsLayer"; import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel"; import { ZouitLayer, ZOUIT_ALL_SEVERITIES, groupZouitBySeverity, type ZouitSeverity, } from "@/components/site-finder/ZouitLayer"; import { ZouitLayerControlPanel } from "@/components/site-finder/ZouitLayerControlPanel"; import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer"; import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton"; import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal"; import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal"; import { useAddCustomPoi, useUpdateCustomPoi, useDeleteCustomPoi, } from "@/hooks/useCustomPois"; // --------------------------------------------------------------------------- // POI legend config (for the legend row below the map) // --------------------------------------------------------------------------- interface CategoryStyle { color: string; label: string; radius: number; } export const CATEGORY_STYLES: Record = { school: { color: "#1d4ed8", label: "Школа", radius: 8 }, kindergarten: { color: "#1d4ed8", label: "Детский сад", radius: 8 }, pharmacy: { color: "#10b981", label: "Аптека", radius: 6 }, hospital: { color: "#f59e0b", label: "Больница", radius: 8 }, shop_mall: { color: "#a855f7", label: "ТЦ", radius: 8 }, shop_supermarket: { color: "#a855f7", label: "Супермаркет", radius: 6 }, shop_small: { color: "#c084fc", label: "Магазин", radius: 6 }, park: { color: "#16a34a", label: "Парк", radius: 6 }, tram_stop: { color: "#dc2626", label: "Трамвай", radius: 6 }, bus_stop: { color: "#6b7280", label: "Автобус", radius: 6 }, metro_stop: { color: "#1e40af", label: "Метро", radius: 8 }, }; // --------------------------------------------------------------------------- // Geometry centroid (bounding-box center — sufficient for zoom-to fit) // --------------------------------------------------------------------------- function flattenCoords(geom: Geometry): Position[] { switch (geom.type) { case "Point": return [geom.coordinates]; case "MultiPoint": case "LineString": return geom.coordinates as Position[]; case "MultiLineString": case "Polygon": return (geom.coordinates as Position[][]).flat(); case "MultiPolygon": return (geom.coordinates as Position[][][]).flat(2); case "GeometryCollection": return geom.geometries.flatMap(flattenCoords); default: return []; } } function geomCenter(geom: Geometry): [number, number] { const coords = flattenCoords(geom); if (!coords.length) return [56.838, 60.6]; let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; for (const [lon, lat] of coords) { if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon; } return [(minLat + maxLat) / 2, (minLon + maxLon) / 2]; } // --------------------------------------------------------------------------- // Props + Component // --------------------------------------------------------------------------- interface Props { data: ParcelAnalysis; isochrones?: FeatureCollection; connectionPoints?: ConnectionPointsResponse; customPois?: CustomPoi[]; parcelCad?: string; // #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них). competitors?: ParcelAnalysisCompetitor[]; pipelineObjects?: PipelineObject[]; riskZones?: RiskZone[]; // §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки (geom_wkt). opportunityParcels?: OpportunityParcel[]; redLines?: RedLine[]; // #1218 — высота тайла карты (px). Default 420 — режим обычной странички // /site-finder/[cad]. MiniMap на /analysis передаёт меньше (≈280), чтобы // карта была компактнее. На высоту НЕ влияет на flow-контролы ниже // (POI add button, легенда, CpLayerControlPanel) — они идут потоком. mapHeight?: number; } // Ключи переключаемых рыночных слоёв (#999 + §12.1-13 #958). export type MarketLayerKey = | "competitors" | "pipeline" | "risk" | "opportunity" | "redlines"; // --------------------------------------------------------------------------- // Map click handler sub-component (must be inside MapContainer) // --------------------------------------------------------------------------- interface MapClickHandlerProps { editMode: boolean; onMapClick: (lat: number, lon: number) => void; } function MapClickHandler({ editMode, onMapClick }: MapClickHandlerProps) { useMapEvents({ click(e) { if (editMode) { onMapClick(e.latlng.lat, e.latlng.lng); } }, }); return null; } export function SiteMap({ data, isochrones, connectionPoints, customPois, parcelCad, competitors, pipelineObjects, riskZones, opportunityParcels, redLines, mapHeight = 420, }: Props) { // Fix Leaflet default icon paths broken by webpack bundler useEffect(() => { void import("leaflet").then((L) => { // @ts-expect-error — _getIconUrl is internal Leaflet property delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", }); }); }, []); // Connection-points layer toggle state (all categories visible by default) const [visibleCategories, setVisibleCategories] = useState>( new Set(CP_ALL_CATEGORIES), ); // #999 (958-B4) — рыночные слои. Карта — MiniMap (компактная), поэтому по // умолчанию включены только Конкуренты; Будущие проекты + Зоны риска OFF // (пользователь включает тумблером в CpLayerControlPanel). const [visibleMarketLayers, setVisibleMarketLayers] = useState< Set >(new Set(["competitors"])); function toggleMarketLayer(key: MarketLayerKey) { setVisibleMarketLayers((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); } // #255 — ЗОУИТ-слой. По умолчанию показываем blocker + warning (важные // охранные зоны), info (зоны сервитута) OFF, чтобы не зашумлять карту — // пользователь включает тумблером в ZouitLayerControlPanel. const [visibleZouit, setVisibleZouit] = useState>( () => new Set(["blocker", "warning"]), ); function toggleZouit(sev: ZouitSeverity) { setVisibleZouit((prev) => { const next = new Set(prev); if (next.has(sev)) { next.delete(sev); } else { next.add(sev); } return next; }); } // Custom POI add mode state const [addMode, setAddMode] = useState(false); const [pendingCoords, setPendingCoords] = useState<{ lat: number; lon: number; } | null>(null); const [editingPoi, setEditingPoi] = useState(null); // Mutations const addMutation = useAddCustomPoi(parcelCad); const updateMutation = useUpdateCustomPoi(parcelCad); const deleteMutation = useDeleteCustomPoi(parcelCad); // Keyboard ESC cancels add mode const addModeRef = useRef(addMode); addModeRef.current = addMode; useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape" && addModeRef.current) { setAddMode(false); setPendingCoords(null); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); function toggleCategory(cat: CpCategory) { setVisibleCategories((prev) => { const next = new Set(prev); if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); } function toggleAll() { setVisibleCategories((prev) => prev.size === CP_ALL_CATEGORIES.length ? new Set() : new Set(CP_ALL_CATEGORIES), ); } function handleMapClick(lat: number, lon: number) { setPendingCoords({ lat, lon }); setAddMode(false); // toggle off click mode; modal opens } const center: [number, number] = data.geom_geojson ? geomCenter(data.geom_geojson) : [56.838, 60.6]; // Build legend from categories present in score_breakdown const presentCategories = Object.keys(data.score_breakdown); // Pre-group structures for both the map layer and the control panel const cpGrouped = connectionPoints ? groupStructuresByCategory(connectionPoints.engineering_structures) : new Map(); // #999 — нормализуем рыночные данные (тонкий ответ → пустые массивы). const competitorList = competitors ?? data.competitors ?? []; const pipelineList = pipelineObjects ?? data.pipeline_24mo?.top_objects ?? []; const riskZoneList = riskZones ?? data.nspd_risk_zones ?? []; // §12.1-13 (#958) — opportunity-ЗУ + красные линии застройки. const opportunityList = opportunityParcels ?? data.nspd_opportunity_parcels ?? []; const redLineList = redLines ?? data.nspd_red_lines ?? []; // Слой рисуется только если у фичи есть геометрия (geom_wkt) — счётчик панели // должен это отражать (пустой WKT не даёт полигон/линию). const opportunityMappable = opportunityList.filter( (o) => typeof o.geom_wkt === "string" && o.geom_wkt.length > 0, ).length; const redLineMappable = redLineList.filter( (l) => typeof l.geom_wkt === "string" && l.geom_wkt.length > 0, ).length; const hasMarketData = competitorList.length > 0 || pipelineList.length > 0 || riskZoneList.length > 0 || opportunityMappable > 0 || redLineMappable > 0; // Кол-во объектов с координатами — для подписи в панели (без координат не // рисуются, поэтому счётчик панели должен это отражать). const competitorMappable = competitorList.filter( (c) => typeof c.lat === "number" && typeof c.lon === "number", ).length; const pipelineMappable = pipelineList.filter( (p) => typeof p.lat === "number" && typeof p.lon === "number", ).length; // #255 — ЗОУИТ-полигоны из analyze payload (geom_geojson). Группируем по // severity; в карту/панель идут только overlaps с валидной геометрией. const zouitOverlaps = data.nspd_zouit_overlaps ?? []; const zouitGrouped = groupZouitBySeverity(zouitOverlaps); const zouitMappableCount = ZOUIT_ALL_SEVERITIES.reduce( (sum, sev) => sum + (zouitGrouped.get(sev)?.length ?? 0), 0, ); const hasZouitGeom = zouitMappableCount > 0; return (
{/* Map */}
{/* Parcel / quarter polygon */} {data.geom_geojson && ( )} {/* #255 — ЗОУИТ-полигоны (охранные зоны). Рисуем рано (под точками POI и рыночными маркерами) как заливку-фон, поверх полигона участка. */} {hasZouitGeom && ( )} {/* Isochrones — render in reverse order (largest range at bottom). NB: react-leaflet GeoJSON кэширует слой, не реагирует на смену `data` prop. Включаем в key первую координату feature (она различается между mode=foot/cycle/car и временем) — это форсирует remount при пересчёте от ORS. */} {isochrones && [...isochrones.features].reverse().map((feature, idx) => { const seconds = typeof feature?.properties?.value === "number" ? (feature.properties.value as number) : 0; const minutes = seconds / 60; const opacity = Math.max(0.2, 0.5 - minutes * 0.02); const color = minutes <= 10 ? "#16a34a" : minutes <= 20 ? "#3b82f6" : "#a855f7"; // Hash из первой координаты — различает foot/cycle/car полигоны const firstCoord = ( feature.geometry as { coordinates?: number[][][] } )?.coordinates?.[0]?.[0]; const coordHash = firstCoord ? `${firstCoord[0].toFixed(4)},${firstCoord[1].toFixed(4)}` : `${idx}`; return ( ); })} {/* #999 (958-B4) — рыночные слои (конкуренты / pipeline / зоны риска). Зоны риска рисуются как фон-заливка внутри MarketLayers, точки — поверх. Видимость каждого слоя — из visibleMarketLayers. */} {hasMarketData && ( )} {/* POI markers */} {Object.entries(data.score_breakdown).flatMap(([cat, pois]) => { const style = CATEGORY_STYLES[cat]; if (!style) return []; return pois .filter((poi) => poi.lat !== 0 && poi.lon !== 0) .map((poi, idx) => (
{style.label}
{poi.name ?? без названия}
{poi.distance_m.toFixed(0)} м {poi.last_edit && ( <>
OSM: {poi.last_edit} )}
)); })} {/* Connection points layer — rendered on top of POI markers */} {connectionPoints && ( )} {/* Custom POI markers — topmost layer */} {customPois && customPois.length > 0 && ( setEditingPoi(poi)} onDelete={(id) => deleteMutation.mutate(id)} /> )}
{/* Custom POI add-mode toggle button */}
{ setAddMode((v) => !v); setPendingCoords(null); }} /> {addMode && ( Кликните на карте для добавления точки )} {customPois && customPois.length > 0 && ( {customPois.length} польз.{" "} {customPois.length === 1 ? "точка" : customPois.length < 5 ? "точки" : "точек"} )}
{/* Add modal */} {pendingCoords && ( { addMutation.mutate(payload, { onSuccess: () => setPendingCoords(null), }); }} onCancel={() => setPendingCoords(null)} /> )} {/* Edit modal */} {editingPoi && ( { updateMutation.mutate( { id: editingPoi.id, data: updateData }, { onSuccess: () => setEditingPoi(null) }, ); }} onCancel={() => setEditingPoi(null)} /> )} {/* POI category legend */} {presentCategories.length > 0 && (
{presentCategories.map((cat) => { const s = CATEGORY_STYLES[cat]; if (!s) return null; const count = data.score_breakdown[cat]?.length ?? 0; return (
{s.label} ({count})
); })}
)} {!data.geom_geojson && (

Геометрия участка не найдена — на карте нет полигона

)} {/* Layer control panel — CP-слои + рыночные слои (#999). Рендерится, если есть данные точек подключения ИЛИ рыночные данные. */} {(connectionPoints || hasMarketData) && ( )} {/* #255 — ЗОУИТ-слой control panel. Рендерится только при наличии полигональной геометрии охранных зон в payload. */} {hasZouitGeom && ( )}
); }