feat(nspd): TIER 3 risk zones (#94 PR1 of 4) #217
9 changed files with 545 additions and 3 deletions
|
|
@ -24,6 +24,7 @@ from app.schemas.parcel import (
|
|||
ParcelDetail,
|
||||
ParcelSearchRequest,
|
||||
ParcelSearchResponse,
|
||||
RiskZone,
|
||||
)
|
||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||
from app.services.site_finder.best_layouts import get_best_layouts
|
||||
|
|
@ -2063,10 +2064,12 @@ def analyze_parcel(
|
|||
# nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1)
|
||||
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
|
||||
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
|
||||
# nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
|
||||
# nspd_dump: freshness metadata — available, stale, harvest_triggered
|
||||
"nspd_zoning": nspd_dump_data["nspd_zoning"],
|
||||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||||
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
||||
"nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
|
||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||
"gate_verdict": compute_gate_verdict(
|
||||
|
|
|
|||
|
|
@ -68,6 +68,18 @@ class ConnectionPointsResponse(BaseModel):
|
|||
dump_fetched_at: str | None
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
||||
|
||||
class RiskZone(BaseModel):
|
||||
"""Одна риск-зона НСПД (TIER 3), пересекающая участок."""
|
||||
|
||||
layer: str # e.g. "risk_flooding", "risk_landslide"
|
||||
subtype: str | None # человекочитаемое название из NSPD properties
|
||||
geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326
|
||||
intersection_area_sqm: float | None # площадь пересечения с участком, м²
|
||||
|
||||
|
||||
# ── Parcel search/detail schemas ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ EMPTY_DUMP_RESULT: dict[str, Any] = {
|
|||
"nspd_zoning": None,
|
||||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_dump": {
|
||||
"available": False,
|
||||
"fetched_at_utc": None,
|
||||
|
|
@ -62,6 +64,7 @@ def make_empty_result(
|
|||
"nspd_zoning": None,
|
||||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_dump": {
|
||||
"available": False,
|
||||
"fetched_at_utc": fetched_at_utc,
|
||||
|
|
@ -124,7 +127,8 @@ def get_quarter_dump_data(
|
|||
harvest_error,
|
||||
territorial_zones_count,
|
||||
zouit_count,
|
||||
engineering_count
|
||||
engineering_count,
|
||||
risks_count
|
||||
FROM nspd_quarter_dumps
|
||||
WHERE quarter_cad = :q
|
||||
"""
|
||||
|
|
@ -150,6 +154,7 @@ def get_quarter_dump_data(
|
|||
territorial_zones_count: int = row[4] or 0
|
||||
zouit_count: int = row[5] or 0
|
||||
engineering_count: int = row[6] or 0
|
||||
risks_count: int = row[7] or 0
|
||||
|
||||
is_stale = (now - fetched_at) > max_age
|
||||
has_error = harvest_error is not None
|
||||
|
|
@ -179,6 +184,7 @@ def get_quarter_dump_data(
|
|||
"nspd_zoning": None,
|
||||
"nspd_zouit_overlaps": [],
|
||||
"nspd_engineering_nearby": [],
|
||||
"nspd_risk_zones": [],
|
||||
"nspd_dump": dump_meta,
|
||||
}
|
||||
|
||||
|
|
@ -186,15 +192,18 @@ def get_quarter_dump_data(
|
|||
"territorial_zones_count": territorial_zones_count,
|
||||
"zouit_count": zouit_count,
|
||||
"engineering_count": engineering_count,
|
||||
"risks_count": risks_count,
|
||||
}
|
||||
nspd_zoning = _get_zoning(db, quarter, parcel_wkt, layer_counts)
|
||||
nspd_zouit = _get_zouit_overlaps(db, quarter, parcel_wkt, layer_counts)
|
||||
nspd_engineering = _get_engineering_nearby(db, quarter, parcel_wkt, layer_counts)
|
||||
nspd_risk_zones = _get_risk_zones(db, quarter, parcel_wkt, layer_counts)
|
||||
|
||||
return {
|
||||
"nspd_zoning": nspd_zoning,
|
||||
"nspd_zouit_overlaps": nspd_zouit,
|
||||
"nspd_engineering_nearby": nspd_engineering,
|
||||
"nspd_risk_zones": nspd_risk_zones,
|
||||
"nspd_dump": dump_meta,
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +403,152 @@ def _get_engineering_nearby(
|
|||
return []
|
||||
|
||||
|
||||
# ── Risk zones (issue #94 TIER 3) ────────────────────────────────────────────
|
||||
|
||||
# Human-readable subtype labels for risk layer keys.
|
||||
# Keys match NSPDClient.QUARTER_RISK_LAYERS short_names.
|
||||
_RISK_SUBTYPE_LABELS: dict[str, str] = {
|
||||
"flooding_underground": "Подтопление",
|
||||
"flooding": "Затопление",
|
||||
"swampification": "Заболачивание",
|
||||
"landslide": "Обвально-осыпные процессы",
|
||||
"abrasion": "Абразия",
|
||||
"erosion_water": "Водная эрозия",
|
||||
"erosion_linear": "Линейная эрозия",
|
||||
"erosion_wind": "Ветровая эрозия",
|
||||
"desertification": "Опустынивание",
|
||||
"clutter": "Захламление",
|
||||
"burns": "Гари",
|
||||
}
|
||||
|
||||
|
||||
def _extract_features_by_layer(
|
||||
features: list[dict[str, Any]],
|
||||
layer_prefix: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generic filter: вернуть features у которых layer начинается с layer_prefix.
|
||||
|
||||
Предназначен для re-use в PR 2+ (opportunity layers, etc.).
|
||||
|
||||
Args:
|
||||
features: list of feature dicts из features_json (уже загруженных в Python).
|
||||
layer_prefix: префикс для фильтрации, e.g. 'risk_', 'zouit_'.
|
||||
|
||||
Returns:
|
||||
Подсписок features где feat['layer'].startswith(layer_prefix).
|
||||
"""
|
||||
return [
|
||||
f
|
||||
for f in features
|
||||
if isinstance(f.get("layer"), str) and f["layer"].startswith(layer_prefix)
|
||||
]
|
||||
|
||||
|
||||
def _get_risk_zones(
|
||||
db: Session,
|
||||
quarter: str,
|
||||
parcel_wkt: str,
|
||||
layer_counts: dict[str, int] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""TIER 3: риск-зоны НСПД (11 слоёв), пересекающие участок.
|
||||
|
||||
Возвращает список риск-зон с полями:
|
||||
layer, subtype, geom_wkt, intersection_area_sqm.
|
||||
|
||||
layer_counts — денормализованные счётчики. Если risks_count == 0 —
|
||||
пропускаем heavy jsonb_array_elements scan.
|
||||
"""
|
||||
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
|
||||
return []
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT feat.value->>'layer' AS layer,
|
||||
feat.value->'properties' AS props,
|
||||
ST_AsText(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
)
|
||||
) AS geom_wkt,
|
||||
ST_Area(
|
||||
ST_Intersection(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
ST_GeomFromText(:wkt, 4326)::geography
|
||||
)
|
||||
) AS intersection_area_sqm
|
||||
FROM nspd_quarter_dumps d,
|
||||
jsonb_array_elements(d.features_json) AS feat(value)
|
||||
WHERE d.quarter_cad = :q
|
||||
AND feat.value->>'layer' LIKE 'risk_%'
|
||||
AND (feat.value->'geometry') IS NOT NULL
|
||||
AND feat.value->>'geometry' != 'null'
|
||||
AND ST_Intersects(
|
||||
ST_Transform(
|
||||
ST_SetSRID(
|
||||
ST_GeomFromGeoJSON(feat.value->>'geometry'),
|
||||
3857
|
||||
),
|
||||
4326
|
||||
),
|
||||
ST_GeomFromText(:wkt, 4326)
|
||||
)
|
||||
ORDER BY layer, intersection_area_sqm DESC NULLS LAST
|
||||
"""
|
||||
),
|
||||
{"q": quarter, "wkt": parcel_wkt},
|
||||
).fetchall()
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
layer: str = r[0] or ""
|
||||
props: dict[str, Any] = r[1] if isinstance(r[1], dict) else {}
|
||||
geom_wkt_val: str | None = r[2]
|
||||
raw_area: Any = r[3]
|
||||
intersection_area_sqm: float | None = None
|
||||
if raw_area is not None:
|
||||
try:
|
||||
val = float(raw_area)
|
||||
intersection_area_sqm = round(val, 1) if not math.isnan(val) else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Извлечь short_name из "risk_<short_name>" для человекочитаемого subtype
|
||||
short_name = layer.removeprefix("risk_")
|
||||
# Попытка взять subtype из properties, затем из mapping
|
||||
subtype = (
|
||||
props.get("type_zone")
|
||||
or props.get("subcategory")
|
||||
or props.get("name")
|
||||
or _RISK_SUBTYPE_LABELS.get(short_name)
|
||||
)
|
||||
if subtype is not None:
|
||||
subtype = str(subtype)
|
||||
|
||||
result.append(
|
||||
{
|
||||
"layer": layer,
|
||||
"subtype": subtype,
|
||||
"geom_wkt": geom_wkt_val,
|
||||
"intersection_area_sqm": intersection_area_sqm,
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("nspd risk zones query failed for quarter=%s: %s", quarter, e)
|
||||
return []
|
||||
|
||||
|
||||
# ── Connection-points lookup (issue #115) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -389,7 +389,10 @@ def harvest_stale_quarters(
|
|||
enqueued = 0
|
||||
for cad in stale_cads:
|
||||
try:
|
||||
harvest_quarter.apply_async(args=[cad, region_code])
|
||||
harvest_quarter.apply_async(
|
||||
args=[cad, region_code],
|
||||
kwargs={"include_zouit": True, "include_risks": True},
|
||||
)
|
||||
enqueued += 1
|
||||
except Exception as e:
|
||||
logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, e)
|
||||
|
|
|
|||
200
backend/tests/services/test_quarter_dump_lookup.py
Normal file
200
backend/tests/services/test_quarter_dump_lookup.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction.
|
||||
|
||||
Покрывает:
|
||||
- _extract_features_by_layer: filter by prefix
|
||||
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
|
||||
no-risks-in-dump
|
||||
Не требует реального PostgreSQL — мокает db.execute().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder.quarter_dump_lookup import (
|
||||
_extract_features_by_layer,
|
||||
_get_risk_zones,
|
||||
derive_quarter_cad,
|
||||
make_empty_result,
|
||||
)
|
||||
|
||||
# ── Fixtures & helpers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Минимальный feature dict в формате features_json."""
|
||||
return {
|
||||
"layer": layer,
|
||||
"feature_id": "test-id",
|
||||
"geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
|
||||
"properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"},
|
||||
}
|
||||
|
||||
|
||||
# ── _extract_features_by_layer ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_features_by_layer_prefix_match() -> None:
|
||||
"""Возвращает только features с нужным prefix."""
|
||||
features = [
|
||||
_make_feature("risk_flooding"),
|
||||
_make_feature("risk_burns"),
|
||||
_make_feature("zouit_engineering"),
|
||||
_make_feature("parcels"),
|
||||
_make_feature("risk_landslide"),
|
||||
]
|
||||
result = _extract_features_by_layer(features, "risk_")
|
||||
assert len(result) == 3
|
||||
layers = {f["layer"] for f in result}
|
||||
assert layers == {"risk_flooding", "risk_burns", "risk_landslide"}
|
||||
|
||||
|
||||
def test_extract_features_by_layer_zouit_prefix() -> None:
|
||||
"""Работает для zouit_ prefix — generic re-use."""
|
||||
features = [
|
||||
_make_feature("zouit_engineering"),
|
||||
_make_feature("zouit_okn"),
|
||||
_make_feature("risk_flooding"),
|
||||
]
|
||||
result = _extract_features_by_layer(features, "zouit_")
|
||||
assert len(result) == 2
|
||||
assert all(f["layer"].startswith("zouit_") for f in result)
|
||||
|
||||
|
||||
def test_extract_features_by_layer_empty_list() -> None:
|
||||
result = _extract_features_by_layer([], "risk_")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_extract_features_by_layer_no_match() -> None:
|
||||
features = [_make_feature("parcels"), _make_feature("buildings")]
|
||||
result = _extract_features_by_layer(features, "risk_")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_extract_features_by_layer_non_string_layer() -> None:
|
||||
"""Gracefully skips features with non-string layer."""
|
||||
features: list[dict[str, Any]] = [
|
||||
{"layer": None, "feature_id": "x", "geometry": None, "properties": {}},
|
||||
_make_feature("risk_flooding"),
|
||||
]
|
||||
result = _extract_features_by_layer(features, "risk_")
|
||||
assert len(result) == 1
|
||||
assert result[0]["layer"] == "risk_flooding"
|
||||
|
||||
|
||||
# ── _get_risk_zones ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||
"""Мок Session где execute().fetchall() возвращает rows."""
|
||||
db = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.fetchall.return_value = rows
|
||||
db.execute.return_value = mock_result
|
||||
return db
|
||||
|
||||
|
||||
def test_get_risk_zones_intersects() -> None:
|
||||
"""Три risk features → три записи в результате."""
|
||||
rows: list[tuple[Any, ...]] = [
|
||||
# (layer, props, geom_wkt, intersection_area_sqm)
|
||||
(
|
||||
"risk_flooding",
|
||||
{"type_zone": "Затопление 1%"},
|
||||
"POLYGON((0 0,1 0,1 1,0 0))",
|
||||
1234.5,
|
||||
),
|
||||
(
|
||||
"risk_burns",
|
||||
{"name": "Гарь"},
|
||||
"POLYGON((0 0,2 0,2 2,0 0))",
|
||||
500.0,
|
||||
),
|
||||
(
|
||||
"risk_landslide",
|
||||
{},
|
||||
"POLYGON((0 0,3 0,3 3,0 0))",
|
||||
999.9,
|
||||
),
|
||||
]
|
||||
db = _make_mock_db_with_rows(rows)
|
||||
|
||||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
flooding = next(r for r in result if r["layer"] == "risk_flooding")
|
||||
assert flooding["subtype"] == "Затопление 1%"
|
||||
assert flooding["intersection_area_sqm"] == 1234.5
|
||||
assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))"
|
||||
|
||||
burns = next(r for r in result if r["layer"] == "risk_burns")
|
||||
assert burns["subtype"] == "Гарь"
|
||||
|
||||
landslide = next(r for r in result if r["layer"] == "risk_landslide")
|
||||
# No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS
|
||||
assert landslide["subtype"] == "Обвально-осыпные процессы"
|
||||
|
||||
|
||||
def test_get_risk_zones_no_intersect() -> None:
|
||||
"""Пустой fetchall → пустой список."""
|
||||
db = _make_mock_db_with_rows([])
|
||||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_risk_zones_no_risks_in_dump() -> None:
|
||||
"""risks_count == 0 → early exit, db.execute не вызывается."""
|
||||
db = MagicMock()
|
||||
layer_counts = {"risks_count": 0}
|
||||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts)
|
||||
assert result == []
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_get_risk_zones_db_exception_returns_empty() -> None:
|
||||
"""DB exception → пустой список (не propagate)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = Exception("DB connection lost")
|
||||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_risk_zones_null_area() -> None:
|
||||
"""NULL intersection_area_sqm → intersection_area_sqm=None в результате."""
|
||||
rows = [("risk_erosion_water", {}, "POINT(0 0)", None)]
|
||||
db = _make_mock_db_with_rows(rows)
|
||||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||||
assert len(result) == 1
|
||||
assert result[0]["intersection_area_sqm"] is None
|
||||
|
||||
|
||||
# ── make_empty_result includes nspd_risk_zones ────────────────────────────────
|
||||
|
||||
|
||||
def test_make_empty_result_has_risk_zones_key() -> None:
|
||||
"""make_empty_result возвращает nspd_risk_zones: []."""
|
||||
result = make_empty_result()
|
||||
assert "nspd_risk_zones" in result
|
||||
assert result["nspd_risk_zones"] == []
|
||||
|
||||
|
||||
# ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cad,expected",
|
||||
[
|
||||
("66:41:0204016", "66:41:0204016"),
|
||||
("66:41:0204016:10", "66:41:0204016"),
|
||||
("66:41:0204016:10:1", "66:41:0204016"),
|
||||
("invalid", None),
|
||||
("66:41", None),
|
||||
],
|
||||
)
|
||||
def test_derive_quarter_cad(cad: str, expected: str | None) -> None:
|
||||
assert derive_quarter_cad(cad) == expected
|
||||
|
|
@ -10,6 +10,7 @@ import { NeighborsBlock } from "./NeighborsBlock";
|
|||
import { NspdZoningBlock } from "./NspdZoningBlock";
|
||||
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
|
||||
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
||||
import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
|
||||
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -24,7 +25,8 @@ export function LandTab({ data }: Props) {
|
|||
data.neighbors_summary !== undefined ||
|
||||
data.nspd_zoning !== undefined ||
|
||||
data.nspd_zouit_overlaps !== undefined ||
|
||||
data.nspd_engineering_nearby !== undefined;
|
||||
data.nspd_engineering_nearby !== undefined ||
|
||||
data.nspd_risk_zones !== undefined;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
|
|
@ -68,6 +70,16 @@ export function LandTab({ data }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue #94 TIER 3 — Риск-зоны НСПД */}
|
||||
{data.nspd_risk_zones !== undefined && (
|
||||
<div>
|
||||
<SectionLabel style={{ marginBottom: 10 }}>
|
||||
Природные риски (НСПД)
|
||||
</SectionLabel>
|
||||
<NspdRiskZonesBlock riskZones={data.nspd_risk_zones} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue #202 — Инженерные объекты в 200 м */}
|
||||
{data.nspd_engineering_nearby !== undefined && (
|
||||
<div>
|
||||
|
|
|
|||
147
frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
Normal file
147
frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import type { RiskZone } from "@/types/nspd";
|
||||
|
||||
interface Props {
|
||||
riskZones: RiskZone[] | null | undefined;
|
||||
parcelAreaSqm?: number | null;
|
||||
}
|
||||
|
||||
// Severity mapping: layer key suffix → severity tier
|
||||
function getSeverity(layer: string): "high" | "medium" | "low" {
|
||||
const key = layer.replace(/^risk_/, "");
|
||||
if (key === "flooding" || key === "landslide") return "high";
|
||||
if (key === "flooding_underground" || key === "burns") return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<
|
||||
"high" | "medium" | "low",
|
||||
{ bg: string; badgeBg: string; color: string; label: string }
|
||||
> = {
|
||||
high: {
|
||||
bg: "#fff1f2",
|
||||
badgeBg: "#fee2e2",
|
||||
color: "#991b1b",
|
||||
label: "ВЫСОКИЙ",
|
||||
},
|
||||
medium: {
|
||||
bg: "#fffbeb",
|
||||
badgeBg: "#fef3c7",
|
||||
color: "#92400e",
|
||||
label: "СРЕДНИЙ",
|
||||
},
|
||||
low: {
|
||||
bg: "#fff7ed",
|
||||
badgeBg: "#ffedd5",
|
||||
color: "#9a3412",
|
||||
label: "НИЗКИЙ",
|
||||
},
|
||||
};
|
||||
|
||||
function formatArea(sqm: number | null): string | null {
|
||||
if (sqm === null) return null;
|
||||
if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`;
|
||||
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
|
||||
}
|
||||
|
||||
export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) {
|
||||
const zones = riskZones ?? [];
|
||||
|
||||
if (zones.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "#dcfce7",
|
||||
color: "#15803d",
|
||||
borderRadius: 6,
|
||||
padding: "2px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Риски не обнаружены
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: "#15803d" }}>
|
||||
Риск-зоны НСПД на участке не выявлены
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: high first, then medium, then low
|
||||
const sorted = [...zones].sort((a, b) => {
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
return order[getSeverity(a.layer)] - order[getSeverity(b.layer)];
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{sorted.map((zone, idx) => {
|
||||
const severity = getSeverity(zone.layer);
|
||||
const style = SEVERITY_STYLES[severity];
|
||||
const areaStr = formatArea(zone.intersection_area_sqm);
|
||||
const pct =
|
||||
parcelAreaSqm && zone.intersection_area_sqm
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((zone.intersection_area_sqm / parcelAreaSqm) * 100),
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.badgeBg}`,
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: style.badgeBg,
|
||||
color: style.color,
|
||||
borderRadius: 5,
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
|
||||
{zone.subtype ?? zone.layer}
|
||||
</div>
|
||||
{(areaStr || pct !== null) && (
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
|
||||
{areaStr && <span>Площадь пересечения: {areaStr}</span>}
|
||||
{areaStr && pct !== null && <span> · </span>}
|
||||
{pct !== null && <span>{pct}% участка</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,15 @@ export interface NspdDumpMeta {
|
|||
total_features: number | null;
|
||||
}
|
||||
|
||||
// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field
|
||||
|
||||
export interface RiskZone {
|
||||
layer: string; // e.g. "risk_flooding", "risk_landslide"
|
||||
subtype: string | null; // human-readable label from NSPD properties or mapping
|
||||
geom_wkt: string | null;
|
||||
intersection_area_sqm: number | null;
|
||||
}
|
||||
|
||||
// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
|
||||
|
||||
export interface EngineeringStructure {
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ export interface ParcelAnalysis {
|
|||
nspd_zoning?: import("./nspd").NspdZoning | null;
|
||||
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
|
||||
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
|
||||
nspd_risk_zones?: import("./nspd").RiskZone[];
|
||||
nspd_dump?: import("./nspd").NspdDumpMeta | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue