diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index db414820..491d257b 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -13,6 +13,7 @@ import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysi import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate"; import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; +import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; @@ -218,6 +219,11 @@ export function AnalysisPageContent({ cad }: Props) { {/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */} + {/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1). + Seeded из анализа (geom + financial_estimate), считается по + запросу; result lazy-mounted (Leaflet). */} + + {/* Chat — grounded parcel-chat over the §22 forecast (#958) */} @@ -364,6 +370,7 @@ const NAV_GROUPS: NavGroup[] = [ { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, ], }, + { id: "section-7", label: "7. Концепция" }, ], }, ]; diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx new file mode 100644 index 00000000..18efeab2 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -0,0 +1,327 @@ +"use client"; + +/** + * Section7Concept — «7. Концепция» (epic #1953, #1965 Stage 1). + * + * Folds the generative concept generator (3 strategy variants: placement map + + * ТЭП + financial model) into the LIGHT analysis report as a new section AFTER + * «6. Прогноз». Frontend-only: it reuses the existing concept components + * (ConceptParamsForm / ConceptVariantsResult) and the /concepts mutation + * (useCreateConcept) AS-IS — no duplication, no backend change. + * + * Inputs are seeded from the analysis the report already has (no re-entry of the + * cadastre / polygon): + * • parcel polygon ← extractPolygon(analysis.geom_geojson) + * • housing_class ← financial_estimate.housing_class_inferred + * • development_type← financial_estimate.development_type_inferred + * • land_cost_rub ← egrn.cadastral_value_rub + * Falls back to comfort / mid_rise when those are null. + * + * On-demand only: the generative call is heavy, so it runs from a button — never + * auto on mount. The result block (Leaflet map) is lazy-mounted via `dynamic(…, + * { ssr: false })`. The section defaults to collapsed (above-the-fold rule). + * + * СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a + * polygon + the form inputs (it is independent of the financial_estimate + * regulatory gate), so we show an honest banner and STILL allow a manual run. + * + * Stage 2 (separate PR) wires an interactive 3D MassingScene + a /concepts/ + * recompute endpoint for live economics — left as a clear seam here, not built. + */ + +import dynamic from "next/dynamic"; +import { useMemo, useState } from "react"; + +import { HeadlineBar } from "@/components/ui/HeadlineBar"; +import { Section } from "@/components/analytics/Section"; +import { + ConceptParamsForm, + DEFAULT_PARAMS, + type ConceptParams, +} from "@/components/concept/ConceptParamsForm"; +import { + extractPolygon, + polygonAreaSqm, + useCreateConcept, + type DevelopmentType, + type HousingClass, +} from "@/lib/concept-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; + +// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so +// it never bloats the main report bundle (loaded only when results exist). +const ConceptVariantsResult = dynamic( + () => + import("@/components/concept/ConceptVariantsResult").then( + (m) => m.ConceptVariantsResult, + ), + { + ssr: false, + loading: () => ( +
+ Загрузка результата… +
+ ), + }, +); + +const SCROLL_MARGIN = 72; + +// ── Narrowing helpers (inferred values are typed `string` on the analysis) ──── + +const HOUSING_CLASSES: readonly HousingClass[] = [ + "econom", + "comfort", + "business", +]; +const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [ + "spot", + "mid_rise", + "high_rise", +]; + +function asHousingClass(value: string | undefined): HousingClass | null { + return value != null && (HOUSING_CLASSES as readonly string[]).includes(value) + ? (value as HousingClass) + : null; +} + +function asDevelopmentType(value: string | undefined): DevelopmentType | null { + return value != null && + (DEVELOPMENT_TYPES as readonly string[]).includes(value) + ? (value as DevelopmentType) + : null; +} + +const ha = (sqm: number) => + (sqm / 10_000).toLocaleString("ru-RU", { maximumFractionDigits: 2 }); + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + /** The /analyze response the report already holds — no re-fetch / re-entry. */ + analysis: ParcelAnalysis; +} + +// ── Section7Concept ─────────────────────────────────────────────────────────── + +export function Section7Concept({ analysis }: Props) { + // Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon / + // null → null; MultiPolygon collapses to its first ring per extractPolygon). + const polygon = useMemo( + () => extractPolygon(analysis.geom_geojson), + [analysis.geom_geojson], + ); + + // Seed form defaults from the analysis: inferred class/type + cadastral value. + // financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет + // зоны) — fall back to the shared DEFAULT_PARAMS (comfort / mid_rise) so a + // manual run is still possible. + const fin = analysis.financial_estimate; + const seededParams = useMemo(() => { + return { + housing_class: + asHousingClass(fin?.housing_class_inferred) ?? + DEFAULT_PARAMS.housing_class, + development_type: + asDevelopmentType(fin?.development_type_inferred) ?? + DEFAULT_PARAMS.development_type, + target_floors: DEFAULT_PARAMS.target_floors, + land_cost_rub: + analysis.egrn?.cadastral_value_rub ?? DEFAULT_PARAMS.land_cost_rub, + }; + }, [fin, analysis.egrn?.cadastral_value_rub]); + + const [params, setParams] = useState(seededParams); + const concept = useCreateConcept(); + + const financeUnavailable = fin == null; + + 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 ( +
+ + + {/* Collapsed by default (above-the-fold rule) — form + button inside; + heavy result is lazy-mounted only after a run. */} +
+ + Рассчитать концепцию застройки для участка + + +
+ {/* Graceful empty state — geometry missing / unsupported. */} + {!polygon ? ( +
+

+ Геометрия участка недоступна — концепцию построить нельзя. + Геометрия загружается из НСПД; повторите анализ позже либо + воспользуйтесь страницей «Концепция застройки», где границы + можно задать вручную. +

+
+ ) : ( + <> + {/* СЗЗ / нет финмодели по регламенту — честный баннер, но НЕ + тупик: /concepts работает по полигону + параметрам формы + независимо от регуляторного гейта financial_estimate. */} + {financeUnavailable && ( +
+ Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ + либо не определена территориальная зона. Концепцию можно + рассчитать вручную: укажите класс, тип застройки и этажность + ниже — движок построит варианты по геометрии участка и + заданным параметрам. +
+ )} + +
+
+
+ Кадастровый номер {analysis.cad_num} · площадь ≈{" "} + {ha(parcelAreaSqm)} га. +
+

+ Параметры по умолчанию подставлены из анализа: класс и тип + застройки — из оценки участка, стоимость — из кадастровой + стоимости (если есть). Можно скорректировать. +

+
+ +
+ +
+
+ + {/* Results */} + {concept.isError && ( +
+
+

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

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

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

+
+
+ )} + + {concept.isSuccess && ( +
+ +
+ )} + + )} +
+
+
+ ); +}