gendesign/frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
lekss361 09611dc690
feat(site-finder): P1 geometry suitability score (#45) (#89)
* feat(site-finder): P1 geometry suitability score (#45)

Backend (parcels.py):
- _polygon_suitability() — Shapely metrics on parcel WGS84 polygon:
  area_ha/m2 (projected via cos(lat) к UTM-like meters), perimeter_m,
  aspect_ratio (long/short side of MABR), convex_hull_ratio (area / hull area),
  min_inscribed_rect_dim_m.
- Composite suitability score 0..1:
  base = area_subscore (0 при <0.2 ha, linear → 1 при ≥0.5 ha)
  −0.3 если aspect_ratio > 5 (вытянутый)
  −0.3 если convex_hull_ratio < 0.65 (изрезанный)
  −0.5 если min_inscribed_rect_dim_m < 30 (узкий)
- Label: микро / подходящий / сложная форма / слабо подходит
- Recommendation: "для МКД 16+ нужно >0.3 ha + минимум 40м"
- В analyze response → geometry_suitability.

Frontend:
- Новый GeometrySuitabilityBlock.tsx с color-coded badge + метрики grid +
  penalties + recommendation
- Добавлен в LandTab (выше "Зонирование (ПЗЗ)")
- TS типы расширены: GeometrySuitability

Closes #45.

* fix(site-finder): address PR #89 auto-review minor feedback

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.

* fix(site-finder): P1 #45 — extract 0.3 ha to _GEOM_AREA_COMFORT_HA

Auto-review нашёл: recommendation упоминала "от 0.3 га" но в constants block
не было 0.3 — magic literal в строке. Тот же класс проблемы что 30/40м inconsistency
из прошлого review, только в другом поле.

Fix: новая константа `_GEOM_AREA_COMFORT_HA = 0.3` с комментарием
"рекомендуемая комфортная площадь МКД (recommendation)". Размещена между
_GEOM_MIN_AREA_HA (физический минимум) и _GEOM_AREA_SCORE_FULL_HA (premium) —
третий semantic threshold. Recommendation теперь f-string использует константу.

Per auto-review on ae5e8de.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 08:00:12 +03:00

196 lines
5.6 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 {
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 = выпуклый, &lt;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>
);
}