This commit is contained in:
parent
5f84008cb3
commit
e359fb4b95
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));
|
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 {
|
function formatPct(fraction: number): string {
|
||||||
return `${(fraction * 100).toFixed(1)}%`;
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
@ -122,8 +149,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600 }}>
|
<div style={{ fontSize: 15, fontWeight: 600 }}>
|
||||||
{STRATEGY_LABELS[variant.strategy]}:{" "}
|
{variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов
|
||||||
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
|
· {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
|
||||||
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
|
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
|
||||||
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
|
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
|
||||||
{formatPct(financial.roi)}
|
{formatPct(financial.roi)}
|
||||||
|
|
@ -165,17 +192,19 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
color: "var(--fg-secondary)",
|
color: "var(--fg-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Разместилось {formatInt(partialFit.placed)} из{" "}
|
Размещено {formatInt(partialFit.placed)} из{" "}
|
||||||
{formatInt(partialFit.requested)} секций — участок вмещает меньше, чем
|
{formatInt(partialFit.requested)} корпусов — участок вмещает меньше,
|
||||||
в заданной программе. ТЭП и финмодель рассчитаны по фактически
|
чем в заданной программе. ТЭП и финмодель рассчитаны по фактически
|
||||||
размещённым секциям.
|
размещённым корпусам.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Placement map */}
|
{/* Placement map */}
|
||||||
<Section
|
<Section
|
||||||
title="Размещение застройки"
|
title="Размещение застройки"
|
||||||
subtitle="Полигон участка (пунктир) и сгенерированные корпуса (заливка)."
|
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatInt(
|
||||||
|
buildingCount(variant),
|
||||||
|
)} корпусов.`}
|
||||||
>
|
>
|
||||||
<ConceptResultMap
|
<ConceptResultMap
|
||||||
parcel={parcel}
|
parcel={parcel}
|
||||||
|
|
@ -556,56 +585,62 @@ export function ConceptVariantsResult({ parcel, variants }: Props) {
|
||||||
|
|
||||||
const activeVariant = variants[Math.min(active, variants.length - 1)];
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Variant tabs */}
|
{/* Variant tabs */}
|
||||||
<div
|
{showTabs && (
|
||||||
role="tablist"
|
<div
|
||||||
aria-label="Варианты застройки"
|
role="tablist"
|
||||||
style={{
|
aria-label="Варианты застройки"
|
||||||
display: "flex",
|
style={{
|
||||||
gap: 8,
|
display: "flex",
|
||||||
borderBottom: "1px solid var(--border-card)",
|
gap: 8,
|
||||||
marginBottom: 4,
|
borderBottom: "1px solid var(--border-card)",
|
||||||
overflowX: "auto",
|
marginBottom: 4,
|
||||||
}}
|
overflowX: "auto",
|
||||||
>
|
}}
|
||||||
{variants.map((v, i) => {
|
>
|
||||||
const isActive = i === active;
|
{variants.map((v, i) => {
|
||||||
return (
|
const isActive = i === active;
|
||||||
<button
|
return (
|
||||||
key={v.strategy}
|
<button
|
||||||
type="button"
|
key={v.strategy}
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={isActive}
|
role="tab"
|
||||||
onClick={() => setActive(i)}
|
aria-selected={isActive}
|
||||||
style={{
|
onClick={() => setActive(i)}
|
||||||
display: "inline-flex",
|
style={{
|
||||||
alignItems: "center",
|
display: "inline-flex",
|
||||||
gap: 8,
|
alignItems: "center",
|
||||||
padding: "10px 14px",
|
gap: 8,
|
||||||
background: "transparent",
|
padding: "10px 14px",
|
||||||
border: "none",
|
background: "transparent",
|
||||||
borderBottom: isActive
|
border: "none",
|
||||||
? "2px solid var(--accent)"
|
borderBottom: isActive
|
||||||
: "2px solid transparent",
|
? "2px solid var(--accent)"
|
||||||
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
|
: "2px solid transparent",
|
||||||
fontSize: 14,
|
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
fontWeight: isActive ? 600 : 500,
|
fontSize: 14,
|
||||||
cursor: "pointer",
|
fontWeight: isActive ? 600 : 500,
|
||||||
whiteSpace: "nowrap",
|
cursor: "pointer",
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
}}
|
||||||
{STRATEGY_LABELS[v.strategy]}
|
>
|
||||||
<Badge variant={isActive ? "info" : "neutral"} size="sm">
|
{variantLabel(v)}
|
||||||
{v.teap.apartments_count > 0
|
<Badge variant={isActive ? "info" : "neutral"} size="sm">
|
||||||
? `${formatInt(v.teap.apartments_count)} кв.`
|
{v.teap.apartments_count > 0
|
||||||
: "—"}
|
? `${formatInt(v.teap.apartments_count)} кв.`
|
||||||
</Badge>
|
: "—"}
|
||||||
</button>
|
</Badge>
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<VariantPanel parcel={parcel} variant={activeVariant} />
|
<VariantPanel parcel={parcel} variant={activeVariant} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,56 @@
|
||||||
"use client";
|
"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
|
* ONE engine. The section folds the generative concept tooling into the LIGHT
|
||||||
* section AFTER «6. Прогноз». Frontend-only — reuses the existing concept
|
* analysis report after «6. Прогноз». A SINGLE source of numbers: POST /concepts
|
||||||
* components and the /concepts mutations AS-IS (no duplication, no backend
|
* with a `building_program` set drives the placement map + ТЭП + финмодель
|
||||||
* change). Two distinct surfaces sit in this section:
|
* (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.
|
* «Число корпусов» maps directly to a single-type program item
|
||||||
* Dragging the этажность / секции sliders re-folds the program and the
|
* `[{ section_type, floors, count }]`: the backend's `place_program` lays out
|
||||||
* economics strip recomputes ТЭП + финмодель vживую via /concepts/recompute
|
* EXACTLY `count` footprints, so scatter is structurally impossible while the
|
||||||
* (debounced). This is the primary, always-on surface.
|
* «уплотнить» toggle is OFF. Toggle ON → building_program is omitted → greedy
|
||||||
* 2. ON-DEMAND variant layouts (bottom): ConceptParamsForm + button →
|
* max-FAR (the explicit, opt-in dense buildout).
|
||||||
* 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 })`.
|
|
||||||
*
|
*
|
||||||
* Inputs are seeded from the analysis the report already has (no re-entry of the
|
* Inputs are seeded from the analysis the report already has (no re-entry):
|
||||||
* cadastre / polygon):
|
|
||||||
* • parcel polygon ← extractPolygon(analysis.geom_geojson)
|
* • parcel polygon ← extractPolygon(analysis.geom_geojson)
|
||||||
* • housing_class ← financial_estimate.housing_class_inferred
|
* • housing_class ← financial_estimate.housing_class_inferred
|
||||||
* • development_type← financial_estimate.development_type_inferred
|
* • development_type← financial_estimate.development_type_inferred
|
||||||
* • land_cost_rub ← egrn.cadastral_value_rub
|
* • land_cost_rub ← egrn.cadastral_value_rub
|
||||||
* Falls back to comfort / mid_rise when those are null.
|
* 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
|
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
|
||||||
* only need a polygon + form inputs (independent of the financial_estimate
|
* polygon + form inputs (independent of the financial_estimate regulatory gate),
|
||||||
* regulatory gate), so we show an honest banner, mark the live economics as
|
* so we show an honest banner and STILL allow a run.
|
||||||
* «условный расчёт», and STILL allow a manual run.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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 { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
import { Section } from "@/components/analytics/Section";
|
import { Section } from "@/components/analytics/Section";
|
||||||
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
|
|
||||||
import { MassingEconomics } from "@/components/site-finder/analysis/MassingEconomics";
|
|
||||||
import {
|
import {
|
||||||
ConceptParamsForm,
|
ConceptProgramForm,
|
||||||
DEFAULT_PARAMS,
|
type ConceptProgramFormState,
|
||||||
type ConceptParams,
|
} from "@/components/concept/ConceptProgramForm";
|
||||||
} from "@/components/concept/ConceptParamsForm";
|
|
||||||
import {
|
|
||||||
HouseProgramPicker,
|
|
||||||
type ProgramMode,
|
|
||||||
} from "@/components/concept/HouseProgramPicker";
|
|
||||||
import {
|
import {
|
||||||
extractPolygon,
|
extractPolygon,
|
||||||
polygonAreaSqm,
|
polygonAreaSqm,
|
||||||
polygonCentroidWkt,
|
|
||||||
useCreateConcept,
|
useCreateConcept,
|
||||||
|
useHouseTypes,
|
||||||
type BuildingProgramItem,
|
type BuildingProgramItem,
|
||||||
type DevelopmentType,
|
type DevelopmentType,
|
||||||
type HousingClass,
|
type HousingClass,
|
||||||
type MassingProgram,
|
type HouseTypeCatalogItem,
|
||||||
} from "@/lib/concept-api";
|
} from "@/lib/concept-api";
|
||||||
import type { MassingModel } from "@/components/site-finder/ptica/massing/MassingScene";
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
|
||||||
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
||||||
|
|
@ -75,7 +65,7 @@ const ConceptVariantsResult = dynamic(
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 200,
|
height: 360,
|
||||||
background: "var(--bg-card-alt)",
|
background: "var(--bg-card-alt)",
|
||||||
border: "1px solid var(--border-card)",
|
border: "1px solid var(--border-card)",
|
||||||
borderRadius: 12,
|
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;
|
const SCROLL_MARGIN = 72;
|
||||||
|
|
||||||
// Fallback КСИТ-цель when the parcel has no resolved ПЗЗ max_far (keeps the live
|
// Fallback floors when neither the catalog nor a selection is available yet.
|
||||||
// massing usable; matches MassingScene's own FALLBACK_FAR).
|
const FLOORS_FALLBACK = 9;
|
||||||
const FALLBACK_FAR = 3.5;
|
const CORPUSES_DEFAULT = 3;
|
||||||
|
|
||||||
// ── Narrowing helpers (inferred values are typed `string` on the analysis) ────
|
// ── Narrowing helpers (inferred values are typed `string` on the analysis) ────
|
||||||
|
|
||||||
|
|
@ -153,8 +114,13 @@ function asDevelopmentType(value: string | undefined): DevelopmentType | null {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ha = (sqm: number) =>
|
/** First comfort-class catalog type (else first entry, else null). */
|
||||||
(sqm / 10_000).toLocaleString("ru-RU", { maximumFractionDigits: 2 });
|
function pickDefaultType(
|
||||||
|
catalog: HouseTypeCatalogItem[],
|
||||||
|
): HouseTypeCatalogItem | null {
|
||||||
|
if (catalog.length === 0) return null;
|
||||||
|
return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -173,130 +139,108 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
[analysis.geom_geojson],
|
[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 (СЗЗ / ЗОУИТ / нет
|
// financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет
|
||||||
// зоны) — fall back to the shared DEFAULT_PARAMS (comfort / mid_rise) so a
|
// зоны) — fall back to comfort / mid_rise so a manual run is still possible.
|
||||||
// manual run is still possible.
|
|
||||||
const fin = analysis.financial_estimate;
|
const fin = analysis.financial_estimate;
|
||||||
const seededParams = useMemo<ConceptParams>(() => {
|
const seeded = useMemo(
|
||||||
return {
|
() => ({
|
||||||
housing_class:
|
housing_class: asHousingClass(fin?.housing_class_inferred) ?? "comfort",
|
||||||
asHousingClass(fin?.housing_class_inferred) ??
|
|
||||||
DEFAULT_PARAMS.housing_class,
|
|
||||||
development_type:
|
development_type:
|
||||||
asDevelopmentType(fin?.development_type_inferred) ??
|
asDevelopmentType(fin?.development_type_inferred) ?? "mid_rise",
|
||||||
DEFAULT_PARAMS.development_type,
|
land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null,
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
[
|
||||||
parcelAreaSqm,
|
|
||||||
fin?.housing_class_inferred,
|
fin?.housing_class_inferred,
|
||||||
fin?.development_type_inferred,
|
fin?.development_type_inferred,
|
||||||
fin?.price_per_sqm_used,
|
|
||||||
fin?.price_source,
|
|
||||||
analysis.egrn?.cadastral_value_rub,
|
analysis.egrn?.cadastral_value_rub,
|
||||||
centroidWkt,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleGenerate() {
|
const concept = useCreateConcept();
|
||||||
if (!polygon) return;
|
// Lift the catalog to the parent so auto-run-on-first-expand can build the
|
||||||
// «Выбрать дома» с непустой программой → передаём building_program (бэк кладёт
|
// default program from the real catalog (single source of truth, no hardcode).
|
||||||
// ровно её, один вариант). Иначе (авто / пустой список) — omit → жадная
|
const houseTypes = useHouseTypes();
|
||||||
// max-FAR раскладка (3 стратегии), поведение по умолчанию не меняется.
|
const catalog = useMemo(() => houseTypes.data ?? [], [houseTypes.data]);
|
||||||
const program =
|
|
||||||
programMode === "pick" && houseProgram.length > 0
|
const financeUnavailable = fin == null;
|
||||||
? houseProgram
|
|
||||||
: undefined;
|
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
||||||
concept.mutate({
|
|
||||||
parcel_geojson: polygon,
|
// Build a concept.mutate payload from a fully-resolved form state. «Число
|
||||||
housing_class: params.housing_class,
|
// корпусов» → a single-type program item's `count` (toggle OFF). Toggle ON →
|
||||||
target_floors: params.target_floors,
|
// building_program omitted → greedy max-FAR (explicit dense opt-in).
|
||||||
development_type: params.development_type,
|
const handleGenerate = useCallback(
|
||||||
land_cost_rub: params.land_cost_rub,
|
(state: ConceptProgramFormState) => {
|
||||||
building_program: program,
|
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 (
|
return (
|
||||||
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
<HeadlineBar
|
<HeadlineBar
|
||||||
title="7. Концепция"
|
title="7. Концепция"
|
||||||
subtitle="Интерактивная 3D-масса застройки: меняете класс, тип, этажность и число секций — ТЭП и финмодель пересчитываются вживую по геометрии этого участка. Отдельной кнопкой ниже движок раскладывает варианты размещения."
|
subtitle="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Collapsed by default (above-the-fold rule) — form + button inside;
|
{/* Collapsed by default (above-the-fold rule). Auto-runs once on first
|
||||||
heavy result is lazy-mounted only after a run. */}
|
expand so the user lands on a result, not an empty form. */}
|
||||||
<details style={{ marginTop: 12 }}>
|
<details style={{ marginTop: 12 }} onToggle={handleToggle}>
|
||||||
<summary
|
<summary
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|
@ -306,11 +250,10 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
padding: "8px 0",
|
padding: "8px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Рассчитать концепцию застройки для участка
|
Параметры и расчёт концепции
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ display: "grid", gap: 24 }}>
|
||||||
{/* Graceful empty state — geometry missing / unsupported. */}
|
|
||||||
{!polygon ? (
|
{!polygon ? (
|
||||||
<Section title="Концепция застройки">
|
<Section title="Концепция застройки">
|
||||||
<p
|
<p
|
||||||
|
|
@ -335,7 +278,6 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
<div
|
<div
|
||||||
role="note"
|
role="note"
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 12,
|
|
||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
background: "var(--warn-soft)",
|
background: "var(--warn-soft)",
|
||||||
border: "1px solid var(--warn)",
|
border: "1px solid var(--warn)",
|
||||||
|
|
@ -347,159 +289,55 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
>
|
>
|
||||||
Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ
|
Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ
|
||||||
либо не определена территориальная зона. Концепцию можно
|
либо не определена территориальная зона. Концепцию можно
|
||||||
рассчитать вручную: укажите класс, тип застройки и этажность
|
рассчитать вручную: задайте число корпусов, класс, тип
|
||||||
ниже — движок построит варианты по геометрии участка и
|
застройки и этажность ниже — движок построит застройку по
|
||||||
заданным параметрам.
|
геометрии участка и заданным параметрам.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── LIVE 3D массинг + экономика (#1965 Stage 2b) ──────────────
|
<ConceptProgramForm
|
||||||
Interactive massing on the left; the economics strip recomputes
|
parcelAreaSqm={parcelAreaSqm}
|
||||||
live from the financial model as floors / sections change. The
|
seeded={seeded}
|
||||||
3D viewport keeps its dark canvas; the KPI strip is light. The
|
hasPolygon={polygon != null}
|
||||||
economics Section renders its own card, so the 3D side gets a
|
isPending={concept.isPending}
|
||||||
matching framed wrapper rather than nesting two Sections. */}
|
onSubmit={handleGenerate}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
{/* Result region */}
|
||||||
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 */}
|
|
||||||
{concept.isError && (
|
{concept.isError && (
|
||||||
<div style={{ marginTop: 16 }}>
|
<Section title="Варианты застройки">
|
||||||
<Section title="Варианты застройки">
|
<p
|
||||||
<p
|
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
|
||||||
style={{
|
role="alert"
|
||||||
margin: 0,
|
>
|
||||||
fontSize: 13,
|
Не удалось рассчитать концепцию: {concept.error.message}
|
||||||
color: "var(--danger)",
|
</p>
|
||||||
}}
|
</Section>
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
Не удалось рассчитать концепции: {concept.error.message}
|
|
||||||
</p>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{concept.isPending && (
|
{concept.isPending && (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div
|
||||||
<Section title="Варианты застройки">
|
style={{
|
||||||
<p
|
minHeight: 700,
|
||||||
style={{
|
background: "var(--bg-card-alt)",
|
||||||
margin: 0,
|
border: "1px solid var(--border-card)",
|
||||||
fontSize: 13,
|
borderRadius: 12,
|
||||||
color: "var(--fg-secondary)",
|
display: "flex",
|
||||||
}}
|
alignItems: "center",
|
||||||
>
|
justifyContent: "center",
|
||||||
Движок рассчитывает варианты размещения…
|
color: "var(--fg-secondary)",
|
||||||
</p>
|
fontSize: 14,
|
||||||
</Section>
|
}}
|
||||||
|
>
|
||||||
|
Движок раскладывает корпуса и считает ТЭП и финмодель…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{concept.isSuccess && (
|
{concept.isSuccess && (
|
||||||
<div style={{ marginTop: 24 }}>
|
<ConceptVariantsResult
|
||||||
<ConceptVariantsResult
|
parcel={polygon}
|
||||||
parcel={polygon}
|
variants={concept.data.variants}
|
||||||
variants={concept.data.variants}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue