From 5b03d6d799bf16d5533d1c202acc5e066f27cb5b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 21:48:21 +0300 Subject: [PATCH] feat(site-finder): isochrones UI + networks VKH + OSM substations --- .../app/services/site_finder/noise_loader.py | 7 + frontend/src/app/site-finder/page.tsx | 10 +- .../site-finder/IsochronesPanel.tsx | 185 ++++++++++++++++++ .../src/components/site-finder/ScoreCard.tsx | 38 +++- .../src/components/site-finder/SiteMap.tsx | 34 +++- frontend/src/hooks/useIsochrones.ts | 34 ++++ frontend/src/types/site-finder.ts | 1 + 7 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/site-finder/IsochronesPanel.tsx create mode 100644 frontend/src/hooks/useIsochrones.ts diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index bf0084b7..cc2f4f6e 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -45,6 +45,13 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [ ("power", "line", "utility", "power_line"), ("power", "minor_line", "utility", "power_line"), ("man_made", "pipeline", "utility", "pipeline"), + # Сети ВКХ (водоснабжение / канализация) — узлы, не магистрали + ("man_made", "water_works", "utility", "water_works"), + ("man_made", "wastewater_plant", "utility", "wastewater_plant"), + ("man_made", "water_tower", "utility", "water_tower"), + ("man_made", "storage_tank", "utility", "storage_tank"), + # Электроподстанции — потенциальные точки подключения 10/35/110 кВ + ("power", "substation", "utility", "substation"), ] diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index dd78c1de..41d7f8b7 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,7 +1,9 @@ "use client"; +import { useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; +import type { FeatureCollection } from "geojson"; import { CadInput } from "@/components/site-finder/CadInput"; import { ScoreCard } from "@/components/site-finder/ScoreCard"; @@ -35,8 +37,12 @@ const SiteMap = dynamic( export default function SiteFinderPage() { const { mutate, data, isPending, error, isIdle } = useSiteAnalysis(); + const [isochrones, setIsochrones] = useState( + undefined, + ); function handleAnalyze(cadNum: string) { + setIsochrones(undefined); mutate(cadNum); } @@ -143,12 +149,12 @@ export default function SiteFinderPage() { > {data.cad_num} - + {/* Right column — map + competitors */}
- + void; +} + +const MODE_LABELS: Record = { + "foot-walking": "Пешком", + "cycling-regular": "Велосипед", + "driving-car": "Машина", +}; + +const ALL_TIMES = [10, 15, 30] as const; + +export function IsochronesPanel({ cadNum, onResult }: Props) { + const [mode, setMode] = useState("foot-walking"); + const [times, setTimes] = useState>(new Set([10, 15])); + + const { mutate, isPending, isError, error } = useIsochrones(); + + function toggleTime(t: number) { + setTimes((prev) => { + const next = new Set(prev); + if (next.has(t)) { + next.delete(t); + } else { + next.add(t); + } + return next; + }); + } + + function handleShow() { + const timesMin = ALL_TIMES.filter((t) => times.has(t)); + if (timesMin.length === 0) return; + mutate( + { cadNum, mode, timesMin }, + { + onSuccess: (res) => { + onResult(res.geojson); + }, + }, + ); + } + + return ( +
+
+ Изохроны доступности +
+ + {/* Mode radio */} +
+ {(Object.keys(MODE_LABELS) as IsoMode[]).map((m) => ( + + ))} +
+ + {/* Time checkboxes */} +
+ {ALL_TIMES.map((t) => { + const checked = times.has(t); + return ( + + ); + })} +
+ + {/* Show button */} + + + {/* Error */} + {isError && ( +
+ {error instanceof Error ? error.message : "Ошибка запроса изохрон"} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index b4b0ef84..747e813b 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -8,14 +8,18 @@ import type { ParcelAnalysisWind, ParcelAnalysisWeather, } from "@/types/site-finder"; +import type { FeatureCollection } from "geojson"; + import { MarketTrendBlock } from "./MarketTrendBlock"; import { GeologyBlock } from "./GeologyBlock"; import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; import { HydrologyBlock } from "./HydrologyBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; +import { IsochronesPanel } from "./IsochronesPanel"; interface Props { data: ParcelAnalysis; + onIsochronesResult?: (fc: FeatureCollection) => void; } const CATEGORY_LABELS: Record = { @@ -479,7 +483,7 @@ function WeatherBlock({ // ── ScoreCard ───────────────────────────────────────────────────────────────── -export function ScoreCard({ data }: Props) { +export function ScoreCard({ data, onIsochronesResult }: Props) { const tramPois = data.score_breakdown["tram_stop"] ?? []; const nearestTram = tramPois.length > 0 @@ -782,6 +786,38 @@ export function ScoreCard({ data }: Props) {
)} + + {/* Isochrones */} + {data.isochrones_available === false ? ( +
+
+ Изохроны доступности +
+
+ Недоступны — нужен OpenRouteService API key (см.{" "} + + backend/.env.runtime + + ) +
+
+ ) : data.isochrones_available === true && onIsochronesResult ? ( + + ) : null} ); } diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 5ea673f3..10cc3392 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -8,7 +8,7 @@ import { CircleMarker, Popup, } from "react-leaflet"; -import type { Geometry, Position } from "geojson"; +import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import "leaflet/dist/leaflet.css"; import type { ParcelAnalysis } from "@/types/site-finder"; @@ -82,9 +82,10 @@ function geomCenter(geom: Geometry): [number, number] { interface Props { data: ParcelAnalysis; + isochrones?: FeatureCollection; } -export function SiteMap({ data }: Props) { +export function SiteMap({ data, isochrones }: Props) { // Fix Leaflet default icon paths broken by webpack bundler useEffect(() => { void import("leaflet").then((L) => { @@ -143,6 +144,35 @@ export function SiteMap({ data }: Props) { /> )} + {/* Isochrones — render in reverse order (largest range at bottom) */} + {isochrones && + [...isochrones.features].reverse().map((feature, idx) => { + const seconds = + typeof feature?.properties?.value === "number" + ? (feature.properties.value as number) + : 0; + const minutes = seconds / 60; + const opacity = Math.max(0.2, 0.5 - minutes * 0.02); + const color = + minutes <= 10 + ? "#16a34a" + : minutes <= 20 + ? "#3b82f6" + : "#a855f7"; + return ( + + ); + })} + {/* POI markers */} {Object.entries(data.score_breakdown).flatMap(([cat, pois]) => { const style = CATEGORY_STYLES[cat]; diff --git a/frontend/src/hooks/useIsochrones.ts b/frontend/src/hooks/useIsochrones.ts new file mode 100644 index 00000000..fd61632e --- /dev/null +++ b/frontend/src/hooks/useIsochrones.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import type { FeatureCollection } from "geojson"; + +import { apiFetch } from "@/lib/api"; + +export interface IsochronesResponse { + cad_num: string; + lat: number; + lon: number; + mode: string; + times_min: number[]; + geojson: FeatureCollection; + source: string; + note: string; +} + +export function useIsochrones() { + return useMutation({ + mutationFn: ({ + cadNum, + mode, + timesMin, + }: { + cadNum: string; + mode: string; + timesMin: number[]; + }) => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum)}/isochrones?mode=${mode}&${timesMin.map((t) => `times_min=${t}`).join("&")}`, + ), + }); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 1c7eec75..343a595b 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -158,6 +158,7 @@ export interface ParcelAnalysis { hydrology?: Hydrology; geotech_risk?: GeotechRisk; geology?: ParcelAnalysisGeology; + isochrones_available?: boolean; } export type PoiCategory =