gendesign/frontend/src/components/site-finder/SiteMap.tsx
bot-backend 15757685c9
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m17s
Deploy / build-worker (push) Successful in 3m19s
Deploy / build-frontend (push) Successful in 3m54s
Deploy / deploy (push) Successful in 1m34s
feat(site-finder): Celery + §3 точки выхода газосети (#2119 B2, PR-4/4 — финал формы-6) (#2251)
2026-07-03 04:26:41 +00:00

996 lines
37 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 { 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='&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) => {
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>
);
}