From e359fb4b95ad6aeb74cf11ca4e146c174fa6f313 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 30 Jun 2026 08:35:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(section7):=20unify=20=C2=AB=D0=9A=D0=BE?= =?UTF-8?q?=D0=BD=D1=86=D0=B5=D0=BF=D1=86=D0=B8=D1=8F=C2=BB=20into=20one?= =?UTF-8?q?=20engine=20driven=20by=20building=5Fprogram=20(#1953)=20(#2101?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/concept/ConceptProgramForm.tsx | 635 ++++++++++++++++++ .../concept/ConceptVariantsResult.tsx | 143 ++-- .../site-finder/analysis/Section7Concept.tsx | 480 +++++-------- 3 files changed, 883 insertions(+), 375 deletions(-) create mode 100644 frontend/src/components/concept/ConceptProgramForm.tsx diff --git a/frontend/src/components/concept/ConceptProgramForm.tsx b/frontend/src/components/concept/ConceptProgramForm.tsx new file mode 100644 index 00000000..af41a801 --- /dev/null +++ b/frontend/src/components/concept/ConceptProgramForm.tsx @@ -0,0 +1,635 @@ +"use client"; + +/** + * ConceptProgramForm — §7 «Концепция» unified program form (epic #1953). + * + * The SINGLE input surface for the section-7 concept engine. Replaces the torn + * two-engine layout (live 3D massing + separate ConceptParamsForm) with ONE + * coherent form whose primary control is «сколько корпусов строить». It builds a + * single-type `building_program` item `{ section_type, floors, count }` — the + * backend's `place_program` lays out EXACTLY `count` building footprints, so + * «число корпусов» === the stepper value and scatter is structurally impossible + * while the «уплотнить» toggle is OFF. + * + * Controlled: the parent owns nothing of the form's internal state; the form + * keeps it locally and lifts a fully-resolved payload via `onSubmit`. The parent + * maps that to `concept.mutate` (building_program when toggle OFF, omitted when + * ON → explicit greedy max-FAR opt-in). + * + * Styling: inline styles + CSS var tokens only (Tailwind classes don't generate + * in this bundle). Catalog (footprint / default floors / class) comes from + * `useHouseTypes()` — the single source of truth, no frontend hardcode. + */ + +import { useEffect, useMemo, useState } from "react"; +import { Minus, Plus, Sparkles } from "lucide-react"; + +import { Section } from "@/components/analytics/Section"; +import { + DEVELOPMENT_TYPE_LABELS, + HOUSING_CLASS_LABELS, + useHouseTypes, + type DevelopmentType, + type HousingClass, + type HouseTypeCatalogItem, +} from "@/lib/concept-api"; + +// ── Contract / UI bounds ────────────────────────────────────────────────────── + +const CORPUSES_MIN = 1; +const CORPUSES_MAX = 12; +const CORPUSES_DEFAULT = 3; + +const FLOORS_MIN = 1; +const FLOORS_MAX = 40; +const FLOORS_FALLBACK = 9; + +const FLOOR_HEIGHT_MIN = 2.4; +const FLOOR_HEIGHT_MAX = 4.5; +const FLOOR_HEIGHT_DEFAULT = 3.0; + +// ── Public payload (lifted to the parent on submit) ─────────────────────────── + +/** + * The fully-resolved form state the parent needs to call `concept.mutate`. + * `densify` ON → the parent OMITS building_program (greedy max-FAR scatter is the + * explicit opt-in); OFF → the parent sends a single-type program item with + * `count = corpuses`. `floor_height_m` is PR-A visual-only (drives the height + * readout, NOT sent to the backend — ТЭП is computed from floors). + */ +export interface ConceptProgramFormState { + /** «Сколько корпусов строить» → building_program item `count` (toggle OFF). */ + corpuses: number; + /** Этажность → building_program item `floors` + scalar `target_floors`. */ + floors: number; + /** Высота этажа, м — visual only (PR-A); drives the height readout. */ + floor_height_m: number; + /** Каталожный тип секции → building_program item `section_type`. */ + section_type: string; + housing_class: HousingClass; + development_type: DevelopmentType; + land_cost_rub: number | null; + /** «Уплотнить до максимума» → parent omits building_program (greedy) when true. */ + densify: boolean; +} + +interface SeededDefaults { + housing_class: HousingClass; + development_type: DevelopmentType; + land_cost_rub: number | null; +} + +interface Props { + parcelAreaSqm: number; + seeded: SeededDefaults; + hasPolygon: boolean; + isPending: boolean; + onSubmit: (state: ConceptProgramFormState) => void; +} + +// ── Shared style tokens (match ConceptParamsForm patterns) ──────────────────── + +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)", +}; + +const helperStyle: React.CSSProperties = { + margin: "4px 0 0", + fontSize: 11, + color: "var(--fg-tertiary)", +}; + +const tabularStyle: React.CSSProperties = { + fontVariantNumeric: "tabular-nums", +}; + +// ── Formatters (ru microcopy) ───────────────────────────────────────────────── + +const nf0 = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); +const ha = (sqm: number) => + (sqm / 10_000).toLocaleString("ru-RU", { maximumFractionDigits: 2 }); + +function clampInt( + raw: number, + min: number, + max: number, + fallback: number, +): number { + if (!Number.isFinite(raw)) return fallback; + return Math.min(max, Math.max(min, Math.round(raw))); +} + +function clampFloat(raw: number, min: number, max: number): number | null { + if (!Number.isFinite(raw)) return null; + return Math.min(max, Math.max(min, raw)); +} + +/** First comfort-class catalog type (else first catalog 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]; +} + +// ── ConceptProgramForm ──────────────────────────────────────────────────────── + +export function ConceptProgramForm({ + parcelAreaSqm, + seeded, + hasPolygon, + isPending, + onSubmit, +}: Props) { + const houseTypes = useHouseTypes(); + const catalog = useMemo(() => houseTypes.data ?? [], [houseTypes.data]); + + const [corpuses, setCorpuses] = useState(CORPUSES_DEFAULT); + const [floors, setFloors] = useState(FLOORS_FALLBACK); + const [floorHeight, setFloorHeight] = useState(FLOOR_HEIGHT_DEFAULT); + const [sectionType, setSectionType] = useState(""); + const [housingClass, setHousingClass] = useState( + seeded.housing_class, + ); + const [developmentType, setDevelopmentType] = useState( + seeded.development_type, + ); + const [landCostRub, setLandCostRub] = useState( + seeded.land_cost_rub, + ); + const [densify, setDensify] = useState(false); + + // Seed the section type + floors from the catalog once it loads. Only seeds + // while the type is still unset so manual edits are never clobbered on refetch. + useEffect(() => { + if (sectionType !== "" || catalog.length === 0) return; + const def = pickDefaultType(catalog); + if (!def) return; + setSectionType(def.section_type); + setFloors(def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK); + }, [catalog, sectionType]); + + const selectedType = useMemo( + () => catalog.find((ht) => ht.section_type === sectionType) ?? null, + [catalog, sectionType], + ); + + // ── Derived read-only readouts (closes «видеть характеристики» complaint) ── + const buildingHeightM = floors * floorHeight; + const footprintSqm = selectedType?.footprint_sqm ?? null; + const totalFootprintSqm = + footprintSqm != null ? corpuses * footprintSqm : null; + const ksitEstimate = + footprintSqm != null && parcelAreaSqm > 0 + ? (corpuses * footprintSqm * floors) / parcelAreaSqm + : null; + + const catalogReady = houseTypes.isSuccess && catalog.length > 0; + const disabled = !hasPolygon || isPending || !catalogReady; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (disabled || sectionType === "") return; + onSubmit({ + corpuses, + floors, + floor_height_m: floorHeight, + section_type: sectionType, + housing_class: housingClass, + development_type: developmentType, + land_cost_rub: landCostRub, + densify, + }); + } + + function handleTypeChange(nextType: string) { + setSectionType(nextType); + const ht = catalog.find((c) => c.section_type === nextType); + if (ht && ht.default_floors > 0) setFloors(ht.default_floors); + } + + return ( +
+
+ {/* 1. «Сколько корпусов строить» — первый и самый крупный блок. */} +
+ + Сколько корпусов строить + +
+ +
+ {corpuses} +
+ + + setCorpuses( + clampInt( + Number(e.target.value), + CORPUSES_MIN, + CORPUSES_MAX, + CORPUSES_DEFAULT, + ), + ) + } + style={{ + flex: 1, + accentColor: "var(--accent)", + cursor: "pointer", + }} + /> +
+

+ {densify + ? "Число корпусов подбирает движок." + : `корпусов на участке ≈ ${ha(parcelAreaSqm)} га`} +

+
+ + {/* 2 + readout. Этажность + высота корпуса. */} +
+ + + setFloors( + clampInt( + Number(e.target.value), + FLOORS_MIN, + FLOORS_MAX, + FLOORS_FALLBACK, + ), + ) + } + style={controlStyle} + /> +

+ Высота корпуса ≈{" "} + {buildingHeightM.toFixed(1)} м (от + 1 до 40 этажей). +

+
+ + {/* 3. Высота этажа — PR-A visual only. */} +
+ + { + const next = clampFloat( + Number(e.target.value), + FLOOR_HEIGHT_MIN, + FLOOR_HEIGHT_MAX, + ); + if (next != null) setFloorHeight(next); + }} + style={controlStyle} + /> +

+ Справочно — ТЭП считается от этажности, а не от высоты этажа. +

+
+ + {/* 4. Тип секции + пятно секции readout. */} +
+ + + {selectedType && ( +

+ Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "} + {nf0.format(selectedType.footprint_d_m)} м ≈{" "} + + {nf0.format(selectedType.footprint_sqm)} + {" "} + м² — задаётся типом секции. +

+ )} +
+ + {/* 5. Класс жилья. */} +
+ + +
+ + {/* 6. Тип застройки. */} +
+ + +

Влияет на график СМР и DCF.

+
+ + {/* 7. Стоимость участка. */} +
+ + { + const raw = e.target.value.trim(); + if (raw === "") { + setLandCostRub(null); + return; + } + const value = Number(raw); + setLandCostRub( + Number.isFinite(value) && value >= 0 ? value : null, + ); + }} + style={controlStyle} + /> +

Учитывается в финмодели как часть затрат.

+
+ + {/* Σ пятно застройки + ориентир КСИТ — только при выключенном уплотнении. */} + {!densify && (totalFootprintSqm != null || ksitEstimate != null) && ( +
+ {totalFootprintSqm != null && ( +
+ Σ пятно застройки ≈{" "} + + {nf0.format(totalFootprintSqm)} + {" "} + м² ({corpuses} × {nf0.format(footprintSqm ?? 0)} м²) +
+ )} + {ksitEstimate != null && ( +
+ Ориентир КСИТ ≈{" "} + + {ksitEstimate.toFixed(2)} + {" "} + (корпуса × пятно × этажность ÷ площадь участка) +
+ )} +
+ )} + + {/* 8. «Уплотнить до максимума» — toggle, default OFF. */} +
+ +
+
+ Уплотнить до максимума +
+

+ Движок сам забьёт участок по нормативу — плотное заполнение, + ручной выбор числа корпусов отключается. +

