feat(site-finder): isochrones UI + networks VKH + OSM substations
This commit is contained in:
parent
923f250926
commit
5b03d6d799
7 changed files with 304 additions and 5 deletions
|
|
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
185
frontend/src/components/site-finder/IsochronesPanel.tsx
Normal file
185
frontend/src/components/site-finder/IsochronesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
34
frontend/src/hooks/useIsochrones.ts
Normal file
34
frontend/src/hooks/useIsochrones.ts
Normal 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("&")}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -158,6 +158,7 @@ export interface ParcelAnalysis {
|
|||
hydrology?: Hydrology;
|
||||
geotech_risk?: GeotechRisk;
|
||||
geology?: ParcelAnalysisGeology;
|
||||
isochrones_available?: boolean;
|
||||
}
|
||||
|
||||
export type PoiCategory =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue