"use client"; /** * PticaMapInner — the rich dark cockpit map (client-only; loaded via * dynamic(ssr:false) from PticaMap). Prototype parity (ptica-redesign/app.js): * * - Two base layers, toggled by the «Спутник | Схема» segmented control: Esri * World Imagery (default, `.baseSat` → darker --map-filter-sat) and CartoDB * dark vector tiles. The parcel polygon reads clearly over both. * - REAL data layers, reusing the existing analysis-map components so logic is * not duplicated: * · parcel polygon (cyan, geom_geojson) * · POI markers from score_breakdown — bucketed ЖК / Школы·Детсады / * Парки·Метро like the prototype legend (CircleMarker, colored) * · competitors / pipeline (MarketLayers — ЖК + будущие проекты) * · ЗОУИТ охранные зоны (ZouitLayer) * · connection points + colored polylines to the parcel (ConnectionPoints- * Layer + per-category lines from the centroid) * · custom POI (CustomPoiLayer + add/edit/delete via useCustomPois) * - The map-overlay legend rows TOGGLE each layer; the «Точки подключения * ресурсов» list is driven by real connection-points data (nearest distance * per resource category). * - Isochrones: the only real source (useIsochrones) is an on-demand ORS * mutation, not part of /analyze — per the task we DO NOT fake it; the legend * row stays «скоро». * - The «3D-масса застройки» legend row + map tool open the future3d drawer. */ import { useEffect, useMemo, useRef, useState } from "react"; import { MapContainer, TileLayer, GeoJSON, CircleMarker, Polyline, Popup, Tooltip, useMap, useMapEvents, } from "react-leaflet"; import type { Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import { EKB_CENTER } from "@/components/site-finder/ptica/ptica-adapt"; import type { ParcelAnalysis } from "@/types/site-finder"; import type { ConnectionPointsResponse } from "@/types/nspd"; import type { CustomPoi } from "@/types/customPoi"; import { MarketLayers, MARKET_COLORS, } from "@/components/site-finder/MarketLayers"; import { ConnectionPointsLayer, CP_CATEGORY_STYLES, CP_ALL_CATEGORIES, classifyStructure, groupStructuresByCategory, type CpCategory, } from "@/components/site-finder/ConnectionPointsLayer"; import { ZouitLayer, ZOUIT_ALL_SEVERITIES, groupZouitBySeverity, type ZouitSeverity, } from "@/components/site-finder/ZouitLayer"; import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer"; import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal"; import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal"; import { useAddCustomPoi, useUpdateCustomPoi, useDeleteCustomPoi, } from "@/hooks/useCustomPois"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; // ── POI legend buckets (prototype: ЖК / Школы·Детсады / Парки·Метро) ─────────── type PoiBucket = "edu" | "greenmetro"; interface PoiBucketStyle { color: string; label: string; radius: number; } // token-hex (map exception per ui-tokens.md) — match prototype poiColor() const POI_BUCKET_STYLES: Record = { edu: { color: "#3fa77d", label: "Школы · Дет.сады", radius: 6 }, // --res-poi greenmetro: { color: "#6aa15f", label: "Парки · Метро", radius: 6 }, }; const EDU_CATEGORIES = new Set(["school", "kindergarten"]); const GREENMETRO_CATEGORIES = new Set([ "park", "metro_stop", "tram_stop", "bus_stop", ]); function poiBucket(category: string): PoiBucket | null { if (EDU_CATEGORIES.has(category)) return "edu"; if (GREENMETRO_CATEGORIES.has(category)) return "greenmetro"; return null; } // ── Narrowing: unknown → GeoJSON Geometry ───────────────────────────────────── const GEOMETRY_TYPES = new Set([ "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon", "GeometryCollection", ]); function asGeometry(value: unknown): Geometry | null { if (value == null || typeof value !== "object") return null; if (!("type" in value)) return null; const type = (value as { type: unknown }).type; if (typeof type !== "string" || !GEOMETRY_TYPES.has(type)) return null; return value as Geometry; } // ── Centroid (bounding-box center — sufficient for connection-line origin) ───── function flattenCoords(geom: Geometry): Position[] { switch (geom.type) { case "Point": return [geom.coordinates]; case "MultiPoint": case "LineString": return geom.coordinates; case "MultiLineString": case "Polygon": return geom.coordinates.flat(); case "MultiPolygon": return geom.coordinates.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 EKB_CENTER; let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; let 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]; } // Pull [lat, lon] out of an EngineeringStructure point geometry (GeoJSON // [lon, lat]). Non-points / missing coords → null (skip in lines). function structureLatLon( geom: Record, ): [number, number] | null { if (geom.type !== "Point") return null; const coords = geom.coordinates as number[] | undefined; if (!coords || coords.length < 2) return null; return [coords[1], coords[0]]; } function formatMeters(m: number): string { if (m >= 1000) return `${(m / 1000).toFixed(2)} км`; return `${Math.round(m)} м`; } // ── Layer keys ──────────────────────────────────────────────────────────────── type LayerKey = | "parcel" | "competitors" // ЖК | "pipeline" // будущие проекты | "edu" | "greenmetro" | "connections" | "zouit" | "custompoi"; // ── Map controllers (must live inside MapContainer) ─────────────────────────── function FitToGeometry({ geom }: { geom: Geometry | null }) { const map = useMap(); useEffect(() => { if (!geom) return; const coords = flattenCoords(geom); if (coords.length < 2) return; let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; let 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; } map.fitBounds( [ [minLat, minLon], [maxLat, maxLon], ], { padding: [48, 48], maxZoom: 16, animate: false }, ); map.setZoom(Math.max(map.getZoom() - 1, 13), { animate: false }); }, [map, geom]); return null; } function MapClickHandler({ editMode, onMapClick, }: { editMode: boolean; onMapClick: (lat: number, lon: number) => void; }) { useMapEvents({ click(e) { if (editMode) onMapClick(e.latlng.lat, e.latlng.lng); }, }); return null; } // ── Props ───────────────────────────────────────────────────────────────────── interface Props { analysis: ParcelAnalysis; cad: string; connectionPoints?: ConnectionPointsResponse; customPois?: CustomPoi[]; onOpenDrawer?: (key: DrawerKey) => void; } export default function PticaMapInner({ analysis, cad, connectionPoints, customPois, onOpenDrawer, }: Props) { // Fix Leaflet default icon paths broken by webpack bundler (mirror SiteMap). 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", }); }); }, []); const geom = useMemo( () => asGeometry(analysis.geom_geojson), [analysis.geom_geojson], ); const center = geom ? geomCenter(geom) : EKB_CENTER; // ── Base layer (satellite default — matches prototype + header) ────────────── const [base, setBase] = useState<"sat" | "vec">("sat"); // ── Layer visibility (parcel always on; isochrones is «скоро», not wired) ──── const [visible, setVisible] = useState>( () => new Set([ "parcel", "competitors", "pipeline", "edu", "greenmetro", "connections", "zouit", "custompoi", ]), ); function toggleLayer(key: LayerKey) { setVisible((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } // ── Custom POI add/edit state + mutations ──────────────────────────────────── const [addMode, setAddMode] = useState(false); const [pendingCoords, setPendingCoords] = useState<{ lat: number; lon: number; } | null>(null); const [editingPoi, setEditingPoi] = useState(null); const addMutation = useAddCustomPoi(cad); const updateMutation = useUpdateCustomPoi(cad); const deleteMutation = useDeleteCustomPoi(cad); // 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 handleMapClick(lat: number, lon: number) { setPendingCoords({ lat, lon }); setAddMode(false); } // ── POI markers from score_breakdown, bucketed ─────────────────────────────── const poiByBucket = useMemo(() => { const buckets: Record< PoiBucket, { lat: number; lon: number; name: string | null; distance_m: number }[] > = { edu: [], greenmetro: [], }; for (const [cat, pois] of Object.entries(analysis.score_breakdown)) { const bucket = poiBucket(cat); if (!bucket) continue; for (const p of pois) { if (p.lat === 0 && p.lon === 0) continue; buckets[bucket].push({ lat: p.lat, lon: p.lon, name: p.name, distance_m: p.distance_m, }); } } return buckets; }, [analysis.score_breakdown]); // ── Market data (competitors = ЖК, pipeline = будущие проекты) ─────────────── const competitorList = analysis.competitors ?? []; const pipelineList = analysis.pipeline_24mo?.top_objects ?? []; const competitorCount = competitorList.filter( (c) => typeof c.lat === "number" && typeof c.lon === "number", ).length; const pipelineCount = pipelineList.filter( (p) => typeof p.lat === "number" && typeof p.lon === "number", ).length; // ── Connection points (grouped by resource category) ───────────────────────── const cpGrouped = useMemo( () => connectionPoints ? groupStructuresByCategory(connectionPoints.engineering_structures) : new Map(), [connectionPoints], ); const cpCount = connectionPoints?.engineering_structures.length ?? 0; // Nearest distance per category — drives the «Точки подключения» list. const cpNearest = useMemo(() => { const nearest = new Map(); if (!connectionPoints) return nearest; for (const s of connectionPoints.engineering_structures) { const cat = classifyStructure(s); const cur = nearest.get(cat); if (cur === undefined || s.distance_to_boundary_m < cur) { nearest.set(cat, s.distance_to_boundary_m); } } return nearest; }, [connectionPoints]); // Connection polylines from the parcel centroid to each structure point. const connectionLines = useMemo(() => { if (!connectionPoints || !geom) return []; const origin = geomCenter(geom); const lines: { from: [number, number]; to: [number, number]; color: string; }[] = []; for (const s of connectionPoints.engineering_structures) { const latLon = structureLatLon(s.geometry_geojson); if (!latLon) continue; const cat = classifyStructure(s); lines.push({ from: origin, to: latLon, color: CP_CATEGORY_STYLES[cat].color, }); } return lines; }, [connectionPoints, geom]); // ── ЗОУИТ overlaps grouped by severity ─────────────────────────────────────── const zouitGrouped = useMemo( () => groupZouitBySeverity(analysis.nspd_zouit_overlaps ?? []), [analysis.nspd_zouit_overlaps], ); const zouitCount = ZOUIT_ALL_SEVERITIES.reduce( (sum, sev) => sum + (zouitGrouped.get(sev)?.length ?? 0), 0, ); const allZouitVisible: Set = useMemo( () => new Set(ZOUIT_ALL_SEVERITIES), [], ); const customPoiList = customPois ?? []; return (
{/* Base layers — only one mounted at a time (key forces tile swap). */} {base === "sat" ? ( ) : ( )} {/* ЗОУИТ охранные зоны — background fill (under markers). */} {visible.has("zouit") && zouitCount > 0 && ( )} {/* Competitors (ЖК) + pipeline (будущие проекты). */} {(visible.has("competitors") || visible.has("pipeline")) && ( )} {/* POI markers (Школы·Детсады / Парки·Метро). */} {(["edu", "greenmetro"] as PoiBucket[]).flatMap((bucket) => { if (!visible.has(bucket)) return []; const style = POI_BUCKET_STYLES[bucket]; return poiByBucket[bucket].map((poi, idx) => ( {poi.name ?? style.label} · {formatMeters(poi.distance_m)} )); })} {/* Connection points + polylines to the parcel centroid. */} {visible.has("connections") && connectionPoints && ( <> {connectionLines.map((line, idx) => ( ))} )} {/* Parcel polygon — cyan, on top of fills so it always reads. */} {visible.has("parcel") && geom && ( )} {/* Custom POI — topmost; add/edit/delete wired to useCustomPois. */} {visible.has("custompoi") && customPoiList.length > 0 && ( setEditingPoi(poi)} onDelete={(id) => deleteMutation.mutate(id)} /> )} {/* ── Legend / layer-toggle overlay ────────────────────────────────────── */}

