From a7671513c9e70929a1928cc5f41deb979a5153bc Mon Sep 17 00:00:00 2001
From: lekss361
Date: Sat, 16 May 2026 22:51:12 +0300
Subject: [PATCH] =?UTF-8?q?feat(#115):=20Leaflet=20layer=20toggle=20=D0=B4?=
=?UTF-8?q?=D0=BB=D1=8F=20connection=20points=20(=D0=9C=D0=B0=D0=BA=D1=81?=
=?UTF-8?q?=20KILLER)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ConnectionPointsLayer.tsx — per-category CircleMarker слой внутри MapContainer
(electricity/gas/water/heat/sewage/telecom/other, классификация по keywords)
- CpLayerControlPanel.tsx — toggle panel под картой: checkbox per-category + count,
toggle-all, summary badges (ближайший, охранная зона)
- SiteMap.tsx — принимает connectionPoints?: ConnectionPointsResponse,
управляет visibleCategories state, рендерит оба новых компонента
- page.tsx — useConnectionPoints(data?.cad_num) -> передаёт в SiteMap
- Empty-state: "0 точек подключения" при dump_available=false или пустом ответе
---
frontend/src/app/site-finder/page.tsx | 10 +-
.../site-finder/ConnectionPointsLayer.tsx | 224 ++++++++++++++++++
.../site-finder/CpLayerControlPanel.tsx | 224 ++++++++++++++++++
.../src/components/site-finder/SiteMap.tsx | 65 ++++-
4 files changed, 520 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/components/site-finder/ConnectionPointsLayer.tsx
create mode 100644 frontend/src/components/site-finder/CpLayerControlPanel.tsx
diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx
index baa15870..460a9c00 100644
--- a/frontend/src/app/site-finder/page.tsx
+++ b/frontend/src/app/site-finder/page.tsx
@@ -16,6 +16,7 @@ import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
+import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
@@ -98,6 +99,9 @@ function SiteFinderContent() {
undefined,
);
+ // Fetch connection points whenever a parcel is loaded
+ const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
+
// Weight profile state — lifted here so it survives tab switches.
// userId + adminToken allow the panel to load/save named profiles.
const [currentWeights, setCurrentWeights] = useState<
@@ -537,7 +541,11 @@ function SiteFinderContent() {
gap: 12,
}}
>
-
+
>
diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
new file mode 100644
index 00000000..1e25173a
--- /dev/null
+++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { CircleMarker, Popup, LayerGroup } from "react-leaflet";
+
+import type { EngineeringStructure } from "@/types/nspd";
+
+// ---------------------------------------------------------------------------
+// Category classification
+// ---------------------------------------------------------------------------
+
+export type CpCategory =
+ | "electricity"
+ | "gas"
+ | "water"
+ | "heat"
+ | "sewage"
+ | "telecom"
+ | "other";
+
+export interface CpCategoryStyle {
+ color: string;
+ label: string;
+ radius: number;
+}
+
+export const CP_CATEGORY_STYLES: Record = {
+ electricity: { color: "#f59e0b", label: "Электричество", radius: 9 },
+ gas: { color: "#3b82f6", label: "Газ", radius: 9 },
+ water: { color: "#06b6d4", label: "Вода", radius: 8 },
+ heat: { color: "#ef4444", label: "Теплоснабжение", radius: 8 },
+ sewage: { color: "#8b5cf6", label: "Канализация", radius: 8 },
+ telecom: { color: "#10b981", label: "Связь", radius: 7 },
+ other: { color: "#6b7280", label: "Другое", radius: 7 },
+};
+
+export const CP_ALL_CATEGORIES = Object.keys(
+ CP_CATEGORY_STYLES,
+) as CpCategory[];
+
+// Keywords to match against `type` and `name` fields (case-insensitive)
+const CATEGORY_KEYWORDS: Array<{ cat: CpCategory; keywords: string[] }> = [
+ {
+ cat: "electricity",
+ keywords: [
+ "трансформатор",
+ "тп-",
+ "ктп",
+ "подстанция",
+ "электр",
+ "tp-",
+ "лэп",
+ "36328",
+ ],
+ },
+ {
+ cat: "gas",
+ keywords: ["газ", "гтс", "газоп", "газорегул", "газопровод"],
+ },
+ {
+ cat: "water",
+ keywords: ["водо", "водопр", "насосн", "колодец", "скважин"],
+ },
+ {
+ cat: "heat",
+ keywords: ["тепло", "теплос", "котельн", "тэц"],
+ },
+ {
+ cat: "sewage",
+ keywords: ["канал", "сток", "ливнев", "кнс", "канализ"],
+ },
+ {
+ cat: "telecom",
+ keywords: ["связ", "телеком", "интернет", "оптик", "вышка"],
+ },
+];
+
+export function classifyStructure(s: EngineeringStructure): CpCategory {
+ const haystack =
+ `${s.name ?? ""} ${s.type ?? ""} ${s.source ?? ""}`.toLowerCase();
+ for (const { cat, keywords } of CATEGORY_KEYWORDS) {
+ if (keywords.some((kw) => haystack.includes(kw))) return cat;
+ }
+ return "other";
+}
+
+// ---------------------------------------------------------------------------
+// Group helper (exported so SiteMap can build counts for the control panel)
+// ---------------------------------------------------------------------------
+
+export function groupStructuresByCategory(
+ structures: EngineeringStructure[],
+): Map {
+ const grouped = new Map();
+ for (const cat of CP_ALL_CATEGORIES) {
+ grouped.set(cat, []);
+ }
+ for (const s of structures) {
+ const cat = classifyStructure(s);
+ grouped.get(cat)!.push(s);
+ }
+ return grouped;
+}
+
+// ---------------------------------------------------------------------------
+// Geometry helpers
+// ---------------------------------------------------------------------------
+
+function extractLatLon(
+ geojson: Record,
+): [number, number] | null {
+ if (geojson.type === "Point") {
+ const coords = geojson.coordinates as number[] | undefined;
+ if (coords && coords.length >= 2) {
+ // GeoJSON: [lon, lat]
+ return [coords[1], coords[0]];
+ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Map layer — renders CircleMarkers inside the Leaflet MapContainer
+// ---------------------------------------------------------------------------
+
+interface LayerProps {
+ visibleCategories: Set;
+ grouped: Map;
+}
+
+export function ConnectionPointsLayer({
+ visibleCategories,
+ grouped,
+}: LayerProps) {
+ return (
+ <>
+ {CP_ALL_CATEGORIES.map((cat) => {
+ if (!visibleCategories.has(cat)) return null;
+ const structs = grouped.get(cat) ?? [];
+ const style = CP_CATEGORY_STYLES[cat];
+
+ return (
+
+ {structs.map((s, idx) => {
+ const latLon = extractLatLon(s.geometry_geojson);
+ if (!latLon) return null;
+
+ return (
+
+
+
+
+
+ {style.label}
+
+
+ {s.name ?? s.type ?? "Объект"}
+
+ {s.type && s.name && (
+
+ {s.type}
+
+ )}
+ {s.readable_address && (
+
+ {s.readable_address}
+
+ )}
+
+ До границы:{" "}
+
+ {Math.round(s.distance_to_boundary_m)} м
+
+
+
+ Источник: {s.source}
+
+
+
+
+ );
+ })}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
new file mode 100644
index 00000000..df16d14b
--- /dev/null
+++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useState } from "react";
+
+import type {
+ ConnectionPointsResponse,
+ EngineeringStructure,
+} from "@/types/nspd";
+import {
+ CP_ALL_CATEGORIES,
+ CP_CATEGORY_STYLES,
+ type CpCategory,
+} from "@/components/site-finder/ConnectionPointsLayer";
+
+interface Props {
+ data: ConnectionPointsResponse;
+ grouped: Map;
+ visibleCategories: Set;
+ onToggleCategory: (cat: CpCategory) => void;
+ onToggleAll: () => void;
+}
+
+export function CpLayerControlPanel({
+ data,
+ grouped,
+ visibleCategories,
+ onToggleCategory,
+ onToggleAll,
+}: Props) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ const totalCount = data.engineering_structures.length;
+ const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length;
+
+ return (
+
+ {/* Header */}
+
setCollapsed((v) => !v)}
+ >
+
+ Точки подключения
+
+
+ {totalCount} шт {collapsed ? "▲" : "▼"}
+
+
+
+ {!collapsed && (
+
+ {/* No dump */}
+ {!data.dump_available && (
+
+ Дамп квартала не загружен — 0 точек подключения
+
+ )}
+
+ {/* Empty state */}
+ {data.dump_available && totalCount === 0 && (
+
+ 0 точек подключения в этом квартале
+
+ )}
+
+ {/* Toggle-all */}
+ {totalCount > 0 && (
+
+ )}
+
+ {/* Per-category */}
+
+ {CP_ALL_CATEGORIES.map((cat) => {
+ const structs = grouped.get(cat) ?? [];
+ const style = CP_CATEGORY_STYLES[cat];
+ if (structs.length === 0) return null;
+ const active = visibleCategories.has(cat);
+ return (
+
+ );
+ })}
+
+
+ {/* Summary */}
+ {data.dump_available && totalCount > 0 && (
+
+ {data.summary.nearest_structure_distance_m !== null && (
+
+ Ближайший:{" "}
+ {Math.round(data.summary.nearest_structure_distance_m)} м
+
+ )}
+ {data.summary.in_protection_zone && (
+
+ В охранной зоне
+
+ )}
+ {data.summary.protection_zones_intersecting > 0 &&
+ !data.summary.in_protection_zone && (
+
+ Охранных зон: {data.summary.protection_zones_intersecting}
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx
index 9c007246..5b81b8d1 100644
--- a/frontend/src/components/site-finder/SiteMap.tsx
+++ b/frontend/src/components/site-finder/SiteMap.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import {
MapContainer,
TileLayer,
@@ -12,6 +12,17 @@ import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
import type { ParcelAnalysis } from "@/types/site-finder";
+import type {
+ ConnectionPointsResponse,
+ EngineeringStructure,
+} from "@/types/nspd";
+import {
+ ConnectionPointsLayer,
+ CP_ALL_CATEGORIES,
+ groupStructuresByCategory,
+ type CpCategory,
+} from "@/components/site-finder/ConnectionPointsLayer";
+import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
// ---------------------------------------------------------------------------
// POI legend config (for the legend row below the map)
@@ -83,9 +94,10 @@ function geomCenter(geom: Geometry): [number, number] {
interface Props {
data: ParcelAnalysis;
isochrones?: FeatureCollection;
+ connectionPoints?: ConnectionPointsResponse;
}
-export function SiteMap({ data, isochrones }: Props) {
+export function SiteMap({ data, isochrones, connectionPoints }: Props) {
// Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => {
void import("leaflet").then((L) => {
@@ -101,6 +113,31 @@ export function SiteMap({ data, isochrones }: Props) {
});
}, []);
+ // Connection-points layer toggle state (all categories visible by default)
+ const [visibleCategories, setVisibleCategories] = useState>(
+ new Set(CP_ALL_CATEGORIES),
+ );
+
+ 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),
+ );
+ }
+
const center: [number, number] = data.geom_geojson
? geomCenter(data.geom_geojson)
: [56.838, 60.6];
@@ -108,6 +145,11 @@ export function SiteMap({ data, isochrones }: Props) {
// 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();
+
return (
{/* Map */}
@@ -222,6 +264,14 @@ export function SiteMap({ data, isochrones }: Props) {
));
})}
+
+ {/* Connection points layer — rendered on top of POI markers */}
+ {connectionPoints && (
+
+ )}
@@ -278,6 +328,17 @@ export function SiteMap({ data, isochrones }: Props) {
Геометрия участка не найдена — на карте нет полигона
)}
+
+ {/* Connection points layer control panel — below the map */}
+ {connectionPoints && (
+
+ )}
);
}