gendesign/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
lekss361 a7671513c9 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 или пустом ответе
2026-05-16 22:51:12 +03:00

224 lines
7.1 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 { 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>
);
})}
</>
);
}