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 ( + + ); + })} +
+ + +
+ ); +}