996 lines
37 KiB
TypeScript
996 lines
37 KiB
TypeScript
"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 { UtilityInfrastructureResponse } from "@/hooks/useUtilityInfrastructure";
|
||
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 {
|
||
UtilityInfrastructureLayer,
|
||
UTILITY_ALL_KINDS,
|
||
groupUtilityByKind,
|
||
type UtilityKind,
|
||
type UtilityMappable,
|
||
} from "@/components/site-finder/UtilityInfrastructureLayer";
|
||
import { UtilityLayerControlPanel } from "@/components/site-finder/UtilityLayerControlPanel";
|
||
import {
|
||
PowerConnectionPointsLayer,
|
||
countMappablePowerPoints,
|
||
} from "@/components/site-finder/PowerConnectionPointsLayer";
|
||
import {
|
||
GasOutletPointsLayer,
|
||
countMappableGasOutlets,
|
||
} from "@/components/site-finder/GasOutletPointsLayer";
|
||
import {
|
||
GAS_OUTLET_MARKER_COLOR,
|
||
LOAD_INDEX_MARKER_COLOR,
|
||
LOAD_INDEX_NULL_COLOR,
|
||
pluralizeOutletPoints,
|
||
} from "@/components/site-finder/connection-capacity";
|
||
import type {
|
||
GasOutletPoint,
|
||
PowerConnectionPoint,
|
||
} from "@/hooks/useConnectionCapacity";
|
||
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 },
|
||
};
|
||
|
||
// Allowlist http(s) перед записью в href (XSS-защита, frontend.md). Возвращает
|
||
// null если схема не http/https — тогда показываем сайт как текст, не ссылку.
|
||
function safeHttpUrl(url: string | null | undefined): string | null {
|
||
if (!url) return null;
|
||
const t = url.trim();
|
||
return t.startsWith("https://") || t.startsWith("http://") ? t : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
// #1746 — инженерная инфраструктура из OSM (доп. слои на той же карте).
|
||
utilityInfrastructure?: UtilityInfrastructureResponse;
|
||
// Центры питания Россетей со свободной мощностью для ТП (#connection-capacity).
|
||
// Отдельный слой (не OSM): цвет по индексу загрузки.
|
||
powerConnectionPoints?: PowerConnectionPoint[];
|
||
// Точки выхода газосети (форма 6 Екатеринбурггаза) в радиусе от участка
|
||
// (#2119 B2 PR-4). Отдельный слой: цвет по знаку свободной мощности.
|
||
gasOutletPoints?: GasOutletPoint[];
|
||
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,
|
||
utilityInfrastructure,
|
||
powerConnectionPoints,
|
||
gasOutletPoints,
|
||
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;
|
||
});
|
||
}
|
||
|
||
// #1746 — слой инж. инфраструктуры OSM. Все виды видимы по умолчанию (как CP);
|
||
// пользователь отключает шумные слои тумблером в UtilityLayerControlPanel.
|
||
const [visibleUtilityKinds, setVisibleUtilityKinds] = useState<
|
||
Set<UtilityKind>
|
||
>(() => new Set(UTILITY_ALL_KINDS));
|
||
|
||
function toggleUtilityKind(kind: UtilityKind) {
|
||
setVisibleUtilityKinds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(kind)) {
|
||
next.delete(kind);
|
||
} else {
|
||
next.add(kind);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function toggleAllUtilityKinds() {
|
||
setVisibleUtilityKinds((prev) =>
|
||
prev.size === UTILITY_ALL_KINDS.length
|
||
? new Set()
|
||
: new Set(UTILITY_ALL_KINDS),
|
||
);
|
||
}
|
||
|
||
// Точки выхода газосети (#2119 B2 PR-4) — единичный слой, тумблер-чекбокс
|
||
// под картой. По умолчанию виден (как ЦП Россетей): точек мало (LIMIT 40,
|
||
// только геокодированные), карту не зашумляет.
|
||
const [showGasOutlets, setShowGasOutlets] = useState(true);
|
||
|
||
// 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;
|
||
|
||
// #1746 — инж. инфраструктура OSM. Группируем по виду; в карту/панель идут
|
||
// только features с валидной геометрией (Point/Line/Polygon).
|
||
const utilityGrouped = utilityInfrastructure
|
||
? groupUtilityByKind(utilityInfrastructure.features)
|
||
: new Map<UtilityKind, UtilityMappable[]>();
|
||
const utilityMappableCount = UTILITY_ALL_KINDS.reduce(
|
||
(sum, kind) => sum + (utilityGrouped.get(kind)?.length ?? 0),
|
||
0,
|
||
);
|
||
const hasUtilityGeom = utilityMappableCount > 0;
|
||
|
||
// Центры питания Россетей со свободной мощностью (#connection-capacity).
|
||
// Рисуем только точки с валидными координатами (Decimal→str приводим в слое).
|
||
const powerPointList = powerConnectionPoints ?? [];
|
||
const powerPointMappable = countMappablePowerPoints(powerPointList);
|
||
const hasPowerPoints = powerPointMappable > 0;
|
||
|
||
// Точки выхода газосети (#2119 B2 PR-4). Только ~19% строк источника
|
||
// геокодированы → список часто пуст (норма). Считаем рисуемые точки.
|
||
const gasOutletList = gasOutletPoints ?? [];
|
||
const gasOutletMappable = countMappableGasOutlets(gasOutletList);
|
||
const hasGasOutlets = gasOutletMappable > 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='© <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) => {
|
||
const websiteUrl = safeHttpUrl(poi.website);
|
||
return (
|
||
<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, minWidth: 160 }}
|
||
>
|
||
<strong>{style.label}</strong>
|
||
<br />
|
||
{poi.name ?? <em>без названия</em>}
|
||
<br />
|
||
<span style={{ color: "#374151" }}>
|
||
{poi.distance_m.toFixed(0)} м от участка
|
||
</span>
|
||
{poi.address && (
|
||
<>
|
||
<br />
|
||
<span style={{ color: "#374151" }}>
|
||
Адрес: {poi.address}
|
||
</span>
|
||
</>
|
||
)}
|
||
{poi.operator && (
|
||
<>
|
||
<br />
|
||
<span style={{ color: "#374151" }}>
|
||
Оператор: {poi.operator}
|
||
</span>
|
||
</>
|
||
)}
|
||
{poi.opening_hours && (
|
||
<>
|
||
<br />
|
||
<span style={{ color: "#374151" }}>
|
||
Часы: {poi.opening_hours}
|
||
</span>
|
||
</>
|
||
)}
|
||
{poi.phone && (
|
||
<>
|
||
<br />
|
||
<a href={`tel:${poi.phone.replace(/[^\d+]/g, "")}`}>
|
||
{poi.phone}
|
||
</a>
|
||
</>
|
||
)}
|
||
{websiteUrl ? (
|
||
<>
|
||
<br />
|
||
<a
|
||
href={websiteUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
Сайт
|
||
</a>
|
||
</>
|
||
) : (
|
||
poi.website && (
|
||
<>
|
||
<br />
|
||
<span style={{ color: "#374151" }}>
|
||
{poi.website}
|
||
</span>
|
||
</>
|
||
)
|
||
)}
|
||
{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}
|
||
/>
|
||
)}
|
||
|
||
{/* #1746 — инж. инфраструктура OSM (ЛЭП-линии, подстанции, узлы).
|
||
Линии/полигоны как <GeoJSON>, точки как CircleMarker. */}
|
||
{hasUtilityGeom && (
|
||
<UtilityInfrastructureLayer
|
||
grouped={utilityGrouped}
|
||
visibleKinds={visibleUtilityKinds}
|
||
/>
|
||
)}
|
||
|
||
{/* Центры питания Россетей со свободной мощностью для ТП
|
||
(#connection-capacity). Отдельный слой поверх OSM: цвет по индексу
|
||
загрузки, отличается от оранжевых точек НСПД/инж.сетей OSM. */}
|
||
{hasPowerPoints && (
|
||
<PowerConnectionPointsLayer points={powerPointList} />
|
||
)}
|
||
|
||
{/* Точки выхода газосети (форма 6 Екатеринбурггаза, #2119 B2 PR-4).
|
||
Отдельный слой поверх ЦП: цвет по знаку свободной мощности,
|
||
меньший радиус (низовой слой газосети). Тумблер-чекбокс под картой. */}
|
||
{hasGasOutlets && showGasOutlets && (
|
||
<GasOutletPointsLayer points={gasOutletList} />
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* Легенда слоя центров питания (#connection-capacity). Слой не тумблерный
|
||
(небольшое число точек), поэтому подпись рендерим в легенде под картой:
|
||
цвет маркера кодирует индекс загрузки ЦП. */}
|
||
{hasPowerPoints && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: "6px 12px",
|
||
padding: "10px 4px 0",
|
||
fontSize: 12,
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: 600 }}>
|
||
Центры питания (резерв): {powerPointMappable}
|
||
</span>
|
||
{(
|
||
[
|
||
["open", "открыт для ТП"],
|
||
["limited", "ограничен"],
|
||
["closed", "закрыт"],
|
||
] as const
|
||
).map(([idx, label]) => (
|
||
<span
|
||
key={idx}
|
||
style={{ display: "flex", alignItems: "center", gap: 5 }}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: "50%",
|
||
background: LOAD_INDEX_MARKER_COLOR[idx],
|
||
border: "2px solid #ffffff",
|
||
boxShadow: "0 0 0 1px #d1d5db",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
{label}
|
||
</span>
|
||
))}
|
||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||
<span
|
||
style={{
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: "50%",
|
||
background: LOAD_INDEX_NULL_COLOR,
|
||
border: "2px solid #ffffff",
|
||
boxShadow: "0 0 0 1px #d1d5db",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
нет данных
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Точки выхода газосети (#2119 B2 PR-4). Чекбокс-тумблер + легенда цветов
|
||
по знаку свободной мощности. Показываем только при непустом списке
|
||
рисуемых точек (LIMIT 40, ~19% геокодировано). */}
|
||
{hasGasOutlets && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: "6px 12px",
|
||
padding: "10px 4px 0",
|
||
fontSize: 12,
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontWeight: 600,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={showGasOutlets}
|
||
onChange={(e) => setShowGasOutlets(e.target.checked)}
|
||
/>
|
||
Точки выхода газа ({pluralizeOutletPoints(gasOutletMappable)})
|
||
</label>
|
||
{(
|
||
[
|
||
["profit", "профицит"],
|
||
["deficit", "дефицит"],
|
||
["calc", "нужен гидрорасчёт"],
|
||
] as const
|
||
).map(([state, label]) => (
|
||
<span
|
||
key={state}
|
||
style={{ display: "flex", alignItems: "center", gap: 5 }}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: "50%",
|
||
background: GAS_OUTLET_MARKER_COLOR[state],
|
||
border: "2px solid #ffffff",
|
||
boxShadow: "0 0 0 1px #d1d5db",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
{label}
|
||
</span>
|
||
))}
|
||
<span style={{ color: "#9ca3af" }}>
|
||
Форма 6 АО «Екатеринбурггаз» · млн м³/мес
|
||
</span>
|
||
</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}
|
||
/>
|
||
)}
|
||
|
||
{/* #1746 — control panel слоя инж. инфраструктуры OSM. Рендерится только
|
||
при наличии рисуемой геометрии. */}
|
||
{hasUtilityGeom && (
|
||
<UtilityLayerControlPanel
|
||
grouped={utilityGrouped}
|
||
visibleKinds={visibleUtilityKinds}
|
||
onToggleKind={toggleUtilityKind}
|
||
onToggleAll={toggleAllUtilityKinds}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|