Merge pull request 'feat(#115): Leaflet layer toggle для connection points (Макс KILLER)' (#230) from feat/connection-points-leaflet-layer into main
This commit is contained in:
commit
56bf28758c
8 changed files with 742 additions and 4 deletions
|
|
@ -26,6 +26,7 @@ from app.services.site_finder.weight_profiles import (
|
||||||
delete_profile,
|
delete_profile,
|
||||||
get_profile,
|
get_profile,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
|
list_profiles_with_system,
|
||||||
update_profile,
|
update_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,8 +38,18 @@ def list_user_profiles(
|
||||||
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
|
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
_: AdminTokenAuth,
|
_: AdminTokenAuth,
|
||||||
|
include_system: Annotated[
|
||||||
|
bool,
|
||||||
|
Query(description="Включить системные preset-профили (Эконом/Комфорт/Бизнес)"),
|
||||||
|
] = False,
|
||||||
) -> Any:
|
) -> 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)
|
return list_profiles(db, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ from sqlalchemy import text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 POI categories — single source of truth; imported by api/v1/parcels.py
|
||||||
ALLOWED_CATEGORIES: set[str] = {
|
ALLOWED_CATEGORIES: set[str] = {
|
||||||
"school",
|
"school",
|
||||||
|
|
@ -74,6 +78,17 @@ _SELECT_BY_USER = f"""
|
||||||
ORDER BY is_default DESC, id ASC
|
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_BY_ID = f"""
|
||||||
SELECT {_SELECT_COLS}
|
SELECT {_SELECT_COLS}
|
||||||
FROM user_weight_profiles
|
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]
|
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:
|
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
|
||||||
"""Вернуть профиль по id (scoped к пользователю)."""
|
"""Вернуть профиль по id (scoped к пользователю)."""
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,93 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
||||||
_clear_overrides()
|
_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 ───────────────────────────────────────────────────────────────────────
|
# ── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
90
data/sql/100_user_weight_profiles_default_seed.sql
Normal file
90
data/sql/100_user_weight_profiles_default_seed.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -16,6 +16,7 @@ import { MarketTab } from "@/components/site-finder/MarketTab";
|
||||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
import {
|
import {
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
type PoiCategoryKey,
|
type PoiCategoryKey,
|
||||||
|
|
@ -98,6 +99,9 @@ function SiteFinderContent() {
|
||||||
undefined,
|
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.
|
// Weight profile state — lifted here so it survives tab switches.
|
||||||
// userId + adminToken allow the panel to load/save named profiles.
|
// userId + adminToken allow the panel to load/save named profiles.
|
||||||
const [currentWeights, setCurrentWeights] = useState<
|
const [currentWeights, setCurrentWeights] = useState<
|
||||||
|
|
@ -537,7 +541,11 @@ function SiteFinderContent() {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SiteMap data={data} isochrones={isochrones} />
|
<SiteMap
|
||||||
|
data={data}
|
||||||
|
isochrones={isochrones}
|
||||||
|
connectionPoints={connectionPoints}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
MapContainer,
|
MapContainer,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
|
|
@ -12,6 +12,17 @@ import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
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)
|
// POI legend config (for the legend row below the map)
|
||||||
|
|
@ -83,9 +94,10 @@ function geomCenter(geom: Geometry): [number, number] {
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
isochrones?: FeatureCollection;
|
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
|
// Fix Leaflet default icon paths broken by webpack bundler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("leaflet").then((L) => {
|
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
|
const center: [number, number] = data.geom_geojson
|
||||||
? geomCenter(data.geom_geojson)
|
? geomCenter(data.geom_geojson)
|
||||||
: [56.838, 60.6];
|
: [56.838, 60.6];
|
||||||
|
|
@ -108,6 +145,11 @@ export function SiteMap({ data, isochrones }: Props) {
|
||||||
// Build legend from categories present in score_breakdown
|
// Build legend from categories present in score_breakdown
|
||||||
const presentCategories = Object.keys(data.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
|
|
@ -222,6 +264,14 @@ export function SiteMap({ data, isochrones }: Props) {
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
));
|
));
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Connection points layer — rendered on top of POI markers */}
|
||||||
|
{connectionPoints && (
|
||||||
|
<ConnectionPointsLayer
|
||||||
|
grouped={cpGrouped}
|
||||||
|
visibleCategories={visibleCategories}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,6 +328,17 @@ export function SiteMap({ data, isochrones }: Props) {
|
||||||
Геометрия участка не найдена — на карте нет полигона
|
Геометрия участка не найдена — на карте нет полигона
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connection points layer control panel — below the map */}
|
||||||
|
{connectionPoints && (
|
||||||
|
<CpLayerControlPanel
|
||||||
|
data={connectionPoints}
|
||||||
|
grouped={cpGrouped}
|
||||||
|
visibleCategories={visibleCategories}
|
||||||
|
onToggleCategory={toggleCategory}
|
||||||
|
onToggleAll={toggleAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue