gendesign/frontend/src/components/site-finder/SiteMap.tsx
Light1YT 0b20d98b45
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m6s
feat(site-finder): ЗОУИТ visual layer на Leaflet карте (#255)
ZouitLayer (GeoJSON-полигоны из geom_geojson, severity blocker/warning/info по keyword,
popup тип+имя) + ZouitLayerControlPanel (collapsible toggle per-severity, счётчик mappable)
+ SiteMap integration (default blocker+warning ON, info OFF, фон под POI). Graceful: нет
geom_geojson → слой/панель скрыты, текстовый NspdZouitOverlapsBlock остаётся. 10 vitest.

Closes #255
2026-06-13 17:41:08 +00:00

684 lines
24 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";
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<string, CategoryStyle> = {
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<Set<CpCategory>>(
new Set(CP_ALL_CATEGORIES),
);
// #999 (958-B4) — рыночные слои. Карта — MiniMap (компактная), поэтому по
// умолчанию включены только Конкуренты; Будущие проекты + Зоны риска OFF
// (пользователь включает тумблером в CpLayerControlPanel).
const [visibleMarketLayers, setVisibleMarketLayers] = useState<
Set<MarketLayerKey>
>(new Set<MarketLayerKey>(["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<Set<ZouitSeverity>>(
() => new Set<ZouitSeverity>(["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<CustomPoi | null>(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<CpCategory, EngineeringStructure[]>();
// #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 (
<div>
{/* Map */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
overflow: "hidden",
height: mapHeight,
cursor: addMode ? "crosshair" : "grab",
}}
>
<MapContainer
center={center}
zoom={15}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
>
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Parcel / quarter polygon */}
{data.geom_geojson && (
<GeoJSON
key={data.cad_num}
data={data.geom_geojson}
style={{
color: "#1d4ed8",
weight: 2.5,
fillColor: "#3b82f6",
fillOpacity: 0.2,
}}
/>
)}
{/* #255 — ЗОУИТ-полигоны (охранные зоны). Рисуем рано (под точками POI
и рыночными маркерами) как заливку-фон, поверх полигона участка. */}
{hasZouitGeom && (
<ZouitLayer
grouped={zouitGrouped}
visibleSeverities={visibleZouit}
/>
)}
{/* 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 (
<GeoJSON
key={`iso-${seconds}-${coordHash}`}
data={feature as Feature}
style={{
color,
fillColor: color,
fillOpacity: opacity,
weight: 1,
}}
/>
);
})}
{/* #999 (958-B4) — рыночные слои (конкуренты / pipeline / зоны риска).
Зоны риска рисуются как фон-заливка внутри MarketLayers, точки —
поверх. Видимость каждого слоя — из visibleMarketLayers. */}
{hasMarketData && (
<MarketLayers
competitors={competitorList}
pipelineObjects={pipelineList}
riskZones={riskZoneList}
opportunityParcels={opportunityList}
redLines={redLineList}
showCompetitors={visibleMarketLayers.has("competitors")}
showPipeline={visibleMarketLayers.has("pipeline")}
showRiskZones={visibleMarketLayers.has("risk")}
showOpportunity={visibleMarketLayers.has("opportunity")}
showRedLines={visibleMarketLayers.has("redlines")}
/>
)}
{/* 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) => (
<CircleMarker
key={`${cat}-${idx}`}
center={[poi.lat, poi.lon]}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.85,
weight: 1.5,
}}
>
<Popup>
<div style={{ fontSize: 13, lineHeight: 1.5 }}>
<strong>{style.label}</strong>
<br />
{poi.name ?? <em>без названия</em>}
<br />
{poi.distance_m.toFixed(0)} м
{poi.last_edit && (
<>
<br />
<span style={{ color: "#6b7280", fontSize: 11 }}>
OSM: {poi.last_edit}
</span>
</>
)}
</div>
</Popup>
</CircleMarker>
));
})}
{/* Connection points layer — rendered on top of POI markers */}
{connectionPoints && (
<ConnectionPointsLayer
grouped={cpGrouped}
visibleCategories={visibleCategories}
/>
)}
{/* Custom POI markers — topmost layer */}
{customPois && customPois.length > 0 && (
<CustomPoiLayer
pois={customPois}
onEdit={(poi) => setEditingPoi(poi)}
onDelete={(id) => deleteMutation.mutate(id)}
/>
)}
</MapContainer>
</div>
{/* Custom POI add-mode toggle button */}
<div
style={{ marginTop: 8, display: "flex", gap: 10, alignItems: "center" }}
>
<CustomPoiToggleButton
active={addMode}
onClick={() => {
setAddMode((v) => !v);
setPendingCoords(null);
}}
/>
{addMode && (
<span style={{ fontSize: 12, color: "#6b7280" }}>
Кликните на карте для добавления точки
</span>
)}
{customPois && customPois.length > 0 && (
<span style={{ fontSize: 12, color: "#374151", marginLeft: "auto" }}>
{customPois.length} польз.{" "}
{customPois.length === 1
? "точка"
: customPois.length < 5
? "точки"
: "точек"}
</span>
)}
</div>
{/* Add modal */}
{pendingCoords && (
<CustomPoiAddModal
lat={pendingCoords.lat}
lon={pendingCoords.lon}
parcelCad={parcelCad ?? null}
isLoading={addMutation.isPending}
onSubmit={(payload) => {
addMutation.mutate(payload, {
onSuccess: () => setPendingCoords(null),
});
}}
onCancel={() => setPendingCoords(null)}
/>
)}
{/* Edit modal */}
{editingPoi && (
<CustomPoiEditModal
poi={editingPoi}
isLoading={updateMutation.isPending}
onSubmit={(updateData) => {
updateMutation.mutate(
{ id: editingPoi.id, data: updateData },
{ onSuccess: () => setEditingPoi(null) },
);
}}
onCancel={() => setEditingPoi(null)}
/>
)}
{/* POI category legend */}
{presentCategories.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "6px 12px",
padding: "10px 4px 0",
}}
>
{presentCategories.map((cat) => {
const s = CATEGORY_STYLES[cat];
if (!s) return null;
const count = data.score_breakdown[cat]?.length ?? 0;
return (
<div
key={cat}
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 12,
color: "#374151",
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.color,
flexShrink: 0,
}}
/>
{s.label} ({count})
</div>
);
})}
</div>
)}
{!data.geom_geojson && (
<p
style={{
marginTop: 8,
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
}}
>
Геометрия участка не найдена на карте нет полигона
</p>
)}
{/* Layer control panel — CP-слои + рыночные слои (#999). Рендерится,
если есть данные точек подключения ИЛИ рыночные данные. */}
{(connectionPoints || hasMarketData) && (
<CpLayerControlPanel
data={connectionPoints ?? null}
grouped={cpGrouped}
visibleCategories={visibleCategories}
onToggleCategory={toggleCategory}
onToggleAll={toggleAll}
marketLayers={
hasMarketData
? [
{
key: "competitors",
label: "Конкуренты",
color: MARKET_COLORS.competitor,
count: competitorMappable,
},
{
key: "pipeline",
label: "Будущие проекты",
color: MARKET_COLORS.pipeline,
count: pipelineMappable,
},
{
key: "risk",
label: "Зоны риска",
color: MARKET_COLORS.risk,
count: riskZoneList.length,
},
{
key: "opportunity",
label: "Перспективные ЗУ",
color: MARKET_COLORS.opportunity,
count: opportunityMappable,
},
{
key: "redlines",
label: "Красные линии",
color: MARKET_COLORS.redline,
count: redLineMappable,
},
]
: undefined
}
visibleMarketLayers={visibleMarketLayers}
onToggleMarketLayer={toggleMarketLayer}
/>
)}
{/* #255 — ЗОУИТ-слой control panel. Рендерится только при наличии
полигональной геометрии охранных зон в payload. */}
{hasZouitGeom && (
<ZouitLayerControlPanel
grouped={zouitGrouped}
visibleSeverities={visibleZouit}
onToggleSeverity={toggleZouit}
/>
)}
</div>
);
}