feat(#115): Leaflet layer toggle для connection points (Макс KILLER)

- 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 или пустом ответе
This commit is contained in:
lekss361 2026-05-16 22:51:12 +03:00
parent a6e4ff0407
commit a7671513c9
4 changed files with 520 additions and 3 deletions

View file

@ -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,
}}
>
<SiteMap data={data} isochrones={isochrones} />
<SiteMap
data={data}
isochrones={isochrones}
connectionPoints={connectionPoints}
/>
</aside>
</div>
</>

View file

@ -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<CpCategory, CpCategoryStyle> = {
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<CpCategory, EngineeringStructure[]> {
const grouped = new Map<CpCategory, EngineeringStructure[]>();
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<string, unknown>,
): [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<CpCategory>;
grouped: Map<CpCategory, EngineeringStructure[]>;
}
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 (
<LayerGroup key={cat}>
{structs.map((s, idx) => {
const latLon = extractLatLon(s.geometry_geojson);
if (!latLon) return null;
return (
<CircleMarker
key={`cp-${cat}-${idx}`}
center={latLon}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.9,
weight: 2,
}}
>
<Popup>
<div
style={{ fontSize: 12, lineHeight: 1.55, minWidth: 180 }}
>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
</div>
<div style={{ marginBottom: 2 }}>
<strong>{s.name ?? s.type ?? "Объект"}</strong>
</div>
{s.type && s.name && (
<div style={{ color: "#6b7280", marginBottom: 2 }}>
{s.type}
</div>
)}
{s.readable_address && (
<div style={{ color: "#374151", marginBottom: 2 }}>
{s.readable_address}
</div>
)}
<div style={{ marginTop: 4, color: "#374151" }}>
До границы:{" "}
<strong>
{Math.round(s.distance_to_boundary_m)} м
</strong>
</div>
<div
style={{
marginTop: 4,
fontSize: 11,
color: "#9ca3af",
borderTop: "1px solid #f3f4f6",
paddingTop: 4,
}}
>
Источник: {s.source}
</div>
</div>
</Popup>
</CircleMarker>
);
})}
</LayerGroup>
);
})}
</>
);
}

View file

@ -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<CpCategory, EngineeringStructure[]>;
visibleCategories: Set<CpCategory>;
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 (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
marginTop: 10,
fontSize: 12,
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
cursor: "pointer",
userSelect: "none",
borderBottom: collapsed ? "none" : "1px solid #f3f4f6",
}}
onClick={() => setCollapsed((v) => !v)}
>
<span style={{ fontWeight: 700, color: "#1f2937" }}>
Точки подключения
</span>
<span style={{ color: "#6b7280", fontSize: 11 }}>
{totalCount} шт&nbsp;{collapsed ? "▲" : "▼"}
</span>
</div>
{!collapsed && (
<div style={{ padding: "8px 12px 10px" }}>
{/* No dump */}
{!data.dump_available && (
<div
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
marginBottom: 8,
}}
>
Дамп квартала не загружен 0 точек подключения
</div>
)}
{/* Empty state */}
{data.dump_available && totalCount === 0 && (
<div style={{ color: "#6b7280", fontSize: 11, marginBottom: 6 }}>
0 точек подключения в этом квартале
</div>
)}
{/* Toggle-all */}
{totalCount > 0 && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 6,
cursor: "pointer",
fontWeight: 600,
color: "#374151",
}}
>
<input
type="checkbox"
checked={allVisible}
onChange={onToggleAll}
style={{ cursor: "pointer" }}
/>
Показать все ({totalCount})
</label>
)}
{/* Per-category */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px 14px",
}}
>
{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 (
<label
key={cat}
style={{
display: "flex",
alignItems: "center",
gap: 5,
cursor: "pointer",
color: "#374151",
opacity: active ? 1 : 0.45,
transition: "opacity 0.15s",
}}
>
<input
type="checkbox"
checked={active}
onChange={() => onToggleCategory(cat)}
style={{ cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: style.color,
flexShrink: 0,
}}
/>
{style.label}
<span style={{ color: "#9ca3af" }}>{structs.length}</span>
</label>
);
})}
</div>
{/* Summary */}
{data.dump_available && totalCount > 0 && (
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid #f3f4f6",
display: "flex",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
}}
>
{data.summary.nearest_structure_distance_m !== null && (
<span
style={{
background: "#f3f4f6",
color: "#374151",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
}}
>
Ближайший:{" "}
{Math.round(data.summary.nearest_structure_distance_m)} м
</span>
)}
{data.summary.in_protection_zone && (
<span
style={{
background: "#fee2e2",
color: "#991b1b",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
fontWeight: 600,
}}
>
В охранной зоне
</span>
)}
{data.summary.protection_zones_intersecting > 0 &&
!data.summary.in_protection_zone && (
<span
style={{
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "2px 8px",
fontSize: 11,
}}
>
Охранных зон: {data.summary.protection_zones_intersecting}
</span>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -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<Set<CpCategory>>(
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<CpCategory, EngineeringStructure[]>();
return (
<div>
{/* Map */}
@ -222,6 +264,14 @@ export function SiteMap({ data, isochrones }: Props) {
</CircleMarker>
));
})}
{/* Connection points layer — rendered on top of POI markers */}
{connectionPoints && (
<ConnectionPointsLayer
grouped={cpGrouped}
visibleCategories={visibleCategories}
/>
)}
</MapContainer>
</div>
@ -278,6 +328,17 @@ export function SiteMap({ data, isochrones }: Props) {
Геометрия участка не найдена на карте нет полигона
</p>
)}
{/* Connection points layer control panel — below the map */}
{connectionPoints && (
<CpLayerControlPanel
data={connectionPoints}
grouped={cpGrouped}
visibleCategories={visibleCategories}
onToggleCategory={toggleCategory}
onToggleAll={toggleAll}
/>
)}
</div>
);
}