diff --git a/frontend/src/app/concept/page.tsx b/frontend/src/app/concept/page.tsx index b92eebea..fd833608 100644 --- a/frontend/src/app/concept/page.tsx +++ b/frontend/src/app/concept/page.tsx @@ -1,21 +1,307 @@ "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 { 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: () => ( +
+ Загрузка карты… +
+ ), + }, +); + +type InputMode = "draw" | "cadastre"; + +const ha = (sqm: number) => + (sqm / 10_000).toLocaleString("ru-RU", { + maximumFractionDigits: 2, + }); export default function ConceptPage() { + const [mode, setMode] = useState("draw"); + const [polygon, setPolygon] = useState(null); + const [params, setParams] = useState(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 ( -
- ← Home -

Concept (Generative Design)

-

- TODO Stage 1a: polygon import (GeoJSON / Leaflet draw) + parameter form - + result tabs. -

-

TODO Stage 1b: render 3 variants on the map with colored buildings.

-

- TODO Stage 1c: TEAP table, financial form, PDF/Excel/DXF download - buttons. -

+
+ {/* Header */} +
+ + ← Главная + + · +

+ Концепция застройки · генеративный дизайн +

+
+ +
+

+ Задайте границы участка и параметры проекта — движок предложит три + стратегии размещения застройки с расчётом ТЭП и финансовой модели. +

+ + {/* Input row: parcel boundary (left) + parameters (right) */} +
+ {/* Parcel boundary */} +
+ setMode("draw")} + icon={} + label="Нарисовать" + /> + setMode("cadastre")} + icon={} + label="Кадастр" + /> +
+ } + > + {mode === "cadastre" && ( +
+ + {cadastreGeom.isError && ( +

+ {cadastreGeom.error.message} +

+ )} +
+ )} + + + +
+ {polygon + ? `Участок задан · площадь ≈ ${ha(parcelAreaSqm)} га` + : "Участок не задан."} +
+ + + {/* Parameters */} +
+ +
+
+ + {/* Results */} + {concept.isError && ( +
+

+ Не удалось рассчитать концепции: {concept.error.message} +

+
+ )} + + {concept.isPending && ( +
+

+ Движок рассчитывает варианты размещения… +

+
+ )} + + {concept.isSuccess && polygon && ( +
+ +
+ )} +
); } + +// ── Input-mode tab ──────────────────────────────────────────────────────────── + +interface ModeTabProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +} + +function ModeTab({ active, onClick, icon, label }: ModeTabProps) { + return ( + + ); +} diff --git a/frontend/src/components/concept/ConceptDrawMap.tsx b/frontend/src/components/concept/ConceptDrawMap.tsx new file mode 100644 index 00000000..421a2073 --- /dev/null +++ b/frontend/src/components/concept/ConceptDrawMap.tsx @@ -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(null); + const handlerRef = useRef(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 ( +
+ + + {/* Externally supplied polygon (cadastre → geom). Keyed on the ring so + react-leaflet remounts the layer when the geometry changes. */} + {polygon && ( + + )} + {}} + /> + + + {/* Toolbar — primary draw + clear; overlays the map top-left. */} +
+ + {polygon && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/concept/ConceptExportButtons.tsx b/frontend/src/components/concept/ConceptExportButtons.tsx new file mode 100644 index 00000000..e07453f3 --- /dev/null +++ b/frontend/src/components/concept/ConceptExportButtons.tsx @@ -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(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 ( +
+
+ + + + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/src/components/concept/ConceptParamsForm.tsx b/frontend/src/components/concept/ConceptParamsForm.tsx new file mode 100644 index 00000000..95117f61 --- /dev/null +++ b/frontend/src/components/concept/ConceptParamsForm.tsx @@ -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 ( +
+
+ + +
+ +
+ + +
+ +
+ + { + 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} + /> +

+ От 1 до 30 этажей. +

+
+ +
+ + { + 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} + /> +

+ Учитывается в финансовой модели как часть затрат. +

+
+ + + + {!hasPolygon && ( +

+ Нарисуйте полигон участка или укажите кадастровый номер, чтобы начать + расчёт. +

+ )} +
+ ); +} diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx new file mode 100644 index 00000000..efd0daab --- /dev/null +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -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 ( +
+ + + + {/* Parcel boundary — outline only so buildings read on top. */} + + + {/* Generated buildings — filled blocks. */} + {hasBuildings && ( + + )} + + + + + {!hasBuildings && ( +
+ Движок ещё не вернул геометрию зданий для этого варианта. +
+ )} +
+ ); +} diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx new file mode 100644 index 00000000..29ef3e16 --- /dev/null +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -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: () => ( +
+ Загрузка карты… +
+ ), + }, +); + +// ── 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 ( +
+ {/* Headline-bar — one-sentence JTBD verdict for the variant. */} +
+
+ {STRATEGY_LABELS[variant.strategy]}:{" "} + {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} + {formatInt(teap.apartments_count)} квартир · валовая прибыль{" "} + {formatMoneyCompact(financial.gross_margin_rub)} +
+
+ {STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} · + плотность {teap.density.toLocaleString("ru-RU")} м²/га. +
+
+ + {/* Placement map */} +
+ +
+ + {/* ТЭП — KPI grid */} +
+
+ + + + + + +
+
+ + {/* Финмодель */} +
} + > +
+ + + + +
+
+
+ ); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + parcel: Polygon; + variants: ConceptVariant[]; +} + +export function ConceptVariantsResult({ parcel, variants }: Props) { + const [active, setActive] = useState(0); + + if (variants.length === 0) { + return ( +

+ Движок не вернул ни одного варианта застройки. +

+ ); + } + + const activeVariant = variants[Math.min(active, variants.length - 1)]; + + return ( +
+ {/* Variant tabs */} +
+ {variants.map((v, i) => { + const isActive = i === active; + return ( + + ); + })} +
+ + +
+ ); +} diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts new file mode 100644 index 00000000..b65bb48b --- /dev/null +++ b/frontend/src/lib/concept-api.ts @@ -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 = { + max_area: "Макс. площадь", + max_insolation: "Макс. инсоляция", + balanced: "Баланс", +}; + +export const STRATEGY_HINTS: Record = { + max_area: "Максимальная плотность застройки в пределах нормативов.", + max_insolation: "Приоритет инсоляции и разрывов между корпусами.", + balanced: "Компромисс между ТЭП и качеством среды.", +}; + +export const HOUSING_CLASS_LABELS: Record = { + econom: "Эконом", + comfort: "Комфорт", + business: "Бизнес", +}; + +export const DEVELOPMENT_TYPE_LABELS: Record = { + 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({ + mutationFn: (payload) => + apiFetch("/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({ + 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); +}