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:
parent
6dea34186b
commit
a4e8fff5ac
8 changed files with 1061 additions and 9 deletions
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
93
frontend/src/components/site-finder/CadInput.tsx
Normal file
93
frontend/src/components/site-finder/CadInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/site-finder/CompetitorTable.tsx
Normal file
194
frontend/src/components/site-finder/CompetitorTable.tsx
Normal 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" }}>
|
||||
ЖК в районе «{districtName}» подсвечены
|
||||
</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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/site-finder/ScoreCard.tsx
Normal file
164
frontend/src/components/site-finder/ScoreCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
frontend/src/components/site-finder/SiteMap.tsx
Normal file
196
frontend/src/components/site-finder/SiteMap.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/hooks/useSiteAnalysis.ts
Normal file
16
frontend/src/hooks/useSiteAnalysis.ts
Normal 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" },
|
||||
),
|
||||
});
|
||||
}
|
||||
47
frontend/src/types/site-finder.ts
Normal file
47
frontend/src/types/site-finder.ts
Normal 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";
|
||||
Loading…
Add table
Reference in a new issue