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", "line", "utility", "power_line"),
|
||||||
("power", "minor_line", "utility", "power_line"),
|
("power", "minor_line", "utility", "power_line"),
|
||||||
("man_made", "pipeline", "utility", "pipeline"),
|
("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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { FeatureCollection } from "geojson";
|
||||||
|
|
||||||
import { CadInput } from "@/components/site-finder/CadInput";
|
import { CadInput } from "@/components/site-finder/CadInput";
|
||||||
import { ScoreCard } from "@/components/site-finder/ScoreCard";
|
import { ScoreCard } from "@/components/site-finder/ScoreCard";
|
||||||
|
|
@ -35,8 +37,12 @@ const SiteMap = dynamic(
|
||||||
|
|
||||||
export default function SiteFinderPage() {
|
export default function SiteFinderPage() {
|
||||||
const { mutate, data, isPending, error, isIdle } = useSiteAnalysis();
|
const { mutate, data, isPending, error, isIdle } = useSiteAnalysis();
|
||||||
|
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
function handleAnalyze(cadNum: string) {
|
function handleAnalyze(cadNum: string) {
|
||||||
|
setIsochrones(undefined);
|
||||||
mutate(cadNum);
|
mutate(cadNum);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,12 +149,12 @@ export default function SiteFinderPage() {
|
||||||
>
|
>
|
||||||
{data.cad_num}
|
{data.cad_num}
|
||||||
</div>
|
</div>
|
||||||
<ScoreCard data={data} />
|
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column — map + competitors */}
|
{/* Right column — map + competitors */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||||
<SiteMap data={data} />
|
<SiteMap data={data} isochrones={isochrones} />
|
||||||
<CompetitorTable
|
<CompetitorTable
|
||||||
competitors={data.competitors}
|
competitors={data.competitors}
|
||||||
districtName={data.district?.district_name}
|
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,
|
ParcelAnalysisWind,
|
||||||
ParcelAnalysisWeather,
|
ParcelAnalysisWeather,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
|
import type { FeatureCollection } from "geojson";
|
||||||
|
|
||||||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||||
import { GeologyBlock } from "./GeologyBlock";
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
|
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
|
||||||
import { HydrologyBlock } from "./HydrologyBlock";
|
import { HydrologyBlock } from "./HydrologyBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
|
import { IsochronesPanel } from "./IsochronesPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
onIsochronesResult?: (fc: FeatureCollection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
|
@ -479,7 +483,7 @@ function WeatherBlock({
|
||||||
|
|
||||||
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ScoreCard({ data }: Props) {
|
export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
||||||
const nearestTram =
|
const nearestTram =
|
||||||
tramPois.length > 0
|
tramPois.length > 0
|
||||||
|
|
@ -782,6 +786,38 @@ export function ScoreCard({ data }: Props) {
|
||||||
<GeologyBlock geology={data.geology} />
|
<GeologyBlock geology={data.geology} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
Popup,
|
Popup,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import type { Geometry, Position } from "geojson";
|
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
|
@ -82,9 +82,10 @@ function geomCenter(geom: Geometry): [number, number] {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
isochrones?: FeatureCollection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteMap({ data }: Props) {
|
export function SiteMap({ data, isochrones }: Props) {
|
||||||
// Fix Leaflet default icon paths broken by webpack bundler
|
// Fix Leaflet default icon paths broken by webpack bundler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("leaflet").then((L) => {
|
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 */}
|
{/* POI markers */}
|
||||||
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
||||||
const style = CATEGORY_STYLES[cat];
|
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;
|
hydrology?: Hydrology;
|
||||||
geotech_risk?: GeotechRisk;
|
geotech_risk?: GeotechRisk;
|
||||||
geology?: ParcelAnalysisGeology;
|
geology?: ParcelAnalysisGeology;
|
||||||
|
isochrones_available?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue