Backend (parcels.py):
1. Фактическая ошибка фикс: recommendation теперь различает "строительный
минимум 30м" (physical penalty trigger) и "комфорт МКД 12-16эт 40м"
(recommendation level). Пользователю чётко видны 2 порога.
2. Lazy imports → module-level: `from shapely import wkt as _shp_wkt`,
`from shapely.geometry import Polygon`.
3. MABR inner exception теперь логирует: `logger.debug("MABR computation
failed, falling back to sqrt(area): %s", mabr_err)`.
4. Magic numbers вынесены в константы: _GEOM_MIN_AREA_HA, _GEOM_AREA_SCORE_FULL_HA,
_GEOM_ASPECT_PENALTY_THRESHOLD, _GEOM_CONVEX_PENALTY_THRESHOLD,
_GEOM_MIN_WIDTH_PHYSICAL_M, _GEOM_MIN_WIDTH_COMFORT_M, _GEOM_LABEL_MICRO_HA,
_GEOM_LABEL_GOOD, _GEOM_LABEL_MEDIUM, плюс penalty константы.
5. Label "микро" теперь комбинируется с penalties: "микро, узкий" — UI видит
обе проблемы. Pure "микро" остаётся когда нет penalty.
Frontend (GeometrySuitabilityBlock.tsx + types):
6. Заменил emoji ⚠ (U+26A0) на текстовый <strong>"Проблемы формы:"</strong> —
стабильнее в WeasyPrint PDF-экспорте и cross-platform.
- TS типы: GeometrySuitabilityBaseLabel экспортирован, label расширен до
`string` для допуска combo-labels; helper colorForLabel() парсит base часть.
Per auto-review on f4e7491.
196 lines
5.6 KiB
TypeScript
196 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import type {
|
||
GeometrySuitability,
|
||
GeometrySuitabilityBaseLabel,
|
||
} from "@/types/site-finder";
|
||
|
||
interface Props {
|
||
data: GeometrySuitability;
|
||
}
|
||
|
||
const LABEL_COLOR: Record<
|
||
GeometrySuitabilityBaseLabel,
|
||
{ bg: string; fg: string; border: string }
|
||
> = {
|
||
подходящий: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
||
"сложная форма": { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" },
|
||
"слабо подходит": { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||
микро: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||
};
|
||
|
||
// label может быть combo "микро, узкий" — берём первую часть как ключ для цвета.
|
||
function colorForLabel(label: string | undefined) {
|
||
if (!label) return LABEL_COLOR["сложная форма"];
|
||
const base = label.split(",")[0].trim() as GeometrySuitabilityBaseLabel;
|
||
return LABEL_COLOR[base] ?? LABEL_COLOR["сложная форма"];
|
||
}
|
||
|
||
function fmtArea(ha: number | undefined, m2: number | undefined): string {
|
||
if (ha !== undefined && ha >= 0.1) {
|
||
return `${ha.toFixed(2)} га`;
|
||
}
|
||
if (m2 !== undefined) {
|
||
return `${m2.toLocaleString("ru-RU")} м²`;
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
export function GeometrySuitabilityBlock({ data }: Props) {
|
||
if (!data.data_available) {
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 10,
|
||
padding: "12px 16px",
|
||
background: "#f9fafb",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "#6b7280",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
Геометрия участка
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "#9ca3af" }}>{data.note}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const c = colorForLabel(data.label);
|
||
const score = data.suitability_score ?? 0;
|
||
const scorePct = Math.round(score * 100);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: `1px solid ${c.border}`,
|
||
background: "#fff",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
color: "#6b7280",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
}}
|
||
>
|
||
Геометрия участка
|
||
</span>
|
||
<span
|
||
style={{
|
||
padding: "2px 10px",
|
||
borderRadius: 12,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: c.fg,
|
||
background: c.bg,
|
||
border: `1px solid ${c.border}`,
|
||
}}
|
||
>
|
||
{data.label} · {scorePct}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Метрики */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||
gap: 10,
|
||
fontSize: 12,
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ color: "#9ca3af", fontSize: 11 }}>Площадь</div>
|
||
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||
{fmtArea(data.area_ha, data.area_m2)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={{ color: "#9ca3af", fontSize: 11 }}>Периметр</div>
|
||
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||
{data.perimeter_m ?? "—"} м
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
||
Соотношение сторон
|
||
</div>
|
||
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||
{data.aspect_ratio?.toFixed(2) ?? "—"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div
|
||
style={{ color: "#9ca3af", fontSize: 11 }}
|
||
title="Площадь / площадь выпуклой оболочки; 1.0 = выпуклый, <0.7 — изрезанный"
|
||
>
|
||
Выпуклость
|
||
</div>
|
||
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||
{data.convex_hull_ratio !== undefined
|
||
? `${(data.convex_hull_ratio * 100).toFixed(0)}%`
|
||
: "—"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div
|
||
style={{ color: "#9ca3af", fontSize: 11 }}
|
||
title="Длина короткой стороны минимального ограничивающего прямоугольника"
|
||
>
|
||
Мин. ширина
|
||
</div>
|
||
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||
{data.min_inscribed_rect_dim_m ?? "—"} м
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Penalties */}
|
||
{data.penalties && data.penalties.length > 0 && (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: c.fg,
|
||
background: c.bg,
|
||
padding: "6px 10px",
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<strong>Проблемы формы:</strong> {data.penalties.join(", ")}
|
||
</div>
|
||
)}
|
||
|
||
{/* Recommendation */}
|
||
{data.recommendation && (
|
||
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
|
||
{data.recommendation}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|