feat(section7): unify «Концепция» into one engine driven by building_program (#1953)
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 55s

§7 «Концепция» собран из двух несогласованных движков (живой 3D-массинг +
MassingEconomics/recompute — КСИТ 3.5/3136кв; и отдельный greedy-прогон —
FAR 2.98/2677кв) в ОДИН program-поток. Новый ConceptProgramForm: первый и
крупнейший контрол — «сколько корпусов строить» (степпер 1–12, дефолт 3) →
single-type building_program item; усыпание участка возможно ТОЛЬКО при явно
включённом тумблере «Уплотнить до максимума». Один grid-контейнер (gap 24,
без marginTop-каши), условный result-регион, авто-расчёт один раз при первом
раскрытии. ConceptVariantsResult показывает число корпусов + ярлык «Ваша
программа» и прячет одинокую вкладку.

MassingEconomics/recompute больше не импортируются в §7 (остаются для ptica-
кокпита). ConceptParamsForm/HouseProgramPicker не тронуты (standalone /concept).
This commit is contained in:
Light1YT 2026-06-30 13:32:16 +05:00
parent 5f84008cb3
commit b06409f04b
3 changed files with 883 additions and 375 deletions

View file

@ -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<number>(CORPUSES_DEFAULT);
const [floors, setFloors] = useState<number>(FLOORS_FALLBACK);
const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT);
const [sectionType, setSectionType] = useState<string>("");
const [housingClass, setHousingClass] = useState<HousingClass>(
seeded.housing_class,
);
const [developmentType, setDevelopmentType] = useState<DevelopmentType>(
seeded.development_type,
);
const [landCostRub, setLandCostRub] = useState<number | null>(
seeded.land_cost_rub,
);
const [densify, setDensify] = useState<boolean>(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<HouseTypeCatalogItem | null>(
() => 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 (
<Section
title="Программа застройки"
subtitle="Задайте число корпусов, этажность и тип секции — движок разложит ровно эту программу на участке и посчитает ТЭП и финмодель по геометрии."
>
<form onSubmit={handleSubmit} style={{ display: "grid", gap: 16 }}>
{/* 1. «Сколько корпусов строить» — первый и самый крупный блок. */}
<div
style={{
border: "1px solid var(--accent-soft)",
borderRadius: 12,
padding: 16,
background: "var(--accent-soft)",
opacity: densify ? 0.55 : 1,
}}
>
<span style={{ ...fieldLabelStyle, color: "var(--accent)" }}>
Сколько корпусов строить
</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 4,
}}
>
<button
type="button"
aria-label="Убавить число корпусов"
disabled={densify || corpuses <= CORPUSES_MIN}
onClick={() => setCorpuses((n) => Math.max(CORPUSES_MIN, n - 1))}
style={stepperButtonStyle(densify || corpuses <= CORPUSES_MIN)}
>
<Minus size={20} strokeWidth={1.5} />
</button>
<div
aria-live="polite"
style={{
minWidth: 56,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 28,
fontWeight: 600,
color: "var(--fg-primary)",
...tabularStyle,
}}
>
{corpuses}
</div>
<button
type="button"
aria-label="Прибавить число корпусов"
disabled={densify || corpuses >= CORPUSES_MAX}
onClick={() => setCorpuses((n) => Math.min(CORPUSES_MAX, n + 1))}
style={stepperButtonStyle(densify || corpuses >= CORPUSES_MAX)}
>
<Plus size={20} strokeWidth={1.5} />
</button>
<input
type="range"
min={CORPUSES_MIN}
max={CORPUSES_MAX}
step={1}
value={corpuses}
disabled={densify}
aria-label="Число корпусов на участке"
onChange={(e) =>
setCorpuses(
clampInt(
Number(e.target.value),
CORPUSES_MIN,
CORPUSES_MAX,
CORPUSES_DEFAULT,
),
)
}
style={{
flex: 1,
accentColor: "var(--accent)",
cursor: "pointer",
}}
/>
</div>
<p style={{ ...helperStyle, color: "var(--fg-secondary)" }}>
{densify
? "Число корпусов подбирает движок."
: `корпусов на участке ≈ ${ha(parcelAreaSqm)} га`}
</p>
</div>
{/* 2 + readout. Этажность + высота корпуса. */}
<div>
<label htmlFor="cpf-floors" style={fieldLabelStyle}>
Этажность
</label>
<input
id="cpf-floors"
type="number"
min={FLOORS_MIN}
max={FLOORS_MAX}
value={floors}
onChange={(e) =>
setFloors(
clampInt(
Number(e.target.value),
FLOORS_MIN,
FLOORS_MAX,
FLOORS_FALLBACK,
),
)
}
style={controlStyle}
/>
<p style={helperStyle}>
Высота корпуса {" "}
<span style={tabularStyle}>{buildingHeightM.toFixed(1)}</span> м (от
1 до 40 этажей).
</p>
</div>
{/* 3. Высота этажа — PR-A visual only. */}
<div>
<label htmlFor="cpf-floor-height" style={fieldLabelStyle}>
Высота этажа, м
</label>
<input
id="cpf-floor-height"
type="number"
step={0.1}
min={FLOOR_HEIGHT_MIN}
max={FLOOR_HEIGHT_MAX}
value={floorHeight}
onChange={(e) => {
const next = clampFloat(
Number(e.target.value),
FLOOR_HEIGHT_MIN,
FLOOR_HEIGHT_MAX,
);
if (next != null) setFloorHeight(next);
}}
style={controlStyle}
/>
<p style={helperStyle}>
Справочно ТЭП считается от этажности, а не от высоты этажа.
</p>
</div>
{/* 4. Тип секции + пятно секции readout. */}
<div>
<label htmlFor="cpf-section-type" style={fieldLabelStyle}>
Тип секции
</label>
<select
id="cpf-section-type"
value={sectionType}
disabled={!catalogReady}
onChange={(e) => handleTypeChange(e.target.value)}
style={controlStyle}
>
{!catalogReady && <option value="">Загрузка каталога</option>}
{catalog.map((opt) => (
<option key={opt.section_type} value={opt.section_type}>
{opt.label_ru}
</option>
))}
</select>
{selectedType && (
<p style={helperStyle}>
Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "}
{nf0.format(selectedType.footprint_d_m)} м {" "}
<span style={tabularStyle}>
{nf0.format(selectedType.footprint_sqm)}
</span>{" "}
м² задаётся типом секции.
</p>
)}
</div>
{/* 5. Класс жилья. */}
<div>
<label htmlFor="cpf-housing-class" style={fieldLabelStyle}>
Класс жилья
</label>
<select
id="cpf-housing-class"
value={housingClass}
onChange={(e) => setHousingClass(e.target.value as HousingClass)}
style={controlStyle}
>
{(Object.keys(HOUSING_CLASS_LABELS) as HousingClass[]).map(
(key) => (
<option key={key} value={key}>
{HOUSING_CLASS_LABELS[key]}
</option>
),
)}
</select>
</div>
{/* 6. Тип застройки. */}
<div>
<label htmlFor="cpf-dev-type" style={fieldLabelStyle}>
Тип застройки
</label>
<select
id="cpf-dev-type"
value={developmentType}
onChange={(e) =>
setDevelopmentType(e.target.value as DevelopmentType)
}
style={controlStyle}
>
{(Object.keys(DEVELOPMENT_TYPE_LABELS) as DevelopmentType[]).map(
(key) => (
<option key={key} value={key}>
{DEVELOPMENT_TYPE_LABELS[key]}
</option>
),
)}
</select>
<p style={helperStyle}>Влияет на график СМР и DCF.</p>
</div>
{/* 7. Стоимость участка. */}
<div>
<label htmlFor="cpf-land-cost" style={fieldLabelStyle}>
Стоимость участка,
</label>
<input
id="cpf-land-cost"
type="number"
min={0}
step={1_000_000}
value={landCostRub ?? ""}
placeholder="Необязательно"
onChange={(e) => {
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}
/>
<p style={helperStyle}>Учитывается в финмодели как часть затрат.</p>
</div>
{/* Σ пятно застройки + ориентир КСИТ — только при выключенном уплотнении. */}
{!densify && (totalFootprintSqm != null || ksitEstimate != null) && (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 12,
background: "var(--bg-card-alt)",
display: "grid",
gap: 4,
}}
>
{totalFootprintSqm != null && (
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
Σ пятно застройки {" "}
<span style={{ ...tabularStyle, color: "var(--fg-primary)" }}>
{nf0.format(totalFootprintSqm)}
</span>{" "}
м² ({corpuses} × {nf0.format(footprintSqm ?? 0)} м²)
</div>
)}
{ksitEstimate != null && (
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
Ориентир КСИТ {" "}
<span style={{ ...tabularStyle, color: "var(--fg-primary)" }}>
{ksitEstimate.toFixed(2)}
</span>{" "}
(корпуса × пятно × этажность ÷ площадь участка)
</div>
)}
</div>
)}
{/* 8. «Уплотнить до максимума» — toggle, default OFF. */}
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 12,
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 12,
}}
>
<button
type="button"
role="switch"
aria-checked={densify}
aria-label="Уплотнить застройку до максимума по нормативу"
onClick={() => setDensify((on) => !on)}
style={{
flexShrink: 0,
width: 44,
height: 24,
borderRadius: 12,
border: "none",
padding: 2,
cursor: "pointer",
background: densify ? "var(--accent)" : "var(--border-strong)",
transition: "background 150ms",
display: "flex",
alignItems: "center",
justifyContent: densify ? "flex-end" : "flex-start",
}}
>
<span
style={{
display: "block",
width: 20,
height: 20,
borderRadius: "50%",
background: "var(--bg-card)",
}}
/>
</button>
<div>
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--fg-primary)",
}}
>
Уплотнить до максимума
</div>
<p style={{ ...helperStyle, marginTop: 2 }}>
Движок сам забьёт участок по нормативу плотное заполнение,
ручной выбор числа корпусов отключается.
</p>
</div>
</div>
{/* 9. Primary CTA. */}
<button
type="submit"
disabled={disabled}
aria-label="Рассчитать концепцию застройки"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 18px",
height: 44,
background: disabled ? "var(--bg-card-alt)" : "var(--accent)",
color: disabled ? "var(--fg-tertiary)" : "#fff",
border: disabled ? "1px solid var(--border-card)" : "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
transition: "opacity 150ms",
}}
>
<Sparkles size={16} strokeWidth={1.5} />
{isPending ? "Расчёт концепции…" : "Рассчитать концепцию"}
</button>
{!hasPolygon && (
<p style={{ margin: 0, fontSize: 12, color: "var(--fg-tertiary)" }}>
Геометрия участка недоступна концепцию построить нельзя.
</p>
)}
{houseTypes.isError && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
role="alert"
>
Не удалось загрузить каталог типовых домов:{" "}
{houseTypes.error.message}
</p>
)}
</form>
</Section>
);
}
// ── 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",
};
}

