* 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 onf4e7491. * 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 onae5e8de. --------- Co-authored-by: lekss361 <claudestars@proton.me>
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>
|
||
);
|
||
}
|