Fix two regressions vs the prototype: 1. PticaMapInner: render real data layers over the dark base by REUSING the existing analysis-map components — POI by category, competitors/pipeline (MarketLayers), connection points + colored polylines (ConnectionPointsLayer), ЗОУИТ (ZouitLayer), custom POI add/edit/delete (CustomPoiLayer + mutation hooks). Add Спутник/Схема base toggle (Esri default + --map-filter-sat); legend rows toggle layers; «Точки подключения» driven by real CP data. Isochrones left «скоро» (ORS is on-demand, not in /analyze). 2. /site-finder/analysis/[cad] now renders the ПТИЦА cockpit — every parcel entry opens ПТИЦА, not the old design. [cad]/ptica redirects to the parent (single cockpit copy). Old AnalysisPageContent kept in repo; legacy UI at /legacy/site-finder. 3. Surface 3D massing: new «Инсоляция · 3D-масса» scan card + 3D legend row + 3D map tool all open the insolation/future3d drawers. SSR-safe (Leaflet only behind dynamic ssr:false); real POI from score_breakdown; no redirect loop. tsc/lint/prettier/build green. code-reviewer APPROVE.
790 lines
26 KiB
TypeScript
790 lines
26 KiB
TypeScript
"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<PoiBucket, PoiBucketStyle> = {
|
||
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<string, unknown>,
|
||
): [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<Set<LayerKey>>(
|
||
() =>
|
||
new Set<LayerKey>([
|
||
"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<CustomPoi | null>(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<CpCategory, never[]>(),
|
||
[connectionPoints],
|
||
);
|
||
const cpCount = connectionPoints?.engineering_structures.length ?? 0;
|
||
|
||
// Nearest distance per category — drives the «Точки подключения» list.
|
||
const cpNearest = useMemo(() => {
|
||
const nearest = new Map<CpCategory, number>();
|
||
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<ZouitSeverity> = useMemo(
|
||
() => new Set(ZOUIT_ALL_SEVERITIES),
|
||
[],
|
||
);
|
||
|
||
const customPoiList = customPois ?? [];
|
||
|
||
return (
|
||
<div
|
||
className={`${styles.mapMount} ${base === "sat" ? styles.baseSat : ""}`}
|
||
>
|
||
<MapContainer
|
||
center={center}
|
||
zoom={geom ? 15 : 11}
|
||
zoomControl={false}
|
||
scrollWheelZoom
|
||
style={{ cursor: addMode ? "crosshair" : "grab" }}
|
||
>
|
||
<FitToGeometry geom={geom} />
|
||
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
||
|
||
{/* Base layers — only one mounted at a time (key forces tile swap). */}
|
||
{base === "sat" ? (
|
||
<TileLayer
|
||
key="sat"
|
||
attribution="© Esri · Maxar"
|
||
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||
maxZoom={19}
|
||
/>
|
||
) : (
|
||
<TileLayer
|
||
key="vec"
|
||
attribution='© <a href="https://carto.com/attributions">CARTO</a> · © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||
maxZoom={19}
|
||
/>
|
||
)}
|
||
|
||
{/* ЗОУИТ охранные зоны — background fill (under markers). */}
|
||
{visible.has("zouit") && zouitCount > 0 && (
|
||
<ZouitLayer
|
||
grouped={zouitGrouped}
|
||
visibleSeverities={allZouitVisible}
|
||
/>
|
||
)}
|
||
|
||
{/* Competitors (ЖК) + pipeline (будущие проекты). */}
|
||
{(visible.has("competitors") || visible.has("pipeline")) && (
|
||
<MarketLayers
|
||
competitors={competitorList}
|
||
pipelineObjects={pipelineList}
|
||
riskZones={[]}
|
||
opportunityParcels={[]}
|
||
redLines={[]}
|
||
showCompetitors={visible.has("competitors")}
|
||
showPipeline={visible.has("pipeline")}
|
||
showRiskZones={false}
|
||
showOpportunity={false}
|
||
showRedLines={false}
|
||
/>
|
||
)}
|
||
|
||
{/* 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) => (
|
||
<CircleMarker
|
||
key={`${bucket}-${idx}`}
|
||
center={[poi.lat, poi.lon]}
|
||
radius={style.radius}
|
||
pathOptions={{
|
||
color: style.color,
|
||
fillColor: style.color,
|
||
fillOpacity: 0.85,
|
||
weight: 1.5,
|
||
}}
|
||
>
|
||
<Tooltip direction="top">
|
||
{poi.name ?? style.label} · {formatMeters(poi.distance_m)}
|
||
</Tooltip>
|
||
</CircleMarker>
|
||
));
|
||
})}
|
||
|
||
{/* Connection points + polylines to the parcel centroid. */}
|
||
{visible.has("connections") && connectionPoints && (
|
||
<>
|
||
{connectionLines.map((line, idx) => (
|
||
<Polyline
|
||
key={`cp-line-${idx}`}
|
||
positions={[line.from, line.to]}
|
||
pathOptions={{
|
||
color: line.color,
|
||
weight: 2.2,
|
||
opacity: 0.85,
|
||
dashArray: "4 6",
|
||
}}
|
||
/>
|
||
))}
|
||
<ConnectionPointsLayer
|
||
grouped={cpGrouped}
|
||
visibleCategories={new Set(CP_ALL_CATEGORIES)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* Parcel polygon — cyan, on top of fills so it always reads. */}
|
||
{visible.has("parcel") && geom && (
|
||
<GeoJSON
|
||
key={analysis.cad_num}
|
||
data={geom}
|
||
style={{
|
||
// = --accent-cyan-strong (Leaflet styles can't read CSS vars)
|
||
color: "#8ed3ff",
|
||
weight: 2.5,
|
||
fillColor: "#8ed3ff",
|
||
fillOpacity: 0.15,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Custom POI — topmost; add/edit/delete wired to useCustomPois. */}
|
||
{visible.has("custompoi") && customPoiList.length > 0 && (
|
||
<CustomPoiLayer
|
||
pois={customPoiList}
|
||
onEdit={(poi) => setEditingPoi(poi)}
|
||
onDelete={(id) => deleteMutation.mutate(id)}
|
||
/>
|
||
)}
|
||
</MapContainer>
|
||
|
||
{/* ── Legend / layer-toggle overlay ────────────────────────────────────── */}
|
||
<div className={styles.mapOverlay}>
|
||
<h4>
|
||
<span className={styles.live} />
|
||
{base === "sat" ? "Спутник · свежий снимок" : "Схема · тёмная карта"}
|
||
</h4>
|
||
|
||
<div className={styles.baseToggle}>
|
||
<button
|
||
type="button"
|
||
className={base === "sat" ? styles.baseToggleActive : ""}
|
||
onClick={() => setBase("sat")}
|
||
>
|
||
Спутник
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={base === "vec" ? styles.baseToggleActive : ""}
|
||
onClick={() => setBase("vec")}
|
||
>
|
||
Схема
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.layerList}>
|
||
<LegendRow
|
||
color="#8ed3ff"
|
||
label="Участок"
|
||
count={geom ? 1 : 0}
|
||
on={visible.has("parcel")}
|
||
onToggle={() => toggleLayer("parcel")}
|
||
/>
|
||
<LegendRow
|
||
color={MARKET_COLORS.competitor}
|
||
label="ЖК"
|
||
count={competitorCount}
|
||
on={visible.has("competitors")}
|
||
onToggle={() => toggleLayer("competitors")}
|
||
/>
|
||
<LegendRow
|
||
color={MARKET_COLORS.pipeline}
|
||
label="Будущие проекты"
|
||
count={pipelineCount}
|
||
on={visible.has("pipeline")}
|
||
onToggle={() => toggleLayer("pipeline")}
|
||
/>
|
||
<LegendRow
|
||
color={POI_BUCKET_STYLES.edu.color}
|
||
label="Школы · Дет.сады"
|
||
count={poiByBucket.edu.length}
|
||
on={visible.has("edu")}
|
||
onToggle={() => toggleLayer("edu")}
|
||
/>
|
||
<LegendRow
|
||
color={POI_BUCKET_STYLES.greenmetro.color}
|
||
label="Парки · Метро"
|
||
count={poiByBucket.greenmetro.length}
|
||
on={visible.has("greenmetro")}
|
||
onToggle={() => toggleLayer("greenmetro")}
|
||
/>
|
||
{zouitCount > 0 && (
|
||
<LegendRow
|
||
color="#d2655b"
|
||
label="ЗОУИТ"
|
||
count={zouitCount}
|
||
on={visible.has("zouit")}
|
||
onToggle={() => toggleLayer("zouit")}
|
||
/>
|
||
)}
|
||
<LegendRow
|
||
color="#7fd0ee"
|
||
label="Точки подключения"
|
||
count={cpCount}
|
||
on={visible.has("connections")}
|
||
onToggle={() => toggleLayer("connections")}
|
||
/>
|
||
{/* Изохроны — реальный источник (ORS) пока вне /analyze: «скоро». */}
|
||
<div className={`${styles.layerRow} ${styles.layerRowSoon}`}>
|
||
<span className={styles.swatch} />
|
||
<span>Изохроны доступности</span>
|
||
<span className={styles.cnt}>скоро</span>
|
||
</div>
|
||
<LegendRow
|
||
color="#e0a93b"
|
||
label="Кастомные POI"
|
||
count={customPoiList.length}
|
||
on={visible.has("custompoi")}
|
||
onToggle={() => toggleLayer("custompoi")}
|
||
/>
|
||
{onOpenDrawer && (
|
||
<button
|
||
type="button"
|
||
className={`${styles.layerRow} ${styles.layerRow3d}`}
|
||
onClick={() => onOpenDrawer("future3d")}
|
||
title="Открыть 3D-массу застройки"
|
||
>
|
||
<span className={`${styles.swatch} ${styles.swatch3d}`} />
|
||
<span>3D-масса застройки</span>
|
||
<span className={styles.openTag}>3D</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{cpCount > 0 && (
|
||
<>
|
||
<hr className={styles.overlayDivider} />
|
||
<div className={styles.overlaySub}>Точки подключения ресурсов</div>
|
||
{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 (
|
||
<div key={catKey} className={styles.resRow}>
|
||
<span
|
||
className={styles.dot}
|
||
style={{ background: style.color }}
|
||
/>
|
||
<span>{style.label}</span>
|
||
<b>{formatMeters(dist)}</b>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Map tools (add-POI · 3D) ─────────────────────────────────────────── */}
|
||
<div className={styles.mapTools}>
|
||
<button
|
||
type="button"
|
||
className={`${styles.tool} ${addMode ? styles.toolActive : ""}`}
|
||
title={addMode ? "Кликните на карте" : "Добавить POI"}
|
||
aria-label="Добавить пользовательскую точку"
|
||
onClick={() => {
|
||
setAddMode((v) => !v);
|
||
setPendingCoords(null);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none">
|
||
<path
|
||
d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M12 7v6M9 10h6"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{onOpenDrawer && (
|
||
<button
|
||
type="button"
|
||
className={styles.tool}
|
||
title="3D-масса застройки"
|
||
aria-label="Открыть 3D-массу застройки"
|
||
onClick={() => onOpenDrawer("future3d")}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none">
|
||
<path
|
||
d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"
|
||
stroke="currentColor"
|
||
strokeWidth="1.4"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{addMode && (
|
||
<div className={styles.mapHint}>Кликните на карте для точки</div>
|
||
)}
|
||
|
||
{/* Add / edit modals (reuse the analysis-map custom-POI flow). */}
|
||
{pendingCoords && (
|
||
<CustomPoiAddModal
|
||
lat={pendingCoords.lat}
|
||
lon={pendingCoords.lon}
|
||
parcelCad={cad}
|
||
isLoading={addMutation.isPending}
|
||
onSubmit={(payload) => {
|
||
addMutation.mutate(payload, {
|
||
onSuccess: () => setPendingCoords(null),
|
||
});
|
||
}}
|
||
onCancel={() => setPendingCoords(null)}
|
||
/>
|
||
)}
|
||
{editingPoi && (
|
||
<CustomPoiEditModal
|
||
poi={editingPoi}
|
||
isLoading={updateMutation.isPending}
|
||
onSubmit={(updateData) => {
|
||
updateMutation.mutate(
|
||
{ id: editingPoi.id, data: updateData },
|
||
{ onSuccess: () => setEditingPoi(null) },
|
||
);
|
||
}}
|
||
onCancel={() => setEditingPoi(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Legend row (toggle button) ─────────────────────────────────────────────────
|
||
|
||
function LegendRow({
|
||
color,
|
||
label,
|
||
count,
|
||
on,
|
||
onToggle,
|
||
}: {
|
||
color: string;
|
||
label: string;
|
||
count: number;
|
||
on: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`${styles.layerRow} ${on ? "" : styles.layerRowOff}`}
|
||
onClick={onToggle}
|
||
aria-pressed={on}
|
||
>
|
||
<span className={styles.swatch} style={{ background: color }} />
|
||
<span>{label}</span>
|
||
<span className={styles.cnt}>{count}</span>
|
||
</button>
|
||
);
|
||
}
|