View file

@ -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) {
}}
>
<div style={{ fontSize: 15, fontWeight: 600 }}>
{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)} корпусов участок вмещает меньше,
чем в заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам.
</div>
)}
{/* Placement map */}
<Section
title="Размещение застройки"
subtitle="Полигон участка (пунктир) и сгенерированные корпуса (заливка)."
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatInt(
buildingCount(variant),
)} корпусов.`}
>
<ConceptResultMap
parcel={parcel}
@ -556,56 +585,62 @@ export function ConceptVariantsResult({ parcel, variants }: Props) {
const activeVariant = variants[Math.min(active, variants.length - 1)];
// A lone tab is noise (common for program mode → one variant). Hide the tab
// bar entirely with a single variant; keep it as-is when there's a choice.
const showTabs = variants.length > 1;
return (
<div>
{/* Variant tabs */}
<div
role="tablist"
aria-label="Варианты застройки"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid var(--border-card)",
marginBottom: 4,
overflowX: "auto",
}}
>
{variants.map((v, i) => {
const isActive = i === active;
return (
<button
key={v.strategy}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActive(i)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "transparent",
border: "none",
borderBottom: isActive
? "2px solid var(--accent)"
: "2px solid transparent",
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{STRATEGY_LABELS[v.strategy]}
<Badge variant={isActive ? "info" : "neutral"} size="sm">
{v.teap.apartments_count > 0
? `${formatInt(v.teap.apartments_count)} кв.`
: "—"}
</Badge>
</button>
);
})}
</div>
{showTabs && (
<div
role="tablist"
aria-label="Варианты застройки"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid var(--border-card)",
marginBottom: 4,
overflowX: "auto",
}}
>
{variants.map((v, i) => {
const isActive = i === active;
return (
<button
key={v.strategy}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActive(i)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "transparent",
border: "none",
borderBottom: isActive
? "2px solid var(--accent)"
: "2px solid transparent",
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{variantLabel(v)}
<Badge variant={isActive ? "info" : "neutral"} size="sm">
{v.teap.apartments_count > 0
? `${formatInt(v.teap.apartments_count)} кв.`
: "—"}
</Badge>
</button>
);
})}
</div>
)}
<VariantPanel parcel={parcel} variant={activeVariant} />
</div>

View file

@ -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: () => (
<div
style={{
height: 200,
height: 360,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
@ -92,40 +82,11 @@ const ConceptVariantsResult = dynamic(
},
);
// The interactive 3D massing is Three.js — lazy-mount, no SSR, so WebGL never
// touches the server and the chunk loads only when the section is expanded.
const MassingViewer = dynamic(
() =>
import("@/components/site-finder/ptica/massing/MassingViewer").then(
(m) => m.MassingViewer,
),
{
ssr: false,
loading: () => (
<div
style={{
height: 380,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка 3D-модели
</div>
),
},
);
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<ConceptParams>(() => {
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<ConceptParams>(seededParams);
// Stage 3b (#1965): program mode — «Авто (max-FAR)» (default, building_program
// omitted → greedy unchanged) vs «Выбрать дома» (typed house program).
const [programMode, setProgramMode] = useState<ProgramMode>("auto");
const [houseProgram, setHouseProgram] = useState<BuildingProgramItem[]>([]);
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<MassingProgram | null>(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 <details> 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
// <details onToggle> 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<HTMLDetailsElement>) => {
if (e.currentTarget.open) setExpanded(true);
},
[],
);
return (
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title="7. Концепция"
subtitle="Интерактивная 3D-масса застройки: меняете класс, тип, этажность и число секций — ТЭП и финмодель пересчитываются вживую по геометрии этого участка. Отдельной кнопкой ниже движок раскладывает варианты размещения."
subtitle="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка."
/>
{/* Collapsed by default (above-the-fold rule) form + button inside;
heavy result is lazy-mounted only after a run. */}
<details style={{ marginTop: 12 }}>
{/* Collapsed by default (above-the-fold rule). Auto-runs once on first
expand so the user lands on a result, not an empty form. */}
<details style={{ marginTop: 12 }} onToggle={handleToggle}>
<summary
style={{
cursor: "pointer",
@ -306,11 +250,10 @@ export function Section7Concept({ analysis }: Props) {
padding: "8px 0",
}}
>
Рассчитать концепцию застройки для участка
Параметры и расчёт концепции
</summary>
<div style={{ marginTop: 12 }}>
{/* Graceful empty state — geometry missing / unsupported. */}
<div style={{ display: "grid", gap: 24 }}>
{!polygon ? (
<Section title="Концепция застройки">
<p
@ -335,7 +278,6 @@ export function Section7Concept({ analysis }: Props) {
<div
role="note"
style={{
marginBottom: 12,
padding: "12px 16px",
background: "var(--warn-soft)",
border: "1px solid var(--warn)",
@ -347,159 +289,55 @@ export function Section7Concept({ analysis }: Props) {
>
Финмодель по регламенту недоступна на участке ЗОУИТ / СЗЗ
либо не определена территориальная зона. Концепцию можно
рассчитать вручную: укажите класс, тип застройки и этажность
ниже движок построит варианты по геометрии участка и
заданным параметрам.
рассчитать вручную: задайте число корпусов, класс, тип
застройки и этажность ниже движок построит застройку по
геометрии участка и заданным параметрам.
</div>
)}
{/* 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. */}
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 480px) minmax(0, 1fr)",
gap: 16,
alignItems: "start",
}}
>
<Section
title="Интерактивная масса застройки"
subtitle={`Редактируемая 3D-модель: тяните ползунки этажности и числа секций — ТЭП и финмодель справа пересчитываются вживую. КСИТ-цель ${farTarget.toFixed(
1,
)} (${
farIsReal ? "регламент НСПД" : "дефолт, источник НСПД"
}).`}
>
<MassingViewer
areaM2={parcelAreaSqm > 0 ? parcelAreaSqm : undefined}
maxFar={farTarget}
farIsReal={farIsReal}
onModelChange={handleModelChange}
/>
</Section>
<MassingEconomics
program={program}
farTarget={farTarget}
ksitOver={ksitOver}
gateConstrained={gateConstrained}
/>
</div>
<ConceptProgramForm
parcelAreaSqm={parcelAreaSqm}
seeded={seeded}
hasPolygon={polygon != null}
isPending={concept.isPending}
onSubmit={handleGenerate}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 320px",
gap: 16,
alignItems: "start",
marginTop: 24,
}}
>
<Section
title="Участок"
subtitle="Границы взяты из геометрии анализа — повторно вводить кадастр не нужно."
>
<div
style={{
fontSize: 13,
color: "var(--fg-primary)",
}}
>
Кадастровый номер {analysis.cad_num} · площадь пятна по
геометрии {ha(parcelAreaSqm)} га.
</div>
<p
style={{
margin: "8px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Массинг и КСИТ считаются от площади застраиваемого пятна по
геометрии участка (контур НСПД), поэтому она может
отличаться от площади по ЕГРН в карточке участка.
</p>
<p
style={{
margin: "8px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Параметры по умолчанию подставлены из анализа: класс и тип
застройки из оценки участка, стоимость из кадастровой
стоимости (если есть). Можно скорректировать.
</p>
</Section>
<Section
title="Параметры проекта"
subtitle="Задайте класс, тип застройки и этажность для расчёта вариантов размещения по кнопке ниже (отдельно от 3D-массы сверху). Можно указать программу из типовых домов вместо авторазмещения."
>
<div style={{ display: "grid", gap: 16 }}>
<HouseProgramPicker
mode={programMode}
onModeChange={setProgramMode}
program={houseProgram}
onChange={setHouseProgram}
isPending={concept.isPending}
/>
<ConceptParamsForm
params={params}
onChange={setParams}
onSubmit={handleGenerate}
hasPolygon={polygon != null}
isPending={concept.isPending}
/>
</div>
</Section>
</div>
{/* Results */}
{/* Result region */}
{concept.isError && (
<div style={{ marginTop: 16 }}>
<Section title="Варианты застройки">
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--danger)",
}}
role="alert"
>
Не удалось рассчитать концепции: {concept.error.message}
</p>
</Section>
</div>
<Section title="Варианты застройки">
<p
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
role="alert"
>
Не удалось рассчитать концепцию: {concept.error.message}
</p>
</Section>
)}
{concept.isPending && (
<div style={{ marginTop: 16 }}>
<Section title="Варианты застройки">
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Движок рассчитывает варианты размещения
</p>
</Section>
<div
style={{
minHeight: 700,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-secondary)",
fontSize: 14,
}}
>
Движок раскладывает корпуса и считает ТЭП и финмодель
</div>
)}
{concept.isSuccess && (
<div style={{ marginTop: 24 }}>
<ConceptVariantsResult
parcel={polygon}
variants={concept.data.variants}
/>
</div>
<ConceptVariantsResult
parcel={polygon}
variants={concept.data.variants}
/>
)}
</>
)}