feat(site-finder): POST /parcels/{cad}/analyze endpoint + UI

Backend:
- analyze endpoint: cad → geom lookup → POI within 1km → district
  context → competitors within 3km. Tram_stop carries negative weight.

Frontend /site-finder:
- CadInput regex-validated (default 66:41:0204016:10)
- SiteMap (Leaflet via next/dynamic) with parcel polygon + legend
- ScoreCard color-coded (>5 green, 2-5 yellow, <2 red) with tram-warning
- CompetitorTable top-20 with same-district highlight
- useSiteAnalysis hook + TS types
This commit is contained in:
lekss361 2026-05-11 18:11:58 +03:00
parent 6dea34186b
commit a4e8fff5ac
8 changed files with 1061 additions and 9 deletions

View file

@ -1,9 +1,33 @@
from fastapi import APIRouter, HTTPException
import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# Веса POI-категорий для scoring (Максим: трамвай = минус)
_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5, # негативный вес — шум / вибрация
}
@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
@ -18,3 +42,178 @@ async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
async def get_parcel(parcel_id: str) -> ParcelDetail:
"""TODO Stage 2b: fetch parcel by id from DB."""
raise HTTPException(status_code=501, detail="Not implemented yet")
@router.post("/{cad_num}/analyze")
def analyze_parcel(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Анализ участка: близость к социалке + district context + конкуренты.
Порядок поиска геометрии: cad_quarters_geom cad_buildings.
"""
# 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON
row = (
db.execute(
text("""
SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
g.geom AS geom_wkb,
'cad_quarter' AS source
FROM cad_quarters_geom g
WHERE g.cad_number = :c
UNION ALL
SELECT ST_AsGeoJSON(b.geom) AS geom_geojson,
b.geom AS geom_wkb,
'cad_building' AS source
FROM cad_buildings b
WHERE b.cad_num = :c
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
if not row:
raise HTTPException(
status_code=404,
detail=f"Геометрия для {cad_num} не найдена. Загрузи через NSPD geo.",
)
geom_geojson: str = row["geom_geojson"]
source: str = row["source"]
# Используем ST_AsText для передачи геометрии в последующие запросы.
# Все PostGIS-запросы принимают текстовый WKT через ST_GeomFromText.
geom_row = (
db.execute(
text("""
SELECT ST_AsText(g.geom) AS wkt
FROM (
SELECT g.geom FROM cad_quarters_geom g WHERE g.cad_number = :c
UNION ALL
SELECT b.geom FROM cad_buildings b WHERE b.cad_num = :c
) g
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
# 2) District context — ближайший район ЕКБ
district_row = (
db.execute(
text("""
SELECT district_name,
median_price_per_m2,
ST_Distance(
d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS dist_to_center
FROM ekb_districts d
WHERE ST_DWithin(
d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
5000
)
ORDER BY dist_to_center ASC
LIMIT 1
"""),
{"wkt": geom_wkt},
)
.mappings()
.first()
)
# 3) POI в радиусе 1 км — список с distance_m
poi_rows = (
db.execute(
text("""
SELECT category,
name,
lat,
lon,
ST_Distance(
p.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
last_osm_edit_date
FROM osm_poi_ekb p
WHERE ST_DWithin(
p.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
1000
)
ORDER BY distance_m ASC
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
by_category: dict[str, list[dict[str, Any]]] = {}
for p in poi_rows:
cat: str = p["category"]
w = _POI_WEIGHTS.get(cat, 0.0)
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
score += w * decay
by_category.setdefault(cat, []).append(
{
"name": p["name"],
"distance_m": round(float(p["distance_m"])),
"last_edit": (
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
),
}
)
# 5) Конкуренты в радиусе 3 км из DOM.РФ
competitor_rows = (
db.execute(
text("""
SELECT obj_id,
comm_name,
dev_name,
obj_class,
flat_count,
district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
ORDER BY o.flat_count DESC NULLS LAST
LIMIT 20
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
return {
"cad_num": cad_num,
"source": source,
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
"district": dict(district_row) if district_row else None,
"score": round(score, 2),
"score_breakdown": by_category,
"poi_count": len(poi_rows),
"competitors": [dict(c) for c in competitor_rows],
}

View file

@ -1,18 +1,161 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { CadInput } from "@/components/site-finder/CadInput";
import { ScoreCard } from "@/components/site-finder/ScoreCard";
import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
() => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap),
{
ssr: false,
loading: () => (
<div
style={{
height: 420,
border: "1px solid #e5e7eb",
borderRadius: 12,
background: "#f9fafb",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
Загрузка карты
</div>
),
},
);
export default function SiteFinderPage() {
const { mutate, data, isPending, error, isIdle } = useSiteAnalysis();
function handleAnalyze(cadNum: string) {
mutate(cadNum);
}
return (
<main style={{ padding: 24, maxWidth: 1200, margin: "0 auto" }}>
<Link href="/"> Home</Link>
<h1>Site Finder</h1>
<p>TODO Stage 2a: load 1000+ Sverdlovsk parcels into PostGIS.</p>
<p>
TODO Stage 2b: map (Mapbox) with color-graded parcels + filters + table
+ card.
<main style={{ padding: "24px 20px", maxWidth: 1280, margin: "0 auto" }}>
{/* Breadcrumb */}
<div style={{ marginBottom: 16 }}>
<Link
href="/"
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
>
Главная
</Link>
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 4 }}>
Site Finder
</h1>
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: 24 }}>
Введите кадастровый номер участка или квартала для анализа локации
</p>
<p>TODO Stage 2c: «Compute concept» button calling Generative module.</p>
{/* Input row */}
<div
style={{
maxWidth: 560,
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "20px 24px",
marginBottom: 32,
}}
>
<CadInput onSubmit={handleAnalyze} loading={isPending} />
</div>
{/* Error state */}
{error && (
<div
style={{
padding: "12px 20px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 10,
color: "#dc2626",
fontSize: 13,
marginBottom: 24,
}}
>
{error instanceof Error ? error.message : "Ошибка анализа участка"}
</div>
)}
{/* Pending skeleton */}
{isPending && (
<div
style={{
padding: 32,
textAlign: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
Анализируем участок
</div>
)}
{/* Empty initial state */}
{isIdle && !error && (
<div
style={{
padding: 40,
textAlign: "center",
color: "#d1d5db",
fontSize: 14,
border: "1px dashed #e5e7eb",
borderRadius: 12,
}}
>
Введите кадастровый номер и нажмите «Анализировать»
</div>
)}
{/* Results */}
{data && (
<div
style={{
display: "grid",
gridTemplateColumns: "320px 1fr",
gap: 24,
alignItems: "start",
}}
>
{/* Left column — score */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{data.cad_num}
</div>
<ScoreCard data={data} />
</div>
{/* Right column — map + competitors */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<SiteMap data={data} />
<CompetitorTable
competitors={data.competitors}
districtName={data.district?.district_name}
/>
</div>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
// Validates cadastral number formats:
// 3-part: 66:41:0204016 (quarter)
// 4-part: 66:41:0204016:10 (parcel)
const CAD_REGEX = /^\d+:\d+:\d+(?::\d+)?$/;
interface Props {
onSubmit: (cadNum: string) => void;
loading: boolean;
}
export function CadInput({ onSubmit, loading }: Props) {
const [value, setValue] = useState("66:41:0204016:10");
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = value.trim();
if (!CAD_REGEX.test(trimmed)) {
setError(
"Неверный формат. Ожидается: 66:41:0204016:10 (участок) или 66:41:0204016 (квартал)",
);
return;
}
setError(null);
onSubmit(trimmed);
}
return (
<form onSubmit={handleSubmit}>
<label
htmlFor="cad-input"
style={{
display: "block",
fontWeight: 600,
marginBottom: 6,
fontSize: 14,
}}
>
Кадастровый номер
</label>
<div style={{ display: "flex", gap: 8 }}>
<input
id="cad-input"
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
placeholder="66:41:0204016:10"
style={{
flex: 1,
padding: "8px 12px",
fontSize: 14,
border: error ? "1px solid #ef4444" : "1px solid #d1d5db",
borderRadius: 8,
outline: "none",
fontFamily: "monospace",
}}
disabled={loading}
/>
<button
type="submit"
disabled={loading || !value.trim()}
style={{
padding: "8px 18px",
fontSize: 14,
fontWeight: 600,
background: loading ? "#9ca3af" : "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 8,
cursor: loading ? "default" : "pointer",
whiteSpace: "nowrap",
}}
>
{loading ? "Анализ…" : "Анализировать"}
</button>
</div>
{error && (
<p style={{ marginTop: 6, fontSize: 12, color: "#ef4444" }}>{error}</p>
)}
<p style={{ marginTop: 4, fontSize: 11, color: "#9ca3af" }}>
Примеры: <code>66:41:0204016:10</code> (участок) ·{" "}
<code>66:41:0204016</code> (квартал)
</p>
</form>
);
}

View file

@ -0,0 +1,194 @@
"use client";
import type { ParcelAnalysisCompetitor } from "@/types/site-finder";
interface Props {
competitors: ParcelAnalysisCompetitor[];
districtName: string | null | undefined;
}
const CLASS_COLORS: Record<string, string> = {
Комфорт: "#1d4ed8",
"Комфорт+": "#2563eb",
Бизнес: "#7c3aed",
Эконом: "#6b7280",
Премиум: "#b45309",
};
export function CompetitorTable({ competitors, districtName }: Props) {
const top20 = competitors.slice(0, 20);
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
overflow: "hidden",
background: "#fff",
}}
>
<div
style={{
padding: "14px 20px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 600, fontSize: 14 }}>
Конкуренты ({competitors.length})
</span>
{districtName && (
<span style={{ fontSize: 12, color: "#6b7280" }}>
ЖК в районе &laquo;{districtName}&raquo; подсвечены
</span>
)}
</div>
{top20.length === 0 ? (
<div style={{ padding: 24, color: "#9ca3af", fontSize: 13 }}>
Конкурентов не найдено
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 12,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"ЖК",
"Девелопер",
"Класс",
"Квартир",
"Район",
"Расст., м",
].map((h) => (
<th
key={h}
style={{
padding: "8px 12px",
textAlign: "left",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
color: "#374151",
whiteSpace: "nowrap",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{top20.map((c, i) => {
const sameDistrict =
districtName && c.district_name === districtName;
return (
<tr
key={c.obj_id}
style={{
background: sameDistrict
? "#eff6ff"
: i % 2
? "#fafbfc"
: "#fff",
borderBottom: "1px solid #f3f4f6",
}}
>
<td
style={{
padding: "7px 12px",
fontWeight: sameDistrict ? 600 : 400,
color: "#111827",
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.comm_name ?? `ЖК #${c.obj_id}`}
</td>
<td
style={{
padding: "7px 12px",
color: "#6b7280",
maxWidth: 160,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.dev_name ?? "—"}
</td>
<td style={{ padding: "7px 12px" }}>
{c.obj_class ? (
<span
style={{
padding: "2px 6px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
background: CLASS_COLORS[c.obj_class] ?? "#9ca3af",
color: "#fff",
}}
>
{c.obj_class}
</span>
) : (
<span style={{ color: "#d1d5db" }}></span>
)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
}}
>
{c.flat_count != null
? c.flat_count.toLocaleString("ru-RU")
: "—"}
</td>
<td style={{ padding: "7px 12px", color: "#6b7280" }}>
{c.district_name ?? "—"}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(c.distance_m).toLocaleString("ru-RU")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{competitors.length > 20 && (
<div
style={{
padding: "8px 20px",
background: "#f9fafb",
borderTop: "1px solid #e5e7eb",
fontSize: 12,
color: "#9ca3af",
}}
>
Показано 20 из {competitors.length} ближайших ЖК
</div>
)}
</div>
);
}

View file

@ -0,0 +1,164 @@
"use client";
import type { ParcelAnalysis } from "@/types/site-finder";
interface Props {
data: ParcelAnalysis;
}
const CATEGORY_LABELS: Record<string, string> = {
school: "Школы",
kindergarten: "Детские сады",
pharmacy: "Аптеки",
hospital: "Больницы / клиники",
shop_mall: "ТЦ / ТРЦ",
shop_supermarket: "Супермаркеты",
shop_small: "Магазины",
park: "Парки",
tram_stop: "Трамвайные остановки",
bus_stop: "Автобусные остановки",
metro_stop: "Метро",
};
function scoreColor(score: number): string {
if (score > 5) return "#16a34a";
if (score >= 2) return "#d97706";
return "#dc2626";
}
function avgDist(items: Array<{ distance_m: number }>): number {
if (!items.length) return 0;
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
}
export function ScoreCard({ data }: Props) {
const tramPois = data.score_breakdown["tram_stop"] ?? [];
const nearestTram =
tramPois.length > 0
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
: null;
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
overflow: "hidden",
background: "#fff",
}}
>
{/* Score header */}
<div
style={{
padding: "20px 24px",
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
display: "flex",
alignItems: "center",
gap: 16,
}}
>
<div
style={{
fontSize: 48,
fontWeight: 800,
lineHeight: 1,
color: scoreColor(data.score),
}}
>
{data.score.toFixed(1)}
</div>
<div>
<div style={{ fontSize: 13, color: "#6b7280", marginBottom: 2 }}>
Социальный балл участка
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
</div>
</div>
{/* District */}
{data.district && (
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #e5e7eb",
fontSize: 13,
}}
>
<span style={{ fontWeight: 600 }}>{data.district.district_name}</span>
<span style={{ color: "#6b7280", marginLeft: 8 }}>
· медиана {(data.district.median_price_per_m2 / 1000).toFixed(0)}{" "}
тыс /м²
</span>
<span style={{ color: "#9ca3af", marginLeft: 8 }}>
· {(data.district.dist_to_center / 1000).toFixed(1)} км до центра
</span>
</div>
)}
{/* Tram warning */}
{nearestTram !== null && (
<div
style={{
padding: "10px 24px",
borderBottom: "1px solid #e5e7eb",
background: "#fff7ed",
fontSize: 13,
color: "#c2410c",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span></span>
<span>Трамвай в {nearestTram} м (возможный источник шума)</span>
</div>
)}
{/* POI breakdown */}
<div style={{ padding: "12px 24px" }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 10,
}}
>
POI по категориям
</div>
{Object.entries(data.score_breakdown).length === 0 ? (
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{Object.entries(data.score_breakdown).map(([cat, items]) => (
<div
key={cat}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 13,
}}
>
<span
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
>
{CATEGORY_LABELS[cat] ?? cat}
</span>
<span style={{ color: "#6b7280", fontSize: 12 }}>
{items.length} шт · ср. {avgDist(items)} м
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet";
import type { Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
import type { ParcelAnalysis } from "@/types/site-finder";
// ---------------------------------------------------------------------------
// POI legend config (for the legend row below the map)
// ---------------------------------------------------------------------------
interface CategoryStyle {
color: string;
label: string;
}
export const CATEGORY_STYLES: Record<string, CategoryStyle> = {
school: { color: "#2563eb", label: "Школа" },
kindergarten: { color: "#0891b2", label: "Детский сад" },
pharmacy: { color: "#16a34a", label: "Аптека" },
hospital: { color: "#059669", label: "Больница" },
shop_mall: { color: "#f59e0b", label: "ТЦ" },
shop_supermarket: { color: "#f97316", label: "Супермаркет" },
shop_small: { color: "#fb923c", label: "Магазин" },
park: { color: "#166534", label: "Парк" },
tram_stop: { color: "#dc2626", label: "Трамвай" },
bus_stop: { color: "#9ca3af", label: "Автобус" },
metro_stop: { color: "#7c3aed", label: "Метро" },
};
// ---------------------------------------------------------------------------
// Geometry centroid (bounding-box center — sufficient for zoom-to fit)
// ---------------------------------------------------------------------------
function flattenCoords(geom: Geometry): Position[] {
switch (geom.type) {
case "Point":
return [geom.coordinates];
case "MultiPoint":
case "LineString":
return geom.coordinates as Position[];
case "MultiLineString":
case "Polygon":
return (geom.coordinates as Position[][]).flat();
case "MultiPolygon":
return (geom.coordinates as Position[][][]).flat(2);
case "GeometryCollection":
return geom.geometries.flatMap(flattenCoords);
default:
return [];
}
}
function geomCenter(geom: Geometry): [number, number] {
const coords = flattenCoords(geom);
if (!coords.length) return [56.838, 60.6];
let minLat = Infinity,
maxLat = -Infinity,
minLon = Infinity,
maxLon = -Infinity;
for (const [lon, lat] of coords) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return [(minLat + maxLat) / 2, (minLon + maxLon) / 2];
}
// ---------------------------------------------------------------------------
// Props + Component
// ---------------------------------------------------------------------------
interface Props {
data: ParcelAnalysis;
}
export function SiteMap({ data }: Props) {
// Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => {
void import("leaflet").then((L) => {
// @ts-expect-error — _getIconUrl is internal Leaflet property
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
});
}, []);
const center: [number, number] = data.geom_geojson
? geomCenter(data.geom_geojson)
: [56.838, 60.6];
// Build legend from categories present in score_breakdown
const presentCategories = Object.keys(data.score_breakdown);
return (
<div>
{/* Map */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 12,
overflow: "hidden",
height: 420,
}}
>
<MapContainer
center={center}
zoom={15}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Parcel / quarter polygon */}
{data.geom_geojson && (
<GeoJSON
key={data.cad_num}
data={data.geom_geojson}
style={{
color: "#1d4ed8",
weight: 2.5,
fillColor: "#3b82f6",
fillOpacity: 0.2,
}}
/>
)}
</MapContainer>
</div>
{/* POI category legend */}
{presentCategories.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "6px 12px",
padding: "10px 4px 0",
}}
>
{presentCategories.map((cat) => {
const s = CATEGORY_STYLES[cat];
if (!s) return null;
const count = data.score_breakdown[cat]?.length ?? 0;
return (
<div
key={cat}
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 12,
color: "#374151",
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.color,
flexShrink: 0,
}}
/>
{s.label} ({count})
</div>
);
})}
</div>
)}
{!data.geom_geojson && (
<p
style={{
marginTop: 8,
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
}}
>
Геометрия участка не найдена на карте нет полигона
</p>
)}
</div>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import type { ParcelAnalysis } from "@/types/site-finder";
export function useSiteAnalysis() {
return useMutation({
mutationFn: (cad: string) =>
apiFetch<ParcelAnalysis>(
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
{ method: "POST" },
),
});
}

View file

@ -0,0 +1,47 @@
import type { Geometry } from "geojson";
export interface ParcelAnalysisCompetitor {
obj_id: number;
comm_name: string | null;
dev_name: string | null;
obj_class: string | null;
flat_count: number | null;
district_name: string | null;
distance_m: number;
}
export interface ParcelAnalysisDistrict {
district_name: string;
median_price_per_m2: number;
dist_to_center: number;
}
export interface ParcelAnalysisPoi {
name: string | null;
distance_m: number;
last_edit: string | null;
}
export interface ParcelAnalysis {
cad_num: string;
source: "cad_quarter" | "cad_building";
geom_geojson: Geometry | null;
district: ParcelAnalysisDistrict | null;
score: number;
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
poi_count: number;
competitors: ParcelAnalysisCompetitor[];
}
export type PoiCategory =
| "school"
| "kindergarten"
| "pharmacy"
| "hospital"
| "shop_mall"
| "shop_supermarket"
| "shop_small"
| "park"
| "tram_stop"
| "bus_stop"
| "metro_stop";