- 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)
147 lines
4.1 KiB
TypeScript
147 lines
4.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|