gendesign/frontend/src/components/site-finder/NeighborsBlock.tsx
lekss361 60d53bbf7d feat(site-finder): P2 cad_buildings соседи + overlap check (#46)
Backend (parcels.py):
- _parse_floors() helper для TEXT column (cad_buildings.floors хранится как
  строка, могут быть диапазоны "5-7"). Возвращает верхнюю границу.
- _neighbors_summary(db, geom_wkt, our_cad) — query соседей в 100м (GIST):
  cad_num, building_name, floors, year_built, cost_value, area, address, distance.
  Aggregate: avg_floors_100m, max_floors_100m, median_cost_per_m2_100m,
  count_buildings_100m. Outliers cost/m² фильтруются (1k < x < 500k).
- Overlap check: ST_Intersects + ST_Area(ST_Intersection) > 50 m² (transformed
  to UTM 32641 для метров). Если есть → has_existing_buildings: true +
  overlap_buildings list.
- В response → neighbors_summary.

Frontend:
- Новый NeighborsBlock.tsx: hard red warn block для overlap (с building names +
  overlap_m2 + "Инвестиции невозможны без сноса"); summary metrics (avg/max
  floors, median price); toggle "Показать N ближайших" → таблица.
- Border меняется на красный при has_existing_buildings — visual cue.
- Добавлен в LandTab выше "Зонирование (ПЗЗ)".
- TS типы: NeighborBuilding, OverlapBuilding, NeighborsSummary.

Closes #46. Closes #21 (cad_buildings в Site Finder фильтрах).
2026-05-12 01:12:29 +03:00

360 lines
10 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 { useState } from "react";
import type { NeighborsSummary } from "@/types/site-finder";
interface Props {
data: NeighborsSummary;
}
function fmtPrice(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 1000) {
return `${(n / 1000).toFixed(0)} тыс ₽/м²`;
}
return `${n.toLocaleString("ru-RU")} ₽/м²`;
}
function fmtYearBuilt(y: number | null | undefined): string {
if (y == null || y === 0) return "—";
return String(y);
}
export function NeighborsBlock({ data }: Props) {
const [expanded, setExpanded] = useState(false);
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,
}}
>
Соседи (100 м)
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{data.note ?? "Данные о соседних зданиях недоступны"}
</div>
</div>
);
}
const count = data.count_buildings_100m ?? 0;
const hasOverlap = !!data.has_existing_buildings;
return (
<div
style={{
border: hasOverlap ? "1px solid #fca5a5" : "1px solid #e5e7eb",
background: "#fff",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Соседи (100 м)
</span>
<span
style={{
fontSize: 12,
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{count} {count === 1 ? "здание" : "зданий"}
</span>
</div>
{/* Overlap warning — hard warn */}
{hasOverlap &&
data.overlap_buildings &&
data.overlap_buildings.length > 0 && (
<div
style={{
padding: "10px 14px",
background: "#fee2e2",
border: "1px solid #fca5a5",
borderRadius: 8,
color: "#b91c1c",
fontSize: 13,
}}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>
На участке уже есть здания (overlap &gt;50 м²)
</div>
<ul
style={{
margin: 0,
paddingLeft: 18,
fontSize: 12,
lineHeight: 1.5,
}}
>
{data.overlap_buildings.map((b) => (
<li key={b.cad_num}>
{b.building_name ?? b.readable_address ?? b.cad_num}
{b.floors ? `, ${b.floors} эт.` : ""}
{b.overlap_m2 ? ` — пересечение ~${b.overlap_m2} м²` : ""}
</li>
))}
</ul>
<div style={{ marginTop: 6, fontSize: 11, fontStyle: "italic" }}>
Инвестиции невозможны без сноса.
</div>
</div>
)}
{/* Summary metrics */}
{count > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
gap: 10,
fontSize: 12,
color: "#374151",
}}
>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>
Средн. этажность
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.avg_floors_100m ?? "—"}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Макс. этажей</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.max_floors_100m ?? "—"}
</div>
</div>
<div>
<div
style={{ color: "#9ca3af", fontSize: 11 }}
title="Медиана кадастровой стоимости / м² — proxy для «premium quarter»"
>
Медиана цены
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtPrice(data.median_cost_per_m2_100m)}
</div>
</div>
</div>
)}
{/* Toggle neighbor list */}
{data.neighbors && data.neighbors.length > 0 && (
<div>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть список"
: `Показать ${data.neighbors.length} ближайших`}
</button>
{expanded && (
<div
style={{
marginTop: 10,
maxHeight: 280,
overflowY: "auto",
border: "1px solid #f3f4f6",
borderRadius: 6,
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead
style={{
background: "#f9fafb",
position: "sticky",
top: 0,
}}
>
<tr>
<th
style={{
padding: "6px 10px",
textAlign: "left",
color: "#6b7280",
fontWeight: 500,
}}
>
Здание
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Эт.
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Год
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
/м²
</th>
<th
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontWeight: 500,
}}
>
Дист.
</th>
</tr>
</thead>
<tbody>
{data.neighbors.map((n) => (
<tr
key={n.cad_num}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td style={{ padding: "6px 10px", color: "#374151" }}>
{n.building_name ?? n.readable_address ?? n.cad_num}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{n.floors ?? "—"}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{fmtYearBuilt(n.year_built)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{fmtPrice(n.cost_per_m2)}
</td>
<td
style={{
padding: "6px 10px",
textAlign: "right",
color: "#6b7280",
fontVariantNumeric: "tabular-nums",
}}
>
{n.distance_m} м
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{data.note && (
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
{data.note}
</div>
)}
</div>
);
}