feat(site-finder): isochrones UI + networks VKH + OSM substations

This commit is contained in:
lekss361 2026-05-11 21:48:21 +03:00
parent 923f250926
commit 5b03d6d799
7 changed files with 304 additions and 5 deletions

View file

@ -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"),
]

View file

@ -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<FeatureCollection | undefined>(
undefined,
);
function handleAnalyze(cadNum: string) {
setIsochrones(undefined);
mutate(cadNum);
}
@ -143,12 +149,12 @@ export default function SiteFinderPage() {
>
{data.cad_num}
</div>
<ScoreCard data={data} />
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
</div>
{/* Right column — map + competitors */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<SiteMap data={data} />
<SiteMap data={data} isochrones={isochrones} />
<CompetitorTable
competitors={data.competitors}
districtName={data.district?.district_name}

View file

@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import type { FeatureCollection } from "geojson";
import { useIsochrones } from "@/hooks/useIsochrones";
type IsoMode = "foot-walking" | "cycling-regular" | "driving-car";
interface Props {
cadNum: string;
onResult: (fc: FeatureCollection) => void;
}
const MODE_LABELS: Record<IsoMode, string> = {
"foot-walking": "Пешком",
"cycling-regular": "Велосипед",
"driving-car": "Машина",
};
const ALL_TIMES = [10, 15, 30] as const;
export function IsochronesPanel({ cadNum, onResult }: Props) {
const [mode, setMode] = useState<IsoMode>("foot-walking");
const [times, setTimes] = useState<Set<number>>(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 (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Изохроны доступности
</div>
{/* Mode radio */}
<div
style={{
display: "flex",
gap: 6,
marginBottom: 10,
flexWrap: "wrap",
}}
>
{(Object.keys(MODE_LABELS) as IsoMode[]).map((m) => (
<label
key={m}
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 13,
cursor: "pointer",
padding: "4px 10px",
borderRadius: 6,
border: `1px solid ${mode === m ? "#3b82f6" : "#e5e7eb"}`,
background: mode === m ? "#dbeafe" : "#fff",
color: mode === m ? "#1d4ed8" : "#374151",
userSelect: "none",
}}
>
<input
type="radio"
name="iso-mode"
value={m}
checked={mode === m}
onChange={() => setMode(m)}
style={{ display: "none" }}
/>
{MODE_LABELS[m]}
</label>
))}
</div>
{/* Time checkboxes */}
<div
style={{
display: "flex",
gap: 6,
marginBottom: 12,
flexWrap: "wrap",
}}
>
{ALL_TIMES.map((t) => {
const checked = times.has(t);
return (
<label
key={t}
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 13,
cursor: "pointer",
padding: "4px 10px",
borderRadius: 6,
border: `1px solid ${checked ? "#3b82f6" : "#e5e7eb"}`,
background: checked ? "#dbeafe" : "#fff",
color: checked ? "#1d4ed8" : "#374151",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTime(t)}
style={{ display: "none" }}
/>
{t} мин
</label>
);
})}
</div>
{/* Show button */}
<button
onClick={handleShow}
disabled={isPending || times.size === 0}
style={{
padding: "7px 16px",
borderRadius: 8,
border: "none",
background: isPending || times.size === 0 ? "#e5e7eb" : "#3b82f6",
color: isPending || times.size === 0 ? "#9ca3af" : "#fff",
fontSize: 13,
fontWeight: 600,
cursor: isPending || times.size === 0 ? "not-allowed" : "pointer",
}}
>
{isPending ? "Загрузка…" : "Показать на карте"}
</button>
{/* Error */}
{isError && (
<div
style={{
marginTop: 8,
fontSize: 12,
color: "#dc2626",
}}
>
{error instanceof Error ? error.message : "Ошибка запроса изохрон"}
</div>
)}
</div>
);
}

View file

@ -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<string, string> = {
@ -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) {
<GeologyBlock geology={data.geology} />
</div>
)}
{/* Isochrones */}
{data.isochrones_available === false ? (
<div
style={{
padding: "10px 24px 14px",
borderTop: "1px solid #e5e7eb",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Изохроны доступности
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
Недоступны нужен OpenRouteService API key (см.{" "}
<code style={{ fontFamily: "monospace" }}>
backend/.env.runtime
</code>
)
</div>
</div>
) : data.isochrones_available === true && onIsochronesResult ? (
<IsochronesPanel cadNum={data.cad_num} onResult={onIsochronesResult} />
) : null}
</div>
);
}

View file

@ -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 (
<GeoJSON
key={`iso-${idx}-${seconds}`}
data={feature as Feature}
style={{
color,
fillColor: color,
fillOpacity: opacity,
weight: 1,
}}
/>
);
})}
{/* POI markers */}
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
const style = CATEGORY_STYLES[cat];

View file

@ -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<IsochronesResponse>(
`/api/v1/parcels/${encodeURIComponent(cadNum)}/isochrones?mode=${mode}&${timesMin.map((t) => `times_min=${t}`).join("&")}`,
),
});
}

View file

@ -158,6 +158,7 @@ export interface ParcelAnalysis {
hydrology?: Hydrology;
geotech_risk?: GeotechRisk;
geology?: ParcelAnalysisGeology;
isochrones_available?: boolean;
}
export type PoiCategory =