gendesign/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
lekss361 1595157ebf feat(nspd): enable TIER 3 risk zones harvest + extract in analyze (#94 PR1)
- harvest_stale_quarters fanout passes include_risks=True to all queued tasks
- _get_risk_zones() with ST_Intersects + ST_Area intersection calc
- _extract_features_by_layer() generic helper for future PR2+ reuse
- RiskZone Pydantic schema; /analyze returns nspd_risk_zones list
- NspdRiskZonesBlock component with severity color-coding (red/yellow/orange)
- 11 unit tests for extract/intersect/null-area/db-exception paths

Part of #94 (TIER 3 risk layers — flooding, landslide, burns, erosion etc)
2026-05-16 16:11:59 +03:00

147 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}