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 */}
+
+
+ {/* 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 (
+
+ );
+}
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) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {top20.map((c, i) => {
+ const sameDistrict =
+ districtName && c.district_name === districtName;
+ return (
+
+ |
+ {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";