feat(site-finder): NSPD frontend integration в LandTab (#202) #212

Merged
lekss361 merged 1 commit from feat/202-nspd-ui-landtab into main 2026-05-16 11:55:32 +00:00
8 changed files with 806 additions and 41 deletions

View file

@ -7,6 +7,10 @@ import { GeologyBlock } from "./GeologyBlock";
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock";
import { NeighborsBlock } from "./NeighborsBlock";
import { NspdZoningBlock } from "./NspdZoningBlock";
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
interface Props {
data: ParcelAnalysis;
@ -17,7 +21,10 @@ export function LandTab({ data }: Props) {
data.geotech_risk !== undefined ||
data.geology !== undefined ||
data.geometry_suitability !== undefined ||
data.neighbors_summary !== undefined;
data.neighbors_summary !== undefined ||
data.nspd_zoning !== undefined ||
data.nspd_zouit_overlaps !== undefined ||
data.nspd_engineering_nearby !== undefined;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
@ -31,51 +38,49 @@ export function LandTab({ data }: Props) {
<GeometrySuitabilityBlock data={data.geometry_suitability} />
)}
{/* Zoning note */}
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
}}
>
<SectionLabel style={{ marginBottom: 8 }}>
Зонирование (ПЗЗ)
</SectionLabel>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Данные ПЗЗ доступны через{" "}
<a
href="https://pkk.rosreestr.ru/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
Публичную кадастровую карту
</a>
. API Росреестра закрыт в 2024 используйте deep-link для кад. номера{" "}
<strong style={{ color: "#374151" }}>{data.cad_num}</strong>.
</div>
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(data.cad_num)}`}
target="_blank"
rel="noopener noreferrer"
{/* Issue #202 — Зонирование (ПЗЗ) из НСПД */}
<div>
<div
style={{
display: "inline-block",
marginTop: 10,
padding: "6px 14px",
background: "#1d4ed8",
color: "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
}}
>
Открыть в ПКК
</a>
<SectionLabel>Зонирование (ПЗЗ)</SectionLabel>
<NspdFreshnessBadge dump={data.nspd_dump} />
</div>
<NspdZoningBlock
data={data.nspd_zoning}
dump={data.nspd_dump}
cadNum={data.cad_num}
/>
</div>
{/* Issue #202 — ЗОУИТ пересечения */}
{data.nspd_zouit_overlaps !== undefined && (
<div>
<SectionLabel style={{ marginBottom: 10 }}>
Зоны с особыми условиями использования (ЗОУИТ)
</SectionLabel>
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps ?? []} />
</div>
)}
{/* Issue #202 — Инженерные объекты в 200 м */}
{data.nspd_engineering_nearby !== undefined && (
<div>
<SectionLabel style={{ marginBottom: 10 }}>
Инженерные объекты вблизи участка
</SectionLabel>
<NspdEngineeringNearbyBlock
nearby={data.nspd_engineering_nearby ?? []}
cadNum={data.cad_num}
/>
</div>
)}
{/* Geotech risk */}
{data.geotech_risk !== undefined && (
<div>

View file

@ -0,0 +1,211 @@
"use client";
import type { NspdEngineeringNearby } from "@/types/nspd";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
interface Props {
nearby: NspdEngineeringNearby[];
cadNum: string | null | undefined;
}
interface MergedRow {
label: string;
type: string | null;
distanceM: number;
source: "analyze" | "connection-points";
}
export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
const { data: cpData, isPending: cpLoading } = useConnectionPoints(cadNum);
// Merge and sort by distance
const rows: MergedRow[] = [];
for (const item of nearby) {
if (item.distance_m !== null) {
rows.push({
label: item.name ?? item.type ?? "Объект",
type: item.type,
distanceM: item.distance_m,
source: "analyze",
});
}
}
if (cpData) {
for (const s of cpData.engineering_structures) {
rows.push({
label: s.name ?? s.type ?? "Объект",
type: s.type,
distanceM: s.distance_to_boundary_m,
source: "connection-points",
});
}
}
// Dedup by label+distance (simple check)
const seen = new Set<string>();
const deduped = rows.filter((r) => {
const key = `${r.label}|${Math.round(r.distanceM)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
deduped.sort((a, b) => a.distanceM - b.distanceM);
if (deduped.length === 0 && !cpLoading) {
return (
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
Инженерных объектов в 200 м не найдено
</div>
);
}
return (
<div>
{deduped.length > 0 && (
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr>
<th
style={{
textAlign: "left",
padding: "4px 8px",
color: "#6b7280",
fontWeight: 600,
borderBottom: "1px solid #e5e7eb",
fontSize: 12,
}}
>
Объект
</th>
<th
style={{
textAlign: "left",
padding: "4px 8px",
color: "#6b7280",
fontWeight: 600,
borderBottom: "1px solid #e5e7eb",
fontSize: 12,
}}
>
Тип
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
color: "#6b7280",
fontWeight: 600,
borderBottom: "1px solid #e5e7eb",
fontSize: 12,
}}
>
Расстояние
</th>
</tr>
</thead>
<tbody>
{deduped.map((row, idx) => (
<tr
key={idx}
style={{
borderBottom:
idx < deduped.length - 1 ? "1px solid #f3f4f6" : undefined,
}}
>
<td
style={{
padding: "5px 8px",
color: "#1f2937",
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.label}
</td>
<td
style={{
padding: "5px 8px",
color: "#6b7280",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.type ?? "—"}
</td>
<td
style={{
padding: "5px 8px",
color: "#374151",
textAlign: "right",
whiteSpace: "nowrap",
}}
>
{Math.round(row.distanceM)} м
</td>
</tr>
))}
</tbody>
</table>
)}
{cpLoading && (
<div style={{ marginTop: 8, fontSize: 12, color: "#9ca3af" }}>
Загружаем данные точек подключения
</div>
)}
{cpData?.summary && (
<div
style={{
marginTop: 10,
display: "flex",
flexWrap: "wrap",
gap: 8,
}}
>
{cpData.summary.in_protection_zone && (
<span
style={{
background: "#fee2e2",
color: "#991b1b",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
fontWeight: 600,
}}
>
В охранной зоне
</span>
)}
{cpData.summary.nearest_structure_distance_m !== null && (
<span
style={{
background: "#f3f4f6",
color: "#374151",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
}}
>
Ближайший:{" "}
{Math.round(cpData.summary.nearest_structure_distance_m)} м
</span>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import type { NspdDumpMeta } from "@/types/nspd";
interface Props {
dump: NspdDumpMeta | null | undefined;
}
export function NspdFreshnessBadge({ dump }: Props) {
if (!dump) {
return (
<span
style={{
display: "inline-block",
background: "#f3f4f6",
color: "#6b7280",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title="Данные НСПД недоступны"
>
НСПД: нет данных
</span>
);
}
if (dump.harvest_triggered && !dump.available) {
return (
<span
style={{
display: "inline-block",
background: "#dbeafe",
color: "#1e40af",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title="Загрузка данных НСПД запущена"
>
НСПД: загрузка
</span>
);
}
if (!dump.available) {
return (
<span
style={{
display: "inline-block",
background: "#f3f4f6",
color: "#6b7280",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title="Дамп НСПД для квартала не загружен"
>
НСПД: нет дампа
</span>
);
}
const dateStr = dump.fetched_at_utc
? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")
: null;
if (dump.stale) {
return (
<span
style={{
display: "inline-block",
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title={
dateStr
? `Данные от ${dateStr} (устаревшие)`
: "Устаревшие данные НСПД"
}
>
НСПД: устарело{dateStr ? ` (${dateStr})` : ""}
</span>
);
}
return (
<span
style={{
display: "inline-block",
background: "#dcfce7",
color: "#15803d",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title={dateStr ? `Данные НСПД от ${dateStr}` : "Данные НСПД актуальны"}
>
НСПД: актуально{dateStr ? ` (${dateStr})` : ""}
</span>
);
}

View file

@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import type { NspdDumpMeta, NspdZoning } from "@/types/nspd";
interface Props {
data: NspdZoning | null | undefined;
dump: NspdDumpMeta | null | undefined;
cadNum: string;
}
export function NspdZoningBlock({ data, dump, cadNum }: Props) {
const [expanded, setExpanded] = useState(false);
const isHarvesting = dump?.harvest_triggered && !dump?.available;
// Loading skeleton when harvest is in progress
if (isHarvesting && !data) {
return (
<div
style={{
background: "#eff6ff",
border: "1px solid #bfdbfe",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: "#3b82f6",
animation: "pulse 1.5s infinite",
}}
/>
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
Загружаем данные НСПД, 1530 с
</span>
</div>
<div
style={{
height: 12,
background: "#bfdbfe",
borderRadius: 4,
width: "60%",
marginBottom: 6,
}}
/>
<div
style={{
height: 12,
background: "#bfdbfe",
borderRadius: 4,
width: "40%",
}}
/>
</div>
);
}
// Data not available, no harvest triggered
if (!data) {
return (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Данные ПЗЗ из НСПД недоступны. Используйте{" "}
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
Публичную кадастровую карту
</a>{" "}
для кад. номера <strong style={{ color: "#374151" }}>{cadNum}</strong>
.
</div>
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 10,
padding: "6px 14px",
background: "#1d4ed8",
color: "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
}}
>
Открыть в ПКК
</a>
</div>
);
}
return (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
marginBottom: 10,
}}
>
{data.zone_code && (
<span
style={{
background: "#dbeafe",
color: "#1e40af",
borderRadius: 6,
padding: "2px 10px",
fontSize: 13,
fontWeight: 600,
letterSpacing: 0.2,
}}
>
{data.zone_code}
</span>
)}
{data.zone_name && (
<span style={{ fontSize: 13, color: "#374151" }}>
{data.zone_name}
</span>
)}
{!data.zone_code && !data.zone_name && (
<span style={{ fontSize: 13, color: "#6b7280" }}>
Зона не определена
</span>
)}
</div>
{data.raw_props && Object.keys(data.raw_props).length > 0 && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => setExpanded((v) => !v)}
style={{
fontSize: 12,
color: "#6b7280",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
textDecoration: "underline",
}}
>
{expanded ? "Скрыть свойства" : "Показать все свойства НСПД"}
</button>
{expanded && (
<div
style={{
marginTop: 8,
background: "#f3f4f6",
borderRadius: 6,
padding: "8px 12px",
fontSize: 12,
color: "#374151",
maxHeight: 200,
overflowY: "auto",
}}
>
{Object.entries(data.raw_props).map(([k, v]) => (
<div key={k} style={{ marginBottom: 2 }}>
<span style={{ color: "#6b7280" }}>{k}: </span>
<span>{String(v ?? "—")}</span>
</div>
))}
</div>
)}
</div>
)}
{dump?.fetched_at_utc && (
<div style={{ marginTop: 8, fontSize: 11, color: "#9ca3af" }}>
НСПД: данные от{" "}
{new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")}
{dump.stale && (
<span style={{ color: "#f59e0b", marginLeft: 4 }}>
(устаревшие)
</span>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import type { NspdZouitOverlap } from "@/types/nspd";
interface Props {
overlaps: NspdZouitOverlap[];
}
// Group key → human-readable label
const GROUP_LABELS: Record<string, string> = {
engineering: "Инженерные коммуникации",
okn: "Объекты культурного наследия",
natural: "Природные территории",
protected: "Охраняемые зоны",
other: "Прочие ЗОУИТ",
};
// Determine a color-coded severity badge from group_key
function getSeverityStyle(groupKey: string): {
bg: string;
color: string;
label: string;
} {
switch (groupKey) {
case "engineering":
return { bg: "#fef3c7", color: "#92400e", label: "warn" };
case "okn":
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
case "protected":
return { bg: "#fee2e2", color: "#991b1b", label: "block" };
case "natural":
return { bg: "#dbeafe", color: "#1e40af", label: "low" };
default:
return { bg: "#f3f4f6", color: "#374151", label: "info" };
}
}
export function NspdZouitOverlapsBlock({ overlaps }: Props) {
if (overlaps.length === 0) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
padding: "10px 14px",
}}
>
<span
style={{
background: "#dcfce7",
color: "#15803d",
borderRadius: 6,
padding: "2px 10px",
fontSize: 12,
fontWeight: 600,
}}
>
Нет пересечений
</span>
<span style={{ fontSize: 13, color: "#15803d" }}>
Охранных зон ЗОУИТ не обнаружено
</span>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{overlaps.map((overlap, idx) => {
const style = getSeverityStyle(overlap.group_key);
const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key;
const detail =
overlap.name ||
(typeof overlap.subcategory === "string"
? overlap.subcategory
: null);
return (
<div
key={idx}
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<span
style={{
background: style.bg,
color: style.color,
borderRadius: 5,
padding: "2px 8px",
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{style.label.toUpperCase()}
</span>
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
{label}
</div>
{detail && (
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{detail}
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import type { ConnectionPointsResponse } from "@/types/nspd";
export type { ConnectionPointsResponse };
export function useConnectionPoints(
cadNum: string | null | undefined,
enabled = true,
) {
return useQuery<ConnectionPointsResponse>({
queryKey: ["connection-points", cadNum],
queryFn: () =>
apiFetch<ConnectionPointsResponse>(
`/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-points`,
),
enabled: !!cadNum && enabled,
staleTime: 5 * 60 * 1000,
});
}

View file

@ -0,0 +1,71 @@
// NSPD (НСПД) types — Issue #202
// Shapes derived from backend/app/services/site_finder/quarter_dump_lookup.py
// and backend/app/schemas/parcel.py
export interface NspdZoning {
zone_code: string | null;
zone_name: string | null;
source?: string;
raw_props?: Record<string, unknown>;
}
export interface NspdZouitOverlap {
group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other"
layer: string; // e.g. "zouit_engineering"
subcategory: string | number | null;
name: string | null;
raw_props?: Record<string, unknown>;
}
export interface NspdEngineeringNearby {
name: string | null;
type: string | null;
distance_m: number | null;
raw_props?: Record<string, unknown>;
}
export interface NspdDumpMeta {
available: boolean;
fetched_at_utc: string | null; // ISO datetime
stale: boolean;
harvest_triggered: boolean;
total_features: number | null;
}
// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
export interface EngineeringStructure {
name: string | null;
type: string | null;
cad_num: string | null;
distance_to_boundary_m: number;
geometry_geojson: Record<string, unknown>;
readable_address: string | null;
raw_props: Record<string, unknown>;
source: string;
}
export interface ZouitEngineeringOverlap {
reg_numb_border: string | null;
type_zone: string | null;
subcategory: number | null;
intersects_parcel: boolean;
geometry_geojson: Record<string, unknown>;
raw_props: Record<string, unknown>;
source: string;
}
export interface ConnectionPointsSummary {
nearest_structure_distance_m: number | null;
in_protection_zone: boolean;
protection_zones_intersecting: number;
total_structures_in_radius: number;
}
export interface ConnectionPointsResponse {
engineering_structures: EngineeringStructure[];
zouit_engineering_overlaps: ZouitEngineeringOverlap[];
summary: ConnectionPointsSummary;
dump_available: boolean;
dump_fetched_at: string | null;
}

View file

@ -379,6 +379,11 @@ export interface ParcelAnalysis {
gate_verdict?: GateVerdict;
// D2 (#34) — velocity-score: темп продаж конкурентов
velocity?: Velocity | null;
// Issue #202 — NSPD frontend integration
nspd_zoning?: import("./nspd").NspdZoning | null;
nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
nspd_dump?: import("./nspd").NspdDumpMeta | null;
}
export type PoiCategory =