All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 3m10s
Deploy / build-frontend (push) Successful in 4m33s
Deploy / build-worker (push) Successful in 4m42s
Deploy / deploy (push) Successful in 1m50s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 13s
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import type { RiskZone } from "@/types/nspd";
|
||
|
||
interface Props {
|
||
riskZones: RiskZone[] | null | undefined;
|
||
parcelAreaSqm?: number | null;
|
||
/**
|
||
* #2934: сколько объектов риск-слоёв в дампе квартала. Пустой `riskZones` без
|
||
* этого признака неоднозначен, и блок рисовал по нему зелёное «Риски не
|
||
* обнаружены» — то есть утверждал результат проверки, которой не было.
|
||
*
|
||
* `undefined`/`null` — дампа нет; `0` — слой не дал объектов на весь квартал;
|
||
* `>0` — слой отработал, и отсутствие пересечений с участком уже настоящий факт.
|
||
*/
|
||
quarterRisksCount?: 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,
|
||
quarterRisksCount,
|
||
}: Props) {
|
||
const zones = riskZones ?? [];
|
||
|
||
if (zones.length === 0) {
|
||
// Зелёный success-стиль оставляем ТОЛЬКО когда слой реально что-то отдал по
|
||
// кварталу: тогда «на участке ничего не пересекается» — измеренный факт.
|
||
// Замер 19.08: risks_count = 0 у всех 669 дампов, слои risk_* не вернули ни
|
||
// одного объекта, — то есть сегодня показывается нейтральная ветка.
|
||
const layerAnswered = (quarterRisksCount ?? 0) > 0;
|
||
if (!layerAnswered) {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
background: "#f8fafc",
|
||
border: "1px solid #e2e8f0",
|
||
borderRadius: 8,
|
||
padding: "10px 14px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
background: "#e2e8f0",
|
||
color: "#475569",
|
||
borderRadius: 6,
|
||
padding: "2px 10px",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
Не проверено
|
||
</span>
|
||
<span style={{ fontSize: 13, color: "#475569" }}>
|
||
Слои природного риска НСПД не вернули объектов по этому кварталу —
|
||
отсутствие риска не подтверждено
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|