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 фильтрах).
360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
"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 >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>
|
||
);
|
||
}
|