feat(concept): Concept UI Stage 1d — ввод участка + варианты (#57)
Заменяет 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
This commit is contained in:
parent
8242e51b61
commit
750d34d5cb
2 changed files with 493 additions and 12 deletions
|
|
@ -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: () => (
|
||||
<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() {
|
||||
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 (
|
||||
<main style={{ padding: 24, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<Link href="/">← Home</Link>
|
||||
<h1>Concept (Generative Design)</h1>
|
||||
<p>
|
||||
TODO Stage 1a: polygon import (GeoJSON / Leaflet draw) + parameter form
|
||||
+ result tabs.
|
||||
</p>
|
||||
<p>TODO Stage 1b: render 3 variants on the map with colored buildings.</p>
|
||||
<p>
|
||||
TODO Stage 1c: TEAP table, financial form, PDF/Excel/DXF download
|
||||
buttons.
|
||||
</p>
|
||||
<main style={{ minHeight: "100vh", background: "var(--bg-app)" }}>
|
||||
{/* Header */}
|
||||
<header
|
||||
style={{
|
||||
background: "var(--bg-card)",
|
||||
borderBottom: "1px solid var(--border-card)",
|
||||
padding: "12px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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