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:
parent
a6e4ff0407
commit
a7671513c9
4 changed files with 520 additions and 3 deletions
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
224
frontend/src/components/site-finder/ConnectionPointsLayer.tsx
Normal file
224
frontend/src/components/site-finder/ConnectionPointsLayer.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/site-finder/CpLayerControlPanel.tsx
Normal file
224
frontend/src/components/site-finder/CpLayerControlPanel.tsx
Normal 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} шт {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue