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 && ( + + )} ); }