gendesign/frontend/src/components/site-finder/ptica/PticaMapInner.tsx
Light1YT ba05a4cb62
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 49s
CI / openapi-codegen-check (pull_request) Successful in 1m48s
feat(ptica): rich dark cockpit map + ПТИЦА as the default analysis route
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.
2026-06-21 00:04:00 +05:00

790 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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="&copy; Esri · Maxar"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maxZoom={19}
/>
) : (
<TileLayer
key="vec"
attribution='&copy; <a href="https://carto.com/attributions">CARTO</a> · &copy; <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>
);
}