{base === "sat" ? "Спутник · свежий снимок" : "Схема · тёмная карта"}

toggleLayer("parcel")} /> toggleLayer("competitors")} /> toggleLayer("pipeline")} /> toggleLayer("edu")} /> toggleLayer("greenmetro")} /> {zouitCount > 0 && ( toggleLayer("zouit")} /> )} toggleLayer("connections")} /> {/* Изохроны — реальный источник (ORS) пока вне /analyze: «скоро». */}
Изохроны доступности скоро
toggleLayer("custompoi")} /> {onOpenDrawer && ( )}
{cpCount > 0 && ( <>
Точки подключения ресурсов
{CP_ALL_CATEGORIES.filter((c) => cpNearest.has(c)).map((catKey) => { const dist = cpNearest.get(catKey); if (dist === undefined) return null; const style = CP_CATEGORY_STYLES[catKey]; return (
{style.label} {formatMeters(dist)}
); })} )}
{/* ── Map tools (add-POI · 3D) ─────────────────────────────────────────── */}
{onOpenDrawer && ( )}
{addMode && (
Кликните на карте для точки
)} {/* Add / edit modals (reuse the analysis-map custom-POI flow). */} {pendingCoords && ( { addMutation.mutate(payload, { onSuccess: () => setPendingCoords(null), }); }} onCancel={() => setPendingCoords(null)} /> )} {editingPoi && ( { updateMutation.mutate( { id: editingPoi.id, data: updateData }, { onSuccess: () => setEditingPoi(null) }, ); }} onCancel={() => setEditingPoi(null)} /> )}
); } // ── Legend row (toggle button) ───────────────────────────────────────────────── function LegendRow({ color, label, count, on, onToggle, }: { color: string; label: string; count: number; on: boolean; onToggle: () => void; }) { return ( ); }