- 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 или пустом ответе
224 lines
7.1 KiB
TypeScript
224 lines
7.1 KiB
TypeScript
"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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|