From a6e4ff0407918106acdc1e24a918d3ed6db9abf1 Mon Sep 17 00:00:00 2001
From: lekss361
Date: Sat, 16 May 2026 22:49:56 +0300
Subject: [PATCH 1/2] feat(#114): seed 3 default weight presets +
include_system API param
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add SQL migration 100_user_weight_profiles_default_seed.sql with system
presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is
idempotent via ON CONFLICT DO UPDATE.
Backend:
- weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system()
- admin_weight_profiles.py: add include_system query param to GET list endpoint
Tests: 3 new tests covering include_system flag and service sentinel behaviour.
---
backend/app/api/v1/admin_weight_profiles.py | 13 ++-
.../services/site_finder/weight_profiles.py | 33 +++++++
backend/tests/test_admin_weight_profiles.py | 87 ++++++++++++++++++
.../100_user_weight_profiles_default_seed.sql | 90 +++++++++++++++++++
4 files changed, 222 insertions(+), 1 deletion(-)
create mode 100644 data/sql/100_user_weight_profiles_default_seed.sql
diff --git a/backend/app/api/v1/admin_weight_profiles.py b/backend/app/api/v1/admin_weight_profiles.py
index 96b6d9b3..fc187596 100644
--- a/backend/app/api/v1/admin_weight_profiles.py
+++ b/backend/app/api/v1/admin_weight_profiles.py
@@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import (
delete_profile,
get_profile,
list_profiles,
+ list_profiles_with_system,
update_profile,
)
@@ -37,8 +38,18 @@ def list_user_profiles(
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
db: Annotated[Session, Depends(get_db)],
_: AdminTokenAuth,
+ include_system: Annotated[
+ bool,
+ Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"),
+ ] = False,
) -> Any:
- """Список всех weight profiles для заданного user_id. Default-профиль первым."""
+ """Список всех weight profiles для заданного user_id. Default-профиль первым.
+
+ При include_system=true добавляются системные presets (user_id='__system__'):
+ Эконом, Комфорт, Бизнес — готовые шаблоны весов POI.
+ """
+ if include_system:
+ return list_profiles_with_system(db, user_id)
return list_profiles(db, user_id)
diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py
index 12259896..08a253d1 100644
--- a/backend/app/services/site_finder/weight_profiles.py
+++ b/backend/app/services/site_finder/weight_profiles.py
@@ -26,6 +26,10 @@ from sqlalchemy import text
logger = logging.getLogger(__name__)
+# Sentinel user_id для системных preset-профилей (не привязаны к реальному пользователю).
+# Seed: data/sql/100_user_weight_profiles_default_seed.sql
+SYSTEM_USER_ID: str = "__system__"
+
# Allowed POI categories — single source of truth; imported by api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
@@ -74,6 +78,17 @@ _SELECT_BY_USER = f"""
ORDER BY is_default DESC, id ASC
"""
+_SELECT_BY_USER_WITH_SYSTEM = f"""
+ SELECT {_SELECT_COLS}
+ FROM user_weight_profiles
+ WHERE user_id = :user_id
+ OR user_id = :system_user_id
+ ORDER BY
+ CASE WHEN user_id = :system_user_id THEN 1 ELSE 0 END ASC,
+ is_default DESC,
+ id ASC
+"""
+
_SELECT_BY_ID = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
@@ -191,6 +206,24 @@ def list_profiles(db: Any, user_id: str) -> list[WeightProfile]:
return [_row_to_profile(r) for r in rows]
+def list_profiles_with_system(db: Any, user_id: str) -> list[WeightProfile]:
+ """Вернуть профили пользователя + системные preset-профили.
+
+ Пользовательские профили идут первыми (default сверху), затем системные
+ presets (Эконом, Комфорт, Бизнес). Предназначен для endpoint с
+ include_system=true — UI dropdown видит и пользовательские, и preset.
+ """
+ rows = (
+ db.execute(
+ text(_SELECT_BY_USER_WITH_SYSTEM),
+ {"user_id": user_id, "system_user_id": SYSTEM_USER_ID},
+ )
+ .mappings()
+ .all()
+ )
+ return [_row_to_profile(r) for r in rows]
+
+
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
"""Вернуть профиль по id (scoped к пользователю)."""
row = (
diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py
index 01caa3ed..c3bad23a 100644
--- a/backend/tests/test_admin_weight_profiles.py
+++ b/backend/tests/test_admin_weight_profiles.py
@@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
_clear_overrides()
+# ── include_system preset seed (Issue #114 / PR #229) ─────────────────────────
+
+
+def test_list_include_system_calls_with_system(
+ client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
+ system_profile = _make_profile(
+ 100, user_id="__system__", profile_name="Комфорт", weights={"park": 1.8, "school": 1.5}
+ )
+ user_profile = _make_profile(1, user_id="user-1", profile_name="Мой профиль")
+ mock = MagicMock()
+ _override_db(mock)
+ try:
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles_with_system",
+ lambda db, user_id: [user_profile, system_profile],
+ )
+ r = client_with_token.get(
+ "/api/v1/admin/site-finder/weight-profiles",
+ params={"user_id": "user-1", "include_system": "true"},
+ headers=_HEADERS,
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body) == 2
+ profile_names = {p["profile_name"] for p in body}
+ assert "Комфорт" in profile_names
+ assert "Мой профиль" in profile_names
+ finally:
+ _clear_overrides()
+
+
+def test_list_without_include_system_does_not_call_with_system(
+ client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """GET без include_system → list_profiles (только пользовательские профили)."""
+ user_profile = _make_profile(1, user_id="user-1")
+ called_with_system = []
+ mock = MagicMock()
+ _override_db(mock)
+ try:
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles",
+ lambda db, user_id: [user_profile],
+ )
+ monkeypatch.setattr(
+ "app.api.v1.admin_weight_profiles.list_profiles_with_system",
+ lambda db, user_id: called_with_system.append(True) or [],
+ )
+ r = client_with_token.get(
+ "/api/v1/admin/site-finder/weight-profiles",
+ params={"user_id": "user-1"},
+ headers=_HEADERS,
+ )
+ assert r.status_code == 200
+ assert len(r.json()) == 1
+ # list_profiles_with_system НЕ должен вызываться без include_system=true
+ assert called_with_system == [], "вызван list_profiles_with_system без флага"
+ finally:
+ _clear_overrides()
+
+
+def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> None:
+ """list_profiles_with_system передаёт system_user_id='__system__' в запрос."""
+ from app.services.site_finder.weight_profiles import SYSTEM_USER_ID, list_profiles_with_system
+
+ captured_params: list[dict] = []
+
+ class FakeResult:
+ def mappings(self) -> FakeResult:
+ return self
+
+ def all(self) -> list:
+ return []
+
+ class FakeDb:
+ def execute(self, query: object, params: dict) -> FakeResult:
+ captured_params.append(params)
+ return FakeResult()
+
+ list_profiles_with_system(FakeDb(), user_id="user-test")
+ assert len(captured_params) == 1
+ assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID
+ assert captured_params[0]["user_id"] == "user-test"
+
+
# ── Auth ───────────────────────────────────────────────────────────────────────
diff --git a/data/sql/100_user_weight_profiles_default_seed.sql b/data/sql/100_user_weight_profiles_default_seed.sql
new file mode 100644
index 00000000..546061cf
--- /dev/null
+++ b/data/sql/100_user_weight_profiles_default_seed.sql
@@ -0,0 +1,90 @@
+-- 100_user_weight_profiles_default_seed.sql
+-- Системные preset-профили весов POI для Site Finder.
+-- Per #114 (Макс feedback): садики и Мегамарт должны иметь разные веса.
+--
+-- Apply order: после 90_user_weight_profiles.sql (таблица уже существует)
+-- Dependencies: user_weight_profiles table
+--
+-- Sentinel owner: '__system__' — не является реальным user_id,
+-- используется только для системных presets.
+-- UI: dropdown показывает system presets всем пользователям через
+-- GET /api/v1/admin/site-finder/weight-profiles?user_id=__system__
+-- или include_system=true (добавлен в #114 seed PR).
+--
+-- Idempotent: ON CONFLICT (user_id, profile_name) DO UPDATE — обновляет
+-- веса если preset уже существует (safe re-apply на prod).
+
+BEGIN;
+
+INSERT INTO user_weight_profiles
+ (user_id, profile_name, weights, is_default, description)
+VALUES
+ (
+ '__system__',
+ 'Эконом',
+ '{
+ "school": 1.2,
+ "kindergarten": 1.0,
+ "pharmacy": 0.8,
+ "hospital": 0.5,
+ "shop_mall": 0.8,
+ "shop_supermarket": 1.5,
+ "shop_small": 1.0,
+ "park": 1.2,
+ "bus_stop": 1.0,
+ "metro_stop": 0.8,
+ "tram_stop": 0.5
+ }'::jsonb,
+ FALSE,
+ 'Эконом-класс: доступность магазинов и транспорта важнее премиальной инфраструктуры'
+ ),
+ (
+ '__system__',
+ 'Комфорт',
+ '{
+ "school": 1.5,
+ "kindergarten": 1.8,
+ "pharmacy": 0.8,
+ "hospital": 0.6,
+ "shop_mall": 1.0,
+ "shop_supermarket": 1.2,
+ "shop_small": 0.5,
+ "park": 1.8,
+ "bus_stop": 0.5,
+ "metro_stop": 1.2,
+ "tram_stop": -0.3
+ }'::jsonb,
+ FALSE,
+ 'Комфорт-класс: парки, детсады и школы в приоритете — семейная аудитория'
+ ),
+ (
+ '__system__',
+ 'Бизнес',
+ '{
+ "school": 1.0,
+ "kindergarten": 0.8,
+ "pharmacy": 0.6,
+ "hospital": 0.5,
+ "shop_mall": 2.0,
+ "shop_supermarket": 0.8,
+ "shop_small": 0.3,
+ "park": 2.0,
+ "bus_stop": 0.2,
+ "metro_stop": 2.5,
+ "tram_stop": -0.5
+ }'::jsonb,
+ FALSE,
+ 'Бизнес-класс: метро, ТЦ и парки в приоритете — платёжеспособная аудитория'
+ )
+ON CONFLICT (user_id, profile_name)
+DO UPDATE SET
+ weights = EXCLUDED.weights,
+ description = EXCLUDED.description,
+ updated_at = NOW();
+
+COMMENT ON TABLE user_weight_profiles IS
+ 'POI weight profiles для site_finder. Per #114 (Макс feedback). '
+ 'weights = {"school": 1.5, "kindergarten": 1.5, "shop_mall": 1.2, ...}. '
+ 'Системные presets: user_id = ''__system__'' (3 preset: Эконом/Комфорт/Бизнес).';
+
+COMMIT;
From a7671513c9e70929a1928cc5f41deb979a5153bc Mon Sep 17 00:00:00 2001
From: lekss361
Date: Sat, 16 May 2026 22:51:12 +0300
Subject: [PATCH 2/2] =?UTF-8?q?feat(#115):=20Leaflet=20layer=20toggle=20?=
=?UTF-8?q?=D0=B4=D0=BB=D1=8F=20connection=20points=20(=D0=9C=D0=B0=D0=BA?=
=?UTF-8?q?=D1=81=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 && (
+
+ )}
);
}