"use client";
/**
* Section7Concept — «7. Концепция» (epic #1953, unify-concept rewrite).
*
* ONE engine. The section folds the generative concept tooling into the LIGHT
* analysis report after «6. Прогноз». A SINGLE source of numbers: POST /concepts
* with a `building_program` set drives the placement map + ТЭП + финмодель
* (ConceptVariantsResult). The old two-engine split (live 3D massing + a
* separate params form / greedy run) is gone — it produced incoherent numbers
* and a torn layout.
*
* «Число корпусов» maps directly to a single-type program item
* `[{ section_type, floors, count }]`: the backend's `place_program` lays out
* EXACTLY `count` footprints, so scatter is structurally impossible while the
* «уплотнить» toggle is OFF. Toggle ON → building_program is omitted → greedy
* max-FAR (the explicit, opt-in dense buildout).
*
* Inputs are seeded from the analysis the report already has (no re-entry):
* • 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.
*
* The section defaults to collapsed (above-the-fold rule). On first expand it
* auto-runs once with the default program (count = 3) so the user lands on a
* result, not an empty form.
*
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
* polygon + form inputs (independent of the financial_estimate regulatory gate),
* so we show an honest banner and STILL allow a run.
*/
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { Section } from "@/components/analytics/Section";
import {
ConceptProgramForm,
type ConceptProgramFormState,
} from "@/components/concept/ConceptProgramForm";
import {
extractPolygon,
polygonAreaSqm,
useCreateConcept,
useHouseTypes,
type BuildingProgramItem,
type DevelopmentType,
type HousingClass,
type HouseTypeCatalogItem,
} 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;
// Fallback floors when neither the catalog nor a selection is available yet.
const FLOORS_FALLBACK = 9;
const CORPUSES_DEFAULT = 3;
// ── 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;
}
/** First comfort-class catalog type (else first entry, else null). */
function pickDefaultType(
catalog: HouseTypeCatalogItem[],
): HouseTypeCatalogItem | null {
if (catalog.length === 0) return null;
return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0];
}
// ── 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 defaults from the analysis: inferred class/type + cadastral value.
// financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет
// зоны) — fall back to comfort / mid_rise so a manual run is still possible.
const fin = analysis.financial_estimate;
const seeded = useMemo(
() => ({
housing_class: asHousingClass(fin?.housing_class_inferred) ?? "comfort",
development_type:
asDevelopmentType(fin?.development_type_inferred) ?? "mid_rise",
land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null,
}),
[
fin?.housing_class_inferred,
fin?.development_type_inferred,
analysis.egrn?.cadastral_value_rub,
],
);
const concept = useCreateConcept();
// Lift the catalog to the parent so auto-run-on-first-expand can build the
// default program from the real catalog (single source of truth, no hardcode).
const houseTypes = useHouseTypes();
const catalog = useMemo(() => houseTypes.data ?? [], [houseTypes.data]);
const financeUnavailable = fin == null;
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
// Build a concept.mutate payload from a fully-resolved form state. «Число
// корпусов» → a single-type program item's `count` (toggle OFF). Toggle ON →
// building_program omitted → greedy max-FAR (explicit dense opt-in).
const handleGenerate = useCallback(
(state: ConceptProgramFormState) => {
if (!polygon) return;
const building_program: BuildingProgramItem[] | undefined = state.densify
? undefined
: [
{
section_type: state.section_type,
floors: state.floors,
count: state.corpuses,
},
];
concept.mutate({
parcel_geojson: polygon,
housing_class: state.housing_class,
// Backward-compat scalar; the program item carries the real floors.
target_floors: state.floors,
development_type: state.development_type,
land_cost_rub: state.land_cost_rub,
building_program,
});
},
[polygon, concept],
);
// ── Auto-run-on-first-expand ──────────────────────────────────────────────
// When the is opened the FIRST time AND a polygon + catalog exist
// AND there's no result yet AND nothing is in flight → fire one run with the
// default program (count = 3, comfort-class section type). A ref guards a
// single execution; if the catalog isn't ready at open the effect re-fires
// when it becomes ready (still only once, still only after first open). The
// flips `expanded`, which (re)triggers the effect.
const autoRanRef = useRef(false);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (autoRanRef.current || !expanded) return;
if (!polygon || catalog.length === 0) return;
if (concept.isPending || concept.isSuccess || concept.isError) return;
const def = pickDefaultType(catalog);
if (!def) return;
autoRanRef.current = true;
handleGenerate({
corpuses: CORPUSES_DEFAULT,
floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK,
floor_height_m: 3.0,
section_type: def.section_type,
housing_class: seeded.housing_class,
development_type: seeded.development_type,
land_cost_rub: seeded.land_cost_rub,
densify: false,
});
}, [expanded, polygon, catalog, concept, seeded, handleGenerate]);
const handleToggle = useCallback(
(e: React.SyntheticEvent) => {
if (e.currentTarget.open) setExpanded(true);
},
[],
);
return (
{/* Collapsed by default (above-the-fold rule). Auto-runs once on first
expand so the user lands on a result, not an empty form. */}
Параметры и расчёт концепции
{!polygon ? (
Геометрия участка недоступна — концепцию построить нельзя.
Геометрия загружается из НСПД; повторите анализ позже либо
воспользуйтесь страницей «Концепция застройки», где границы
можно задать вручную.
) : (
<>
{/* СЗЗ / нет финмодели по регламенту — честный баннер, но НЕ
тупик: /concepts работает по полигону + параметрам формы
независимо от регуляторного гейта financial_estimate. */}
{financeUnavailable && (
Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ
либо не определена территориальная зона. Концепцию можно
рассчитать вручную: задайте число корпусов, класс, тип
застройки и этажность ниже — движок построит застройку по
геометрии участка и заданным параметрам.
)}
{/* Result region */}
{concept.isError && (
Не удалось рассчитать концепцию: {concept.error.message}
)}
{concept.isPending && (
Движок раскладывает корпуса и считает ТЭП и финмодель…
)}
{concept.isSuccess && (
)}
>
)}
);
}