feat(concept): Concept UI Stage 1d (#57) #1316
7 changed files with 1595 additions and 12 deletions
|
|
@ -1,21 +1,307 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concept (Generative Design) — Stage 1d UI.
|
||||||
|
*
|
||||||
|
* Flow: define the parcel boundary (draw a polygon OR resolve from a cadastre
|
||||||
|
* number) → set ConceptInput parameters → POST /api/v1/concepts → render the
|
||||||
|
* returned variants (placement map + ТЭП + financial model + export).
|
||||||
|
*
|
||||||
|
* Contract: backend/app/schemas/concept.py (ConceptInput / ConceptOutput).
|
||||||
|
* Map is lazy-mounted (Leaflet uses `window`); data-fetching via TanStack
|
||||||
|
* useMutation through the shared apiFetch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MapPin, Pencil } from "lucide-react";
|
||||||
|
import type { Polygon } from "geojson";
|
||||||
|
|
||||||
|
import { CadInput } from "@/components/site-finder/CadInput";
|
||||||
|
import { Section } from "@/components/analytics/Section";
|
||||||
|
import {
|
||||||
|
ConceptParamsForm,
|
||||||
|
DEFAULT_PARAMS,
|
||||||
|
type ConceptParams,
|
||||||
|
} from "@/components/concept/ConceptParamsForm";
|
||||||
|
import { ConceptVariantsResult } from "@/components/concept/ConceptVariantsResult";
|
||||||
|
import {
|
||||||
|
polygonAreaSqm,
|
||||||
|
useCadastreGeom,
|
||||||
|
useCreateConcept,
|
||||||
|
} from "@/lib/concept-api";
|
||||||
|
|
||||||
|
// ConceptDrawMap uses Leaflet + leaflet-draw — must load without SSR.
|
||||||
|
const ConceptDrawMap = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/concept/ConceptDrawMap").then((m) => m.ConceptDrawMap),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 420,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px dashed var(--border-strong)",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка карты…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type InputMode = "draw" | "cadastre";
|
||||||
|
|
||||||
|
const ha = (sqm: number) =>
|
||||||
|
(sqm / 10_000).toLocaleString("ru-RU", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
export default function ConceptPage() {
|
export default function ConceptPage() {
|
||||||
|
const [mode, setMode] = useState<InputMode>("draw");
|
||||||
|
const [polygon, setPolygon] = useState<Polygon | null>(null);
|
||||||
|
const [params, setParams] = useState<ConceptParams>(DEFAULT_PARAMS);
|
||||||
|
|
||||||
|
const cadastreGeom = useCadastreGeom();
|
||||||
|
const concept = useCreateConcept();
|
||||||
|
|
||||||
|
function handleCadSubmit(cad: string) {
|
||||||
|
cadastreGeom.mutate(cad, {
|
||||||
|
onSuccess: (geom) => {
|
||||||
|
setPolygon(geom);
|
||||||
|
// Fresh boundary invalidates a previous concept run.
|
||||||
|
concept.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
if (!polygon) return;
|
||||||
|
concept.mutate({
|
||||||
|
parcel_geojson: polygon,
|
||||||
|
housing_class: params.housing_class,
|
||||||
|
target_floors: params.target_floors,
|
||||||
|
development_type: params.development_type,
|
||||||
|
land_cost_rub: params.land_cost_rub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: 24, maxWidth: 1200, margin: "0 auto" }}>
|
<main style={{ minHeight: "100vh", background: "var(--bg-app)" }}>
|
||||||
<Link href="/">← Home</Link>
|
{/* Header */}
|
||||||
<h1>Concept (Generative Design)</h1>
|
<header
|
||||||
<p>
|
style={{
|
||||||
TODO Stage 1a: polygon import (GeoJSON / Leaflet draw) + parameter form
|
background: "var(--bg-card)",
|
||||||
+ result tabs.
|
borderBottom: "1px solid var(--border-card)",
|
||||||
</p>
|
padding: "12px 24px",
|
||||||
<p>TODO Stage 1b: render 3 variants on the map with colored buildings.</p>
|
display: "flex",
|
||||||
<p>
|
alignItems: "center",
|
||||||
TODO Stage 1c: TEAP table, financial form, PDF/Excel/DXF download
|
gap: 16,
|
||||||
buttons.
|
}}
|
||||||
</p>
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Главная
|
||||||
|
</Link>
|
||||||
|
<span style={{ color: "var(--border-soft)" }}>·</span>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Концепция застройки · генеративный дизайн
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{ maxWidth: 1280, margin: "0 auto", padding: "16px 24px 48px" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 16px",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
maxWidth: 760,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Задайте границы участка и параметры проекта — движок предложит три
|
||||||
|
стратегии размещения застройки с расчётом ТЭП и финансовой модели.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Input row: parcel boundary (left) + parameters (right) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr) 320px",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Parcel boundary */}
|
||||||
|
<Section
|
||||||
|
title="Границы участка"
|
||||||
|
subtitle="Нарисуйте полигон на карте или укажите кадастровый номер."
|
||||||
|
right={
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Способ ввода участка"
|
||||||
|
style={{ display: "flex", gap: 4 }}
|
||||||
|
>
|
||||||
|
<ModeTab
|
||||||
|
active={mode === "draw"}
|
||||||
|
onClick={() => setMode("draw")}
|
||||||
|
icon={<Pencil size={14} strokeWidth={1.5} />}
|
||||||
|
label="Нарисовать"
|
||||||
|
/>
|
||||||
|
<ModeTab
|
||||||
|
active={mode === "cadastre"}
|
||||||
|
onClick={() => setMode("cadastre")}
|
||||||
|
icon={<MapPin size={14} strokeWidth={1.5} />}
|
||||||
|
label="Кадастр"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mode === "cadastre" && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<CadInput
|
||||||
|
onSubmit={handleCadSubmit}
|
||||||
|
loading={cadastreGeom.isPending}
|
||||||
|
/>
|
||||||
|
{cadastreGeom.isError && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "8px 0 0",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--danger)",
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{cadastreGeom.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConceptDrawMap polygon={polygon} onChange={setPolygon} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 13,
|
||||||
|
color: polygon ? "var(--fg-primary)" : "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{polygon
|
||||||
|
? `Участок задан · площадь ≈ ${ha(parcelAreaSqm)} га`
|
||||||
|
: "Участок не задан."}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Parameters */}
|
||||||
|
<Section
|
||||||
|
title="Параметры проекта"
|
||||||
|
subtitle="Класс, тип застройки и этажность."
|
||||||
|
>
|
||||||
|
<ConceptParamsForm
|
||||||
|
params={params}
|
||||||
|
onChange={setParams}
|
||||||
|
onSubmit={handleGenerate}
|
||||||
|
hasPolygon={polygon != null}
|
||||||
|
isPending={concept.isPending}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{concept.isError && (
|
||||||
|
<Section title="Варианты застройки">
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Не удалось рассчитать концепции: {concept.error.message}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{concept.isPending && (
|
||||||
|
<Section title="Варианты застройки">
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}
|
||||||
|
>
|
||||||
|
Движок рассчитывает варианты размещения…
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{concept.isSuccess && polygon && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<ConceptVariantsResult
|
||||||
|
parcel={polygon}
|
||||||
|
variants={concept.data.variants}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Input-mode tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ModeTabProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeTab({ active, onClick, icon, label }: ModeTabProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: active ? "var(--accent-soft)" : "transparent",
|
||||||
|
color: active ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
|
border: active
|
||||||
|
? "1px solid var(--accent-soft)"
|
||||||
|
: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
226
frontend/src/components/concept/ConceptDrawMap.tsx
Normal file
226
frontend/src/components/concept/ConceptDrawMap.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConceptDrawMap — Leaflet draw-polygon input for the parcel boundary.
|
||||||
|
*
|
||||||
|
* Uses the imperative `L.Draw.Polygon` handler (leaflet-draw) inside a
|
||||||
|
* `useMap()` child rather than the react-leaflet-draw wrapper (not a repo dep).
|
||||||
|
* The drawn ring is emitted upward as a GeoJSON Polygon (WGS84) for
|
||||||
|
* ConceptInput.parcel_geojson. Re-using the react-leaflet + OSM TileLayer
|
||||||
|
* pattern from EntryMap / SiteMap. Lazy-mounted (ssr:false) by the page —
|
||||||
|
* Leaflet touches `window`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet-draw";
|
||||||
|
import type { Polygon } from "geojson";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import "leaflet-draw/dist/leaflet.draw.css";
|
||||||
|
|
||||||
|
// Yekaterinburg center — matches EntryMap default view.
|
||||||
|
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||||
|
const DEFAULT_ZOOM = 13;
|
||||||
|
|
||||||
|
// ── Draw handler child (must live inside MapContainer for useMap) ─────────────
|
||||||
|
|
||||||
|
interface DrawLayerProps {
|
||||||
|
/** Drawing requested by the parent toolbar (toggled on each click). */
|
||||||
|
drawTick: number;
|
||||||
|
/** Clear requested by the parent toolbar. */
|
||||||
|
clearTick: number;
|
||||||
|
onPolygon: (polygon: Polygon) => void;
|
||||||
|
onCleared: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawLayer({
|
||||||
|
drawTick,
|
||||||
|
clearTick,
|
||||||
|
onPolygon,
|
||||||
|
onCleared,
|
||||||
|
}: DrawLayerProps) {
|
||||||
|
const map = useMap();
|
||||||
|
const groupRef = useRef<L.FeatureGroup | null>(null);
|
||||||
|
const handlerRef = useRef<L.Draw.Polygon | null>(null);
|
||||||
|
|
||||||
|
// One FeatureGroup holds the single drawn polygon; wire the CREATED event.
|
||||||
|
useEffect(() => {
|
||||||
|
const group = new L.FeatureGroup();
|
||||||
|
group.addTo(map);
|
||||||
|
groupRef.current = group;
|
||||||
|
|
||||||
|
const onCreated = (e: L.LeafletEvent) => {
|
||||||
|
const { layer } = e as L.DrawEvents.Created;
|
||||||
|
group.clearLayers();
|
||||||
|
group.addLayer(layer);
|
||||||
|
const gj = (layer as L.Polygon).toGeoJSON();
|
||||||
|
if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
|
||||||
|
onPolygon(gj.geometry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on(L.Draw.Event.CREATED, onCreated);
|
||||||
|
return () => {
|
||||||
|
map.off(L.Draw.Event.CREATED, onCreated);
|
||||||
|
group.remove();
|
||||||
|
};
|
||||||
|
// map identity is stable for the MapContainer lifetime; onPolygon is stable.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Start the polygon draw handler when the parent toolbar bumps drawTick.
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawTick === 0) return;
|
||||||
|
handlerRef.current?.disable();
|
||||||
|
const handler = new L.Draw.Polygon(map as L.DrawMap, {
|
||||||
|
allowIntersection: false,
|
||||||
|
showArea: false,
|
||||||
|
shapeOptions: {
|
||||||
|
color: "#1d4ed8",
|
||||||
|
weight: 2.5,
|
||||||
|
fillColor: "#3b82f6",
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handlerRef.current = handler;
|
||||||
|
handler.enable();
|
||||||
|
return () => {
|
||||||
|
handler.disable();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [drawTick]);
|
||||||
|
|
||||||
|
// Clear the drawn polygon when the parent toolbar bumps clearTick.
|
||||||
|
useEffect(() => {
|
||||||
|
if (clearTick === 0) return;
|
||||||
|
handlerRef.current?.disable();
|
||||||
|
groupRef.current?.clearLayers();
|
||||||
|
onCleared();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [clearTick]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Externally provided polygon (e.g. resolved from a cadastre number). */
|
||||||
|
polygon: Polygon | null;
|
||||||
|
onChange: (polygon: Polygon | null) => void;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConceptDrawMap({ polygon, onChange, height = 420 }: Props) {
|
||||||
|
// Tick counters bumped by the toolbar buttons drive the DrawLayer effects
|
||||||
|
// (start-draw / clear) without exposing a Leaflet ref to the parent.
|
||||||
|
const [drawTick, setDrawTick] = useState(0);
|
||||||
|
const [clearTick, setClearTick] = useState(0);
|
||||||
|
|
||||||
|
function handleDraw() {
|
||||||
|
setDrawTick((t) => t + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
setClearTick((t) => t + 1);
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", width: "100%", height }}>
|
||||||
|
<MapContainer
|
||||||
|
center={EKB_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ width: "100%", height: "100%", borderRadius: 12 }}
|
||||||
|
scrollWheelZoom
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{/* Externally supplied polygon (cadastre → geom). Keyed on the ring so
|
||||||
|
react-leaflet remounts the layer when the geometry changes. */}
|
||||||
|
{polygon && (
|
||||||
|
<GeoJSON
|
||||||
|
key={JSON.stringify(polygon.coordinates[0]?.[0] ?? [])}
|
||||||
|
data={polygon}
|
||||||
|
style={{
|
||||||
|
color: "#1d4ed8",
|
||||||
|
weight: 2.5,
|
||||||
|
fillColor: "#3b82f6",
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DrawLayer
|
||||||
|
drawTick={drawTick}
|
||||||
|
clearTick={clearTick}
|
||||||
|
onPolygon={onChange}
|
||||||
|
onCleared={() => {}}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Toolbar — primary draw + clear; overlays the map top-left. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDraw}
|
||||||
|
aria-label="Нарисовать полигон участка"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={14} strokeWidth={1.5} />
|
||||||
|
Нарисовать участок
|
||||||
|
</button>
|
||||||
|
{polygon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label="Очистить полигон"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
frontend/src/components/concept/ConceptExportButtons.tsx
Normal file
210
frontend/src/components/concept/ConceptExportButtons.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConceptExportButtons — export controls for one concept variant.
|
||||||
|
*
|
||||||
|
* The Stage-1a backend contract (`backend/app/api/v1/concepts.py`) ships only
|
||||||
|
* `POST /concepts`; no export endpoint exists yet. So:
|
||||||
|
* - GeoJSON (buildings) + CSV (ТЭП + финмодель) are generated client-side
|
||||||
|
* here, re-using `triggerDownload` and the CSV-injection-safe escaping from
|
||||||
|
* the Site Finder ExportButtons.
|
||||||
|
* - DXF / PDF are produced server-side (ezdxf / WeasyPrint per the stack).
|
||||||
|
* We POST the variant to the documented `/api/v1/concepts/export` path and
|
||||||
|
* download the blob; until that endpoint lands the button reports a clear
|
||||||
|
* "не готово" message instead of failing silently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import { triggerDownload } from "@/lib/download";
|
||||||
|
import { STRATEGY_LABELS, type ConceptVariant } from "@/lib/concept-api";
|
||||||
|
|
||||||
|
// ── CSV helpers (mirrors site-finder ExportButtons) ───────────────────────────
|
||||||
|
|
||||||
|
function escapeCsvCell(cell: string): string {
|
||||||
|
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
|
||||||
|
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
|
||||||
|
// starters with a leading apostrophe so the spreadsheet treats them as text.
|
||||||
|
let safe = cell;
|
||||||
|
if (/^[=+\-@\t\r]/.test(safe)) {
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
const needsQuotes = /[",\n\r]/.test(safe);
|
||||||
|
if (needsQuotes) {
|
||||||
|
return `"${safe.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvRows(variant: ConceptVariant): string[][] {
|
||||||
|
const t = variant.teap;
|
||||||
|
const f = variant.financial;
|
||||||
|
return [
|
||||||
|
["Показатель", "Значение"],
|
||||||
|
["Стратегия", STRATEGY_LABELS[variant.strategy]],
|
||||||
|
["Площадь застройки, м²", String(t.built_area_sqm)],
|
||||||
|
["Общая площадь, м²", String(t.total_floor_area_sqm)],
|
||||||
|
["Жилая площадь, м²", String(t.residential_area_sqm)],
|
||||||
|
["Квартир, шт", String(t.apartments_count)],
|
||||||
|
["Плотность", String(t.density)],
|
||||||
|
["Машино-мест", String(t.parking_spaces)],
|
||||||
|
["Выручка, ₽", String(f.revenue_rub)],
|
||||||
|
["Затраты, ₽", String(f.cost_rub)],
|
||||||
|
["Валовая прибыль, ₽", String(f.gross_margin_rub)],
|
||||||
|
["IRR", String(f.irr)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCsvBlob(rows: string[][]): Blob {
|
||||||
|
const csv = rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n");
|
||||||
|
// UTF-8 BOM so Excel renders Cyrillic correctly.
|
||||||
|
return new Blob(["" + csv], { type: "text/csv;charset=utf-8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant: ConceptVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConceptExportButtons({ variant }: Props) {
|
||||||
|
const [serverLoading, setServerLoading] = useState<"dxf" | "pdf" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
|
||||||
|
|
||||||
|
function handleGeojson() {
|
||||||
|
setError(null);
|
||||||
|
const blob = new Blob(
|
||||||
|
[JSON.stringify(variant.buildings_geojson, null, 2)],
|
||||||
|
{
|
||||||
|
type: "application/geo+json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
triggerDownload(blob, `${fileStem}.geojson`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCsv() {
|
||||||
|
setError(null);
|
||||||
|
const blob = generateCsvBlob(buildCsvRows(variant));
|
||||||
|
triggerDownload(blob, `${fileStem}.csv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleServerExport(format: "dxf" | "pdf") {
|
||||||
|
setServerLoading(format);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/concepts/export?format=${format}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(variant),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.status === 404 || res.status === 405) {
|
||||||
|
setError(
|
||||||
|
format === "dxf"
|
||||||
|
? "Экспорт DXF появится после публикации движка генерации."
|
||||||
|
: "Экспорт PDF появится после публикации движка генерации.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ошибка сервера ${res.status}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
triggerDownload(blob, `${fileStem}.${format}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: `Ошибка экспорта ${format.toUpperCase()}`;
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setServerLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryBtn: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: "var(--accent-2)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGeojson}
|
||||||
|
aria-label="Скачать геометрию зданий GeoJSON"
|
||||||
|
style={secondaryBtn}
|
||||||
|
>
|
||||||
|
<MapIcon size={14} strokeWidth={1.5} />
|
||||||
|
GeoJSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCsv}
|
||||||
|
aria-label="Скачать ТЭП и финмодель CSV"
|
||||||
|
style={secondaryBtn}
|
||||||
|
>
|
||||||
|
<Download size={14} strokeWidth={1.5} />
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleServerExport("dxf")}
|
||||||
|
disabled={serverLoading !== null}
|
||||||
|
aria-label="Скачать DXF"
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity: serverLoading !== null ? 0.6 : 1,
|
||||||
|
cursor: serverLoading !== null ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileCode size={14} strokeWidth={1.5} />
|
||||||
|
{serverLoading === "dxf" ? "Экспорт…" : "DXF"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleServerExport("pdf")}
|
||||||
|
disabled={serverLoading !== null}
|
||||||
|
aria-label="Скачать PDF"
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity: serverLoading !== null ? 0.6 : 1,
|
||||||
|
cursor: serverLoading !== null ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={14} strokeWidth={1.5} />
|
||||||
|
{serverLoading === "pdf" ? "Экспорт…" : "PDF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
frontend/src/components/concept/ConceptParamsForm.tsx
Normal file
232
frontend/src/components/concept/ConceptParamsForm.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConceptParamsForm — controlled form for the ConceptInput parameters
|
||||||
|
* (housing_class / target_floors / development_type / land_cost_rub). The
|
||||||
|
* parcel polygon is collected separately by the map; this form owns only the
|
||||||
|
* scalar fields and the submit CTA. Submit is disabled until the parent
|
||||||
|
* confirms a polygon is present (`hasPolygon`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEVELOPMENT_TYPE_LABELS,
|
||||||
|
HOUSING_CLASS_LABELS,
|
||||||
|
type DevelopmentType,
|
||||||
|
type HousingClass,
|
||||||
|
} from "@/lib/concept-api";
|
||||||
|
|
||||||
|
export interface ConceptParams {
|
||||||
|
housing_class: HousingClass;
|
||||||
|
target_floors: number;
|
||||||
|
development_type: DevelopmentType;
|
||||||
|
land_cost_rub: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PARAMS: ConceptParams = {
|
||||||
|
housing_class: "comfort",
|
||||||
|
target_floors: 9,
|
||||||
|
development_type: "mid_rise",
|
||||||
|
land_cost_rub: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 14,
|
||||||
|
border: "1px solid var(--border-strong)",
|
||||||
|
borderRadius: 8,
|
||||||
|
outline: "none",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: ConceptParams;
|
||||||
|
onChange: (params: ConceptParams) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
hasPolygon: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConceptParamsForm({
|
||||||
|
params,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
hasPolygon,
|
||||||
|
isPending,
|
||||||
|
}: Props) {
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!hasPolygon || isPending) return;
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = !hasPolygon || isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: "grid", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="housing-class" style={fieldLabelStyle}>
|
||||||
|
Класс жилья
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="housing-class"
|
||||||
|
value={params.housing_class}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...params,
|
||||||
|
housing_class: e.target.value as HousingClass,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={controlStyle}
|
||||||
|
>
|
||||||
|
{(Object.keys(HOUSING_CLASS_LABELS) as HousingClass[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{HOUSING_CLASS_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dev-type" style={fieldLabelStyle}>
|
||||||
|
Тип застройки
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="dev-type"
|
||||||
|
value={params.development_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...params,
|
||||||
|
development_type: e.target.value as DevelopmentType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={controlStyle}
|
||||||
|
>
|
||||||
|
{(Object.keys(DEVELOPMENT_TYPE_LABELS) as DevelopmentType[]).map(
|
||||||
|
(key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{DEVELOPMENT_TYPE_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="target-floors" style={fieldLabelStyle}>
|
||||||
|
Целевая этажность
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="target-floors"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
value={params.target_floors}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = Number(e.target.value);
|
||||||
|
const clamped = Number.isFinite(raw)
|
||||||
|
? Math.min(30, Math.max(1, Math.round(raw)))
|
||||||
|
: 1;
|
||||||
|
onChange({ ...params, target_floors: clamped });
|
||||||
|
}}
|
||||||
|
style={controlStyle}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "4px 0 0",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
От 1 до 30 этажей.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="land-cost" style={fieldLabelStyle}>
|
||||||
|
Стоимость участка, ₽
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="land-cost"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1_000_000}
|
||||||
|
value={params.land_cost_rub ?? ""}
|
||||||
|
placeholder="Необязательно"
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
const value = raw === "" ? null : Number(raw);
|
||||||
|
onChange({
|
||||||
|
...params,
|
||||||
|
land_cost_rub:
|
||||||
|
value != null && Number.isFinite(value) && value >= 0
|
||||||
|
? value
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={controlStyle}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "4px 0 0",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Учитывается в финансовой модели как часть затрат.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Рассчитать концепции застройки"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 18px",
|
||||||
|
height: 44,
|
||||||
|
background: disabled ? "var(--bg-card-alt)" : "var(--accent)",
|
||||||
|
color: disabled ? "var(--fg-tertiary)" : "#fff",
|
||||||
|
border: disabled ? "1px solid var(--border-card)" : "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
transition: "opacity 150ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} strokeWidth={1.5} />
|
||||||
|
{isPending ? "Расчёт концепций…" : "Рассчитать концепции"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!hasPolygon && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нарисуйте полигон участка или укажите кадастровый номер, чтобы начать
|
||||||
|
расчёт.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/src/components/concept/ConceptResultMap.tsx
Normal file
144
frontend/src/components/concept/ConceptResultMap.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConceptResultMap — renders one variant's building placement: the parcel
|
||||||
|
* polygon (outline) + the generated buildings FeatureCollection (filled), over
|
||||||
|
* an OSM base. Re-uses the react-leaflet + GeoJSON pattern from SiteMap.
|
||||||
|
* Lazy-mounted (ssr:false) by the parent — Leaflet touches `window`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
||||||
|
import type { FeatureCollection, Polygon, Position } from "geojson";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// ── Bounds helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null {
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
let minLon = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
for (const ring of rings) {
|
||||||
|
for (const [lon, lat] of ring) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lon < minLon) minLon = lon;
|
||||||
|
if (lon > maxLon) maxLon = lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minLat)) return null;
|
||||||
|
return [
|
||||||
|
[minLat, minLon],
|
||||||
|
[maxLat, maxLon],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FitBoundsProps {
|
||||||
|
bounds: L.LatLngBoundsExpression | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-leaflet does not auto-fit to data; do it imperatively once mounted.
|
||||||
|
function FitBounds({ bounds }: FitBoundsProps) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (bounds) {
|
||||||
|
map.fitBounds(bounds, { padding: [24, 24] });
|
||||||
|
}
|
||||||
|
}, [map, bounds]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
parcel: Polygon;
|
||||||
|
buildings: FeatureCollection;
|
||||||
|
/** Keying makes react-leaflet remount the buildings layer on variant switch. */
|
||||||
|
variantKey: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConceptResultMap({
|
||||||
|
parcel,
|
||||||
|
buildings,
|
||||||
|
variantKey,
|
||||||
|
height = 360,
|
||||||
|
}: Props) {
|
||||||
|
const bounds = ringBounds(parcel.coordinates as Position[][]);
|
||||||
|
const hasBuildings = buildings.features.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapContainer
|
||||||
|
center={[56.8389, 60.6057]}
|
||||||
|
zoom={15}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
scrollWheelZoom
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Parcel boundary — outline only so buildings read on top. */}
|
||||||
|
<GeoJSON
|
||||||
|
key={`parcel-${variantKey}`}
|
||||||
|
data={parcel}
|
||||||
|
style={{
|
||||||
|
color: "#D1D5DB",
|
||||||
|
weight: 2,
|
||||||
|
dashArray: "6 4",
|
||||||
|
fillOpacity: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Generated buildings — filled blocks. */}
|
||||||
|
{hasBuildings && (
|
||||||
|
<GeoJSON
|
||||||
|
key={`buildings-${variantKey}`}
|
||||||
|
data={buildings}
|
||||||
|
style={{
|
||||||
|
color: "#1d4ed8",
|
||||||
|
weight: 1.5,
|
||||||
|
fillColor: "#1d4ed8",
|
||||||
|
fillOpacity: 0.55,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FitBounds bounds={bounds} />
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{!hasBuildings && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Движок ещё не вернул геометрию зданий для этого варианта.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
frontend/src/components/concept/ConceptVariantsResult.tsx
Normal file
290
frontend/src/components/concept/ConceptVariantsResult.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConceptVariantsResult — variant tabs + per-variant placement map, ТЭП KPIs,
|
||||||
|
* financial model, and export controls. Driven by the ConceptOutput returned
|
||||||
|
* from POST /concepts. The map is lazy-mounted (Leaflet uses `window`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
|
import { Section } from "@/components/analytics/Section";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import {
|
||||||
|
STRATEGY_HINTS,
|
||||||
|
STRATEGY_LABELS,
|
||||||
|
type ConceptVariant,
|
||||||
|
} from "@/lib/concept-api";
|
||||||
|
import type { Polygon } from "geojson";
|
||||||
|
|
||||||
|
import { ConceptExportButtons } from "./ConceptExportButtons";
|
||||||
|
|
||||||
|
const ConceptResultMap = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./ConceptResultMap").then((m) => ({ default: m.ConceptResultMap })),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 360,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка карты…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Formatters (ru microcopy) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
|
||||||
|
function formatMoneyCompact(rub: number): string {
|
||||||
|
const abs = Math.abs(rub);
|
||||||
|
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
|
||||||
|
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
|
||||||
|
return `${nf.format(Math.round(rub))} ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInt(n: number): string {
|
||||||
|
return nf.format(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(fraction: number): string {
|
||||||
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Variant panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
parcel: Polygon;
|
||||||
|
variant: ConceptVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
|
const { teap, financial } = variant;
|
||||||
|
const marginPositive =
|
||||||
|
financial.gross_margin_rub > 0
|
||||||
|
? true
|
||||||
|
: financial.gross_margin_rub < 0
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-headline)",
|
||||||
|
color: "var(--fg-on-dark)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600 }}>
|
||||||
|
{STRATEGY_LABELS[variant.strategy]}:{" "}
|
||||||
|
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
|
||||||
|
{formatInt(teap.apartments_count)} квартир · валовая прибыль{" "}
|
||||||
|
{formatMoneyCompact(financial.gross_margin_rub)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-on-dark-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} ·
|
||||||
|
плотность {teap.density.toLocaleString("ru-RU")} м²/га.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placement map */}
|
||||||
|
<Section
|
||||||
|
title="Размещение застройки"
|
||||||
|
subtitle="Полигон участка (пунктир) и сгенерированные корпуса (заливка)."
|
||||||
|
>
|
||||||
|
<ConceptResultMap
|
||||||
|
parcel={parcel}
|
||||||
|
buildings={variant.buildings_geojson}
|
||||||
|
variantKey={variant.strategy}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ТЭП — KPI grid */}
|
||||||
|
<Section
|
||||||
|
title="ТЭП — технико-экономические показатели"
|
||||||
|
subtitle="Расчёт движка генерации по выбранной стратегии размещения."
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Площадь застройки"
|
||||||
|
value={formatInt(teap.built_area_sqm)}
|
||||||
|
unit="м²"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Общая площадь"
|
||||||
|
value={formatInt(teap.total_floor_area_sqm)}
|
||||||
|
unit="м²"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Жилая площадь"
|
||||||
|
value={formatInt(teap.residential_area_sqm)}
|
||||||
|
unit="м²"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Квартир"
|
||||||
|
value={formatInt(teap.apartments_count)}
|
||||||
|
unit="шт"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Плотность"
|
||||||
|
value={teap.density.toLocaleString("ru-RU")}
|
||||||
|
unit="м²/га"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Машино-мест"
|
||||||
|
value={formatInt(teap.parking_spaces)}
|
||||||
|
unit="шт"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Финмодель */}
|
||||||
|
<Section
|
||||||
|
title="Финансовая модель"
|
||||||
|
subtitle="Оценка выручки и доходности по ценам класса жилья и стоимости участка."
|
||||||
|
right={<ConceptExportButtons variant={variant} />}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Выручка"
|
||||||
|
value={formatMoneyCompact(financial.revenue_rub)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Затраты"
|
||||||
|
value={formatMoneyCompact(financial.cost_rub)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Валовая прибыль"
|
||||||
|
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||||
|
delta={{
|
||||||
|
value:
|
||||||
|
marginPositive === false
|
||||||
|
? "Отрицательная маржа"
|
||||||
|
: "Положительная маржа",
|
||||||
|
positive: marginPositive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="IRR"
|
||||||
|
value={formatPct(financial.irr)}
|
||||||
|
delta={{ value: "Внутренняя норма доходности", positive: null }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
parcel: Polygon;
|
||||||
|
variants: ConceptVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConceptVariantsResult({ parcel, variants }: Props) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
|
if (variants.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ fontSize: 14, color: "var(--fg-secondary)" }}>
|
||||||
|
Движок не вернул ни одного варианта застройки.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVariant = variants[Math.min(active, variants.length - 1)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Variant tabs */}
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Варианты застройки"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
borderBottom: "1px solid var(--border-card)",
|
||||||
|
marginBottom: 4,
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{variants.map((v, i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.strategy}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderBottom: isActive
|
||||||
|
? "2px solid var(--accent)"
|
||||||
|
: "2px solid transparent",
|
||||||
|
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STRATEGY_LABELS[v.strategy]}
|
||||||
|
<Badge variant={isActive ? "info" : "neutral"} size="sm">
|
||||||
|
{v.teap.apartments_count > 0
|
||||||
|
? `${formatInt(v.teap.apartments_count)} кв.`
|
||||||
|
: "—"}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VariantPanel parcel={parcel} variant={activeVariant} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
frontend/src/lib/concept-api.ts
Normal file
195
frontend/src/lib/concept-api.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* Concept (Generative Design) API — TanStack Query wrappers.
|
||||||
|
*
|
||||||
|
* Contract mirrors `backend/app/schemas/concept.py` exactly (Stage 1a frozen
|
||||||
|
* interface). Types are defined inline rather than via `npm run codegen`
|
||||||
|
* because codegen targets a live OpenAPI on localhost:8000 (`src/lib/
|
||||||
|
* api-types.ts`) which is unreachable in this env — same approach as the
|
||||||
|
* parcel-chat contract in `site-finder-api.ts`.
|
||||||
|
*
|
||||||
|
* Endpoint: POST /api/v1/concepts → ConceptOutput.
|
||||||
|
* Export endpoints (DXF/PDF) are not in the Stage-1a contract yet; the export
|
||||||
|
* buttons fall back to client-side GeoJSON/CSV generation and probe the
|
||||||
|
* documented `/concepts/{...}/export` path opportunistically (see
|
||||||
|
* ConceptExportButtons).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||||
|
|
||||||
|
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||||
|
|
||||||
|
// ── Input contract (ConceptInput) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export type HousingClass = "econom" | "comfort" | "business";
|
||||||
|
export type DevelopmentType = "spot" | "mid_rise" | "high_rise";
|
||||||
|
export type ConceptStrategy = "max_area" | "max_insolation" | "balanced";
|
||||||
|
|
||||||
|
export interface ConceptInput {
|
||||||
|
/** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
|
||||||
|
parcel_geojson: Polygon;
|
||||||
|
housing_class: HousingClass;
|
||||||
|
/** Целевая этажность 1–30. */
|
||||||
|
target_floors: number;
|
||||||
|
development_type: DevelopmentType;
|
||||||
|
/** Стоимость участка (₽) — опционально, для финмодели. */
|
||||||
|
land_cost_rub?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Output contract (ConceptOutput) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Технико-экономические показатели (ТЭП). */
|
||||||
|
export interface Teap {
|
||||||
|
built_area_sqm: number;
|
||||||
|
total_floor_area_sqm: number;
|
||||||
|
residential_area_sqm: number;
|
||||||
|
apartments_count: number;
|
||||||
|
density: number;
|
||||||
|
parking_spaces: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialModel {
|
||||||
|
revenue_rub: number;
|
||||||
|
cost_rub: number;
|
||||||
|
gross_margin_rub: number;
|
||||||
|
irr: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptVariant {
|
||||||
|
strategy: ConceptStrategy;
|
||||||
|
/** FeatureCollection полигонов зданий (WGS84). */
|
||||||
|
buildings_geojson: FeatureCollection;
|
||||||
|
teap: Teap;
|
||||||
|
financial: FinancialModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptOutput {
|
||||||
|
variants: ConceptVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy display metadata ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const STRATEGY_LABELS: Record<ConceptStrategy, string> = {
|
||||||
|
max_area: "Макс. площадь",
|
||||||
|
max_insolation: "Макс. инсоляция",
|
||||||
|
balanced: "Баланс",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRATEGY_HINTS: Record<ConceptStrategy, string> = {
|
||||||
|
max_area: "Максимальная плотность застройки в пределах нормативов.",
|
||||||
|
max_insolation: "Приоритет инсоляции и разрывов между корпусами.",
|
||||||
|
balanced: "Компромисс между ТЭП и качеством среды.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOUSING_CLASS_LABELS: Record<HousingClass, string> = {
|
||||||
|
econom: "Эконом",
|
||||||
|
comfort: "Комфорт",
|
||||||
|
business: "Бизнес",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEVELOPMENT_TYPE_LABELS: Record<DevelopmentType, string> = {
|
||||||
|
spot: "Точечная",
|
||||||
|
mid_rise: "Среднеэтажная",
|
||||||
|
high_rise: "Высотная",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Hook: useCreateConcept ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/concepts — generate building variants for a parcel polygon.
|
||||||
|
* Action (not a query) → drive from `useMutation` per the data-layer rule.
|
||||||
|
* Uses the shared `apiFetch` (base URL + session header + Content-Type) rather
|
||||||
|
* than re-adding fetch config.
|
||||||
|
*/
|
||||||
|
export function useCreateConcept() {
|
||||||
|
return useMutation<ConceptOutput, Error, ConceptInput>({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
apiFetch<ConceptOutput>("/api/v1/concepts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook: useCadastreGeom ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a cadastre number to its parcel polygon by POSTing the Site Finder
|
||||||
|
* analyze endpoint (`/api/v1/parcels/{cad}/analyze`) and reading `geom_geojson`.
|
||||||
|
* Re-uses the existing endpoint rather than adding a new backend route.
|
||||||
|
*
|
||||||
|
* The analyze endpoint returns 202 while НСПД geometry is still being fetched
|
||||||
|
* (see useParcelAnalyzeQuery). For the concept input we keep the UX simple: a
|
||||||
|
* 202 surfaces a "try again" error instead of a 2-minute poll loop — the user
|
||||||
|
* can re-submit once geometry is cached, or draw the polygon manually.
|
||||||
|
*/
|
||||||
|
export function useCadastreGeom() {
|
||||||
|
return useMutation<Polygon, Error, string>({
|
||||||
|
mutationFn: async (cad) => {
|
||||||
|
const url = `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=12`;
|
||||||
|
const { status, body } = await apiFetchWithStatus<{
|
||||||
|
geom_geojson?: unknown;
|
||||||
|
}>(url, { method: "POST" });
|
||||||
|
|
||||||
|
if (status === 202) {
|
||||||
|
throw new Error(
|
||||||
|
"Геометрия участка ещё загружается из НСПД. Повторите через несколько секунд.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const polygon = extractPolygon(body?.geom_geojson);
|
||||||
|
if (!polygon) {
|
||||||
|
throw new Error(
|
||||||
|
"Не удалось получить геометрию участка по этому номеру.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return polygon;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polygon helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`)
|
||||||
|
* into a Polygon. Accepts a bare Polygon, a Feature wrapping a Polygon, or the
|
||||||
|
* first polygon ring of a MultiPolygon. Returns null on any other shape.
|
||||||
|
*/
|
||||||
|
export function extractPolygon(geom: unknown): Polygon | null {
|
||||||
|
if (geom == null || typeof geom !== "object") return null;
|
||||||
|
const g = geom as {
|
||||||
|
type?: unknown;
|
||||||
|
coordinates?: unknown;
|
||||||
|
geometry?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (g.type === "Feature") {
|
||||||
|
return extractPolygon((g as Feature).geometry);
|
||||||
|
}
|
||||||
|
if (g.type === "Polygon" && Array.isArray(g.coordinates)) {
|
||||||
|
return {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: g.coordinates as Polygon["coordinates"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
|
||||||
|
const first = (g.coordinates as Polygon["coordinates"][])[0];
|
||||||
|
if (first) return { type: "Polygon", coordinates: first };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approximate parcel area in m² from a WGS84 polygon (spherical-excess shoelace). */
|
||||||
|
export function polygonAreaSqm(polygon: Polygon): number {
|
||||||
|
const ring = polygon.coordinates[0];
|
||||||
|
if (!ring || ring.length < 4) return 0;
|
||||||
|
const R = 6_378_137; // WGS84 equatorial radius (m)
|
||||||
|
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < ring.length - 1; i++) {
|
||||||
|
const [lon1, lat1] = ring[i];
|
||||||
|
const [lon2, lat2] = ring[i + 1];
|
||||||
|
area +=
|
||||||
|
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||||
|
}
|
||||||
|
return Math.abs((area * R * R) / 2);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue