From 750d34d5cb220d72d3988f491f818ac0ae3954ff Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 21:32:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(concept):=20Concept=20UI=20Stage=201d=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D0=B2=D0=BE=D0=B4=20=D1=83=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20+=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет concept/page.tsx TODO-stubs полным generative-флоу: draw-полигон или кадастр→geom ввод участка, форма параметров ConceptInput, POST /concepts через useMutation, табы вариантов с placement-картой + ТЭП KPI + финмодель + экспорт (GeoJSON/CSV client-side, DXF/PDF на /concepts/export graceful 404). Типы зеркалят concept.py; переиспользует site-finder Leaflet + analytics Section/KpiCard паттерны. tsc + lint + build clean. Closes #57 --- frontend/src/app/concept/page.tsx | 310 ++++++++++++++++++++++++++++-- frontend/src/lib/concept-api.ts | 195 +++++++++++++++++++ 2 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/concept-api.ts 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/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); +}