+
+
+ + {/* 9. Primary CTA. */} + + + {!hasPolygon && ( +

+ Геометрия участка недоступна — концепцию построить нельзя. +

+ )} + {houseTypes.isError && ( +

+ Не удалось загрузить каталог типовых домов:{" "} + {houseTypes.error.message} +

+ )} +
+
+ ); +} + +// ── Stepper button style ─────────────────────────────────────────────────────── + +function stepperButtonStyle(isDisabled: boolean): React.CSSProperties { + return { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 44, + height: 44, + flexShrink: 0, + borderRadius: 8, + border: "1px solid var(--border-strong)", + background: "var(--bg-card)", + color: isDisabled ? "var(--fg-tertiary)" : "var(--fg-primary)", + cursor: isDisabled ? "not-allowed" : "pointer", + }; +} diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 9ec5cfcd..1fcca110 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -64,6 +64,33 @@ function formatInt(n: number): string { return nf.format(Math.round(n)); } +/** + * Число корпусов на участке = count of placed building footprints (each Feature + * in buildings_geojson is one корпус). Guards a missing FeatureCollection. + */ +function buildingCount(variant: ConceptVariant): number { + return variant.buildings_geojson?.features?.length ?? 0; +} + +/** + * A program variant is built from a `building_program` (not a 1b strategy): the + * backend tags every placed feature `properties.strategy === "program"` while + * reporting the variant under the fixed "balanced" strategy slot. Detect it from + * the first feature so we can use the honest «Ваша программа» label instead of + * STRATEGY_LABELS["balanced"] («Баланс»), which would mislabel it. + */ +function isProgramVariant(variant: ConceptVariant): boolean { + const first = variant.buildings_geojson?.features?.[0]; + return first?.properties?.strategy === "program"; +} + +/** Display label: «Ваша программа» for program variants, else strategy label. */ +function variantLabel(variant: ConceptVariant): string { + return isProgramVariant(variant) + ? "Ваша программа" + : STRATEGY_LABELS[variant.strategy]; +} + function formatPct(fraction: number): string { return `${(fraction * 100).toFixed(1)}%`; } @@ -122,8 +149,8 @@ function VariantPanel({ parcel, variant }: PanelProps) { }} >
- {STRATEGY_LABELS[variant.strategy]}:{" "} - {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} + {variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов + · {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} {formatInt(teap.apartments_count)} квартир · чистая прибыль{" "} {formatMoneyCompact(financial.net_profit_rub)} · ROI{" "} {formatPct(financial.roi)} @@ -165,17 +192,19 @@ function VariantPanel({ parcel, variant }: PanelProps) { color: "var(--fg-secondary)", }} > - Разместилось {formatInt(partialFit.placed)} из{" "} - {formatInt(partialFit.requested)} секций — участок вмещает меньше, чем - в заданной программе. ТЭП и финмодель рассчитаны по фактически - размещённым секциям. + Размещено {formatInt(partialFit.placed)} из{" "} + {formatInt(partialFit.requested)} корпусов — участок вмещает меньше, + чем в заданной программе. ТЭП и финмодель рассчитаны по фактически + размещённым корпусам.
)} {/* Placement map */}
1; + return (
{/* Variant tabs */} -
- {variants.map((v, i) => { - const isActive = i === active; - return ( - - ); - })} -
+ {showTabs && ( +
+ {variants.map((v, i) => { + const isActive = i === active; + return ( + + ); + })} +
+ )}
diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index 7793d964..4102a007 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -1,66 +1,56 @@ "use client"; /** - * Section7Concept — «7. Концепция» (epic #1953, #1965 Stage 2b). + * Section7Concept — «7. Концепция» (epic #1953, unify-concept rewrite). * - * Folds the generative concept tooling into the LIGHT analysis report as a new - * section AFTER «6. Прогноз». Frontend-only — reuses the existing concept - * components and the /concepts mutations AS-IS (no duplication, no backend - * change). Two distinct surfaces sit in this section: + * 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. * - * 1. LIVE interactive 3D massing (top): MassingViewer + MassingEconomics. - * Dragging the этажность / секции sliders re-folds the program and the - * economics strip recomputes ТЭП + финмодель vживую via /concepts/recompute - * (debounced). This is the primary, always-on surface. - * 2. ON-DEMAND variant layouts (bottom): ConceptParamsForm + button → - * useCreateConcept → /concepts returns up to 3 placement strategies - * (max_area / max_insolation / balanced) rendered on a Leaflet map. Heavy, - * so it runs only from the button, never auto on mount, and the result map - * is lazy-mounted via `dynamic(…, { ssr: false })`. + * «Число корпусов» 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 of the - * cadastre / polygon): + * 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). + * 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: the massing + /concepts - * only need a polygon + form inputs (independent of the financial_estimate - * regulatory gate), so we show an honest banner, mark the live economics as - * «условный расчёт», and STILL allow a manual run. + * СЗЗ / 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, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HeadlineBar } from "@/components/ui/HeadlineBar"; import { Section } from "@/components/analytics/Section"; -import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation"; -import { MassingEconomics } from "@/components/site-finder/analysis/MassingEconomics"; import { - ConceptParamsForm, - DEFAULT_PARAMS, - type ConceptParams, -} from "@/components/concept/ConceptParamsForm"; -import { - HouseProgramPicker, - type ProgramMode, -} from "@/components/concept/HouseProgramPicker"; + ConceptProgramForm, + type ConceptProgramFormState, +} from "@/components/concept/ConceptProgramForm"; import { extractPolygon, polygonAreaSqm, - polygonCentroidWkt, useCreateConcept, + useHouseTypes, type BuildingProgramItem, type DevelopmentType, type HousingClass, - type MassingProgram, + type HouseTypeCatalogItem, } from "@/lib/concept-api"; -import type { MassingModel } from "@/components/site-finder/ptica/massing/MassingScene"; import type { ParcelAnalysis } from "@/types/site-finder"; // ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so @@ -75,7 +65,7 @@ const ConceptVariantsResult = dynamic( loading: () => (
- import("@/components/site-finder/ptica/massing/MassingViewer").then( - (m) => m.MassingViewer, - ), - { - ssr: false, - loading: () => ( -
- Загрузка 3D-модели… -
- ), - }, -); - const SCROLL_MARGIN = 72; -// Fallback КСИТ-цель when the parcel has no resolved ПЗЗ max_far (keeps the live -// massing usable; matches MassingScene's own FALLBACK_FAR). -const FALLBACK_FAR = 3.5; +// 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) ──── @@ -153,8 +114,13 @@ function asDevelopmentType(value: string | undefined): DevelopmentType | null { : null; } -const ha = (sqm: number) => - (sqm / 10_000).toLocaleString("ru-RU", { maximumFractionDigits: 2 }); +/** 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 ───────────────────────────────────────────────────────────────────── @@ -173,130 +139,108 @@ export function Section7Concept({ analysis }: Props) { [analysis.geom_geojson], ); - // Seed form defaults from the analysis: inferred class/type + cadastral value. + // Seed 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. + // зоны) — fall back to 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, + const seeded = useMemo( + () => ({ + housing_class: asHousingClass(fin?.housing_class_inferred) ?? "comfort", 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); - - // Stage 3b (#1965): program mode — «Авто (max-FAR)» (default, building_program - // omitted → greedy unchanged) vs «Выбрать дома» (typed house program). - const [programMode, setProgramMode] = useState("auto"); - const [houseProgram, setHouseProgram] = useState([]); - - const concept = useCreateConcept(); - - const financeUnavailable = fin == null; - - // Regulatorily constrained for МКД: no регламентная финмодель (ЗОУИТ / СЗЗ / - // нет зоны → financial_estimate == null) OR the gate verdict says МКД нельзя. - // Drives the honest «условный расчёт» caveat in the live economics strip. - const gateConstrained = - financeUnavailable || analysis.gate_verdict?.can_build_mkd === false; - - const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0; - - // КСИТ-цель (max_far) fed to the 3D scene + economics: real ПЗЗ-регламент when - // resolved (arrives as a STRING on the wire → coerce), else a sane fallback. - const farTarget = coerceFloat(analysis.nspd_zoning?.max_far) ?? FALLBACK_FAR; - const farIsReal = coerceFloat(analysis.nspd_zoning?.max_far) != null; - - // Centroid hint so the recompute endpoint can DB-resolve a market price only as - // a fallback (we forward the genuine pre-resolved price below when available). - const centroidWkt = useMemo( - () => (polygon ? polygonCentroidWkt(polygon) : null), - [polygon], - ); - - // The live massing program (Σ footprint × floors + context). Updated by the 3D - // scene's onModelChange; null until the scene fires its first build. The - // economics panel debounces + recomputes off it. - const [program, setProgram] = useState(null); - const [ksitOver, setKsitOver] = useState(false); - - // Map the scene's pure computeModel result → the recompute contract. Building - // program ← model; site / class / price ← this analysis (no re-entry): - // • total_footprint_sqm / floors / sections ← model - // • site_area_sqm ← parcel polygon area - // • housing_class / development_type ← inferred (fallback comfort / mid_rise) - // • land_cost_rub ← egrn.cadastral_value_rub - // • market_price_per_sqm / price_source ← financial_estimate (genuine source) - // • parcel_centroid_wkt ← polygon centroid (DB price fallback hint) - const handleModelChange = useCallback( - (model: MassingModel) => { - setKsitOver(model.farActual > model.maxFar + 0.001); - setProgram({ - total_footprint_sqm: model.totalFootprint, - floors: model.floors, - sections: model.sections, - site_area_sqm: parcelAreaSqm > 0 ? parcelAreaSqm : model.parcelArea, - housing_class: - asHousingClass(fin?.housing_class_inferred) ?? - DEFAULT_PARAMS.housing_class, - development_type: - asDevelopmentType(fin?.development_type_inferred) ?? - DEFAULT_PARAMS.development_type, - land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null, - market_price_per_sqm: fin?.price_per_sqm_used ?? null, - price_source: fin?.price_source ?? null, - parcel_centroid_wkt: centroidWkt, - }); - }, + asDevelopmentType(fin?.development_type_inferred) ?? "mid_rise", + land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null, + }), [ - parcelAreaSqm, fin?.housing_class_inferred, fin?.development_type_inferred, - fin?.price_per_sqm_used, - fin?.price_source, analysis.egrn?.cadastral_value_rub, - centroidWkt, ], ); - function handleGenerate() { - if (!polygon) return; - // «Выбрать дома» с непустой программой → передаём building_program (бэк кладёт - // ровно её, один вариант). Иначе (авто / пустой список) — omit → жадная - // max-FAR раскладка (3 стратегии), поведение по умолчанию не меняется. - const program = - programMode === "pick" && houseProgram.length > 0 - ? houseProgram - : undefined; - 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, - building_program: program, + 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) — form + button inside; - heavy result is lazy-mounted only after a run. */} -
+ {/* Collapsed by default (above-the-fold rule). Auto-runs once on first + expand so the user lands on a result, not an empty form. */} +
- Рассчитать концепцию застройки для участка + Параметры и расчёт концепции -
- {/* Graceful empty state — geometry missing / unsupported. */} +
{!polygon ? (

Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ либо не определена территориальная зона. Концепцию можно - рассчитать вручную: укажите класс, тип застройки и этажность - ниже — движок построит варианты по геометрии участка и - заданным параметрам. + рассчитать вручную: задайте число корпусов, класс, тип + застройки и этажность ниже — движок построит застройку по + геометрии участка и заданным параметрам.

)} - {/* ── LIVE 3D массинг + экономика (#1965 Stage 2b) ────────────── - Interactive massing on the left; the economics strip recomputes - live from the financial model as floors / sections change. The - 3D viewport keeps its dark canvas; the KPI strip is light. The - economics Section renders its own card, so the 3D side gets a - matching framed wrapper rather than nesting two Sections. */} -
-
- 0 ? parcelAreaSqm : undefined} - maxFar={farTarget} - farIsReal={farIsReal} - onModelChange={handleModelChange} - /> -
- -
+ -
-
-
- Кадастровый номер {analysis.cad_num} · площадь пятна по - геометрии ≈ {ha(parcelAreaSqm)} га. -
-

- Массинг и КСИТ считаются от площади застраиваемого пятна по - геометрии участка (контур НСПД), поэтому она может - отличаться от площади по ЕГРН в карточке участка. -

-

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

-
- -
-
- - -
-
-
- - {/* Results */} + {/* Result region */} {concept.isError && ( -
-
-

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

-
-
+
+

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

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

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

-
+
+ Движок раскладывает корпуса и считает ТЭП и финмодель…
)} {concept.isSuccess && ( -
- -
+ )} )}