diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 2c485633..932ead45 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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], + } diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 1c19ed72..dd78c1de 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -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: () => ( +
+ Загрузка карты… +
+ ), + }, +); + export default function SiteFinderPage() { + const { mutate, data, isPending, error, isIdle } = useSiteAnalysis(); + + function handleAnalyze(cadNum: string) { + mutate(cadNum); + } + return ( -
- ← Home -

Site Finder

-

TODO Stage 2a: load 1000+ Sverdlovsk parcels into PostGIS.

-

- TODO Stage 2b: map (Mapbox) with color-graded parcels + filters + table - + card. +

+ {/* Breadcrumb */} +
+ + ← Главная + +
+ +

+ Site Finder +

+

+ Введите кадастровый номер участка или квартала для анализа локации

-

TODO Stage 2c: «Compute concept» button calling Generative module.

+ + {/* Input row */} +
+ +
+ + {/* Error state */} + {error && ( +
+ {error instanceof Error ? error.message : "Ошибка анализа участка"} +
+ )} + + {/* Pending skeleton */} + {isPending && ( +
+ Анализируем участок… +
+ )} + + {/* Empty initial state */} + {isIdle && !error && ( +
+ Введите кадастровый номер и нажмите «Анализировать» +
+ )} + + {/* Results */} + {data && ( +
+ {/* Left column — score */} +
+
+ {data.cad_num} +
+ +
+ + {/* Right column — map + competitors */} +
+ + +
+
+ )}
); } diff --git a/frontend/src/components/site-finder/CadInput.tsx b/frontend/src/components/site-finder/CadInput.tsx new file mode 100644 index 00000000..ab1dae4a --- /dev/null +++ b/frontend/src/components/site-finder/CadInput.tsx @@ -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(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 ( +
+ +
+ { + 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} + /> + +
+ {error && ( +

{error}

+ )} +

+ Примеры: 66:41:0204016:10 (участок) ·{" "} + 66:41:0204016 (квартал) +

+
+ ); +} diff --git a/frontend/src/components/site-finder/CompetitorTable.tsx b/frontend/src/components/site-finder/CompetitorTable.tsx new file mode 100644 index 00000000..3d0fbd1f --- /dev/null +++ b/frontend/src/components/site-finder/CompetitorTable.tsx @@ -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 = { + Комфорт: "#1d4ed8", + "Комфорт+": "#2563eb", + Бизнес: "#7c3aed", + Эконом: "#6b7280", + Премиум: "#b45309", +}; + +export function CompetitorTable({ competitors, districtName }: Props) { + const top20 = competitors.slice(0, 20); + + return ( +
+
+ + Конкуренты ({competitors.length}) + + {districtName && ( + + ЖК в районе «{districtName}» подсвечены + + )} +
+ + {top20.length === 0 ? ( +
+ Конкурентов не найдено +
+ ) : ( +
+ + + + {[ + "ЖК", + "Девелопер", + "Класс", + "Квартир", + "Район", + "Расст., м", + ].map((h) => ( + + ))} + + + + {top20.map((c, i) => { + const sameDistrict = + districtName && c.district_name === districtName; + return ( + + + + + + + + + ); + })} + +
+ {h} +
+ {c.comm_name ?? `ЖК #${c.obj_id}`} + + {c.dev_name ?? "—"} + + {c.obj_class ? ( + + {c.obj_class} + + ) : ( + + )} + + {c.flat_count != null + ? c.flat_count.toLocaleString("ru-RU") + : "—"} + + {c.district_name ?? "—"} + + {Math.round(c.distance_m).toLocaleString("ru-RU")} +
+
+ )} + {competitors.length > 20 && ( +
+ Показано 20 из {competitors.length} ближайших ЖК +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx new file mode 100644 index 00000000..bc7f6ca8 --- /dev/null +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -0,0 +1,164 @@ +"use client"; + +import type { ParcelAnalysis } from "@/types/site-finder"; + +interface Props { + data: ParcelAnalysis; +} + +const CATEGORY_LABELS: Record = { + 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 ( +
+ {/* Score header */} +
+
+ {data.score.toFixed(1)} +
+
+
+ Социальный балл участка +
+
+ {data.poi_count} POI ·{" "} + {data.source === "cad_quarter" ? "квартал" : "участок"} +
+
+
+ + {/* District */} + {data.district && ( +
+ {data.district.district_name} + + · медиана {(data.district.median_price_per_m2 / 1000).toFixed(0)}{" "} + тыс ₽/м² + + + · {(data.district.dist_to_center / 1000).toFixed(1)} км до центра + +
+ )} + + {/* Tram warning */} + {nearestTram !== null && ( +
+ + Трамвай в {nearestTram} м (возможный источник шума) +
+ )} + + {/* POI breakdown */} +
+
+ POI по категориям +
+ {Object.entries(data.score_breakdown).length === 0 ? ( +

Нет данных

+ ) : ( +
+ {Object.entries(data.score_breakdown).map(([cat, items]) => ( +
+ + {CATEGORY_LABELS[cat] ?? cat} + + + {items.length} шт · ср. {avgDist(items)} м + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx new file mode 100644 index 00000000..14304286 --- /dev/null +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -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 = { + 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 ( +
+ {/* Map */} +
+ + + + {/* Parcel / quarter polygon */} + {data.geom_geojson && ( + + )} + +
+ + {/* POI category legend */} + {presentCategories.length > 0 && ( +
+ {presentCategories.map((cat) => { + const s = CATEGORY_STYLES[cat]; + if (!s) return null; + const count = data.score_breakdown[cat]?.length ?? 0; + return ( +
+ + {s.label} ({count}) +
+ ); + })} +
+ )} + + {!data.geom_geojson && ( +

+ Геометрия участка не найдена — на карте нет полигона +

+ )} +
+ ); +} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts new file mode 100644 index 00000000..29d234b4 --- /dev/null +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -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( + `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, + { method: "POST" }, + ), + }); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts new file mode 100644 index 00000000..06668135 --- /dev/null +++ b/frontend/src/types/site-finder.ts @@ -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; + 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";