feat(section7): unify «Концепция» into one engine driven by building_program (#1953)
§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:
parent
5f84008cb3
commit
b06409f04b
3 changed files with 883 additions and 375 deletions
635
frontend/src/components/concept/ConceptProgramForm.tsx
Normal file
635
frontend/src/components/concept/ConceptProgramForm.tsx
Normal 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue