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