348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Section7Concept — «7. Концепция» (epic #1953, unify-concept rewrite).
|
||
*
|
||
* ONE engine. The section folds the generative concept tooling into the LIGHT
|
||
* analysis report after «6. Прогноз». A SINGLE source of numbers: POST /concepts
|
||
* with a `building_program` set drives the placement map + ТЭП + финмодель
|
||
* (ConceptVariantsResult). The old two-engine split (live 3D massing + a
|
||
* separate params form / greedy run) is gone — it produced incoherent numbers
|
||
* and a torn layout.
|
||
*
|
||
* «Число корпусов» maps directly to a single-type program item
|
||
* `[{ section_type, floors, count }]`: the backend's `place_program` lays out
|
||
* EXACTLY `count` footprints, so scatter is structurally impossible while the
|
||
* «уплотнить» toggle is OFF. Toggle ON → building_program is omitted → greedy
|
||
* max-FAR (the explicit, opt-in dense buildout).
|
||
*
|
||
* Inputs are seeded from the analysis the report already has (no re-entry):
|
||
* • parcel polygon ← extractPolygon(analysis.geom_geojson)
|
||
* • housing_class ← financial_estimate.housing_class_inferred
|
||
* • development_type← financial_estimate.development_type_inferred
|
||
* • land_cost_rub ← egrn.cadastral_value_rub
|
||
* Falls back to comfort / mid_rise when those are null.
|
||
*
|
||
* The section defaults to collapsed (above-the-fold rule). On first expand it
|
||
* auto-runs once with the default program (count = 3) so the user lands on a
|
||
* result, not an empty form.
|
||
*
|
||
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
|
||
* polygon + form inputs (independent of the financial_estimate regulatory gate),
|
||
* so we show an honest banner and STILL allow a run.
|
||
*/
|
||
|
||
import dynamic from "next/dynamic";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||
import { Section } from "@/components/analytics/Section";
|
||
import {
|
||
ConceptProgramForm,
|
||
type ConceptProgramFormState,
|
||
} from "@/components/concept/ConceptProgramForm";
|
||
import {
|
||
extractPolygon,
|
||
polygonAreaSqm,
|
||
useCreateConcept,
|
||
useHouseTypes,
|
||
type BuildingProgramItem,
|
||
type DevelopmentType,
|
||
type HousingClass,
|
||
type HouseTypeCatalogItem,
|
||
} from "@/lib/concept-api";
|
||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||
|
||
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
||
// it never bloats the main report bundle (loaded only when results exist).
|
||
const ConceptVariantsResult = dynamic(
|
||
() =>
|
||
import("@/components/concept/ConceptVariantsResult").then(
|
||
(m) => m.ConceptVariantsResult,
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div
|
||
style={{
|
||
height: 360,
|
||
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,
|
||
}}
|
||
>
|
||
Загрузка результата…
|
||
</div>
|
||
),
|
||
},
|
||
);
|
||
|
||
const SCROLL_MARGIN = 72;
|
||
|
||
// Fallback floors when neither the catalog nor a selection is available yet.
|
||
const FLOORS_FALLBACK = 9;
|
||
const CORPUSES_DEFAULT = 3;
|
||
|
||
// ── Narrowing helpers (inferred values are typed `string` on the analysis) ────
|
||
|
||
const HOUSING_CLASSES: readonly HousingClass[] = [
|
||
"econom",
|
||
"comfort",
|
||
"business",
|
||
];
|
||
const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [
|
||
"spot",
|
||
"mid_rise",
|
||
"high_rise",
|
||
];
|
||
|
||
function asHousingClass(value: string | undefined): HousingClass | null {
|
||
return value != null && (HOUSING_CLASSES as readonly string[]).includes(value)
|
||
? (value as HousingClass)
|
||
: null;
|
||
}
|
||
|
||
function asDevelopmentType(value: string | undefined): DevelopmentType | null {
|
||
return value != null &&
|
||
(DEVELOPMENT_TYPES as readonly string[]).includes(value)
|
||
? (value as DevelopmentType)
|
||
: null;
|
||
}
|
||
|
||
/** First comfort-class catalog type (else first entry, else null). */
|
||
function pickDefaultType(
|
||
catalog: HouseTypeCatalogItem[],
|
||
): HouseTypeCatalogItem | null {
|
||
if (catalog.length === 0) return null;
|
||
return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0];
|
||
}
|
||
|
||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
/** The /analyze response the report already holds — no re-fetch / re-entry. */
|
||
analysis: ParcelAnalysis;
|
||
}
|
||
|
||
// ── Section7Concept ───────────────────────────────────────────────────────────
|
||
|
||
export function Section7Concept({ analysis }: Props) {
|
||
// Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon /
|
||
// null → null; MultiPolygon collapses to its first ring per extractPolygon).
|
||
const polygon = useMemo(
|
||
() => extractPolygon(analysis.geom_geojson),
|
||
[analysis.geom_geojson],
|
||
);
|
||
|
||
// Seed defaults from the analysis: inferred class/type + cadastral value.
|
||
// financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет
|
||
// зоны) — fall back to comfort / mid_rise so a manual run is still possible.
|
||
const fin = analysis.financial_estimate;
|
||
const seeded = useMemo(
|
||
() => ({
|
||
housing_class: asHousingClass(fin?.housing_class_inferred) ?? "comfort",
|
||
development_type:
|
||
asDevelopmentType(fin?.development_type_inferred) ?? "mid_rise",
|
||
land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null,
|
||
}),
|
||
[
|
||
fin?.housing_class_inferred,
|
||
fin?.development_type_inferred,
|
||
analysis.egrn?.cadastral_value_rub,
|
||
],
|
||
);
|
||
|
||
const concept = useCreateConcept();
|
||
// Lift the catalog to the parent so auto-run-on-first-expand can build the
|
||
// default program from the real catalog (single source of truth, no hardcode).
|
||
const houseTypes = useHouseTypes();
|
||
const catalog = useMemo(() => houseTypes.data ?? [], [houseTypes.data]);
|
||
|
||
const financeUnavailable = fin == null;
|
||
|
||
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
||
|
||
// Build a concept.mutate payload from a fully-resolved form state. «Число
|
||
// корпусов» → a single-type program item's `count` (toggle OFF). Toggle ON →
|
||
// building_program omitted → greedy max-FAR (explicit dense opt-in).
|
||
const handleGenerate = useCallback(
|
||
(state: ConceptProgramFormState) => {
|
||
if (!polygon) return;
|
||
const building_program: BuildingProgramItem[] | undefined = state.densify
|
||
? undefined
|
||
: [
|
||
{
|
||
section_type: state.section_type,
|
||
floors: state.floors,
|
||
count: state.corpuses,
|
||
},
|
||
];
|
||
concept.mutate({
|
||
parcel_geojson: polygon,
|
||
housing_class: state.housing_class,
|
||
// Backward-compat scalar; the program item carries the real floors.
|
||
target_floors: state.floors,
|
||
development_type: state.development_type,
|
||
land_cost_rub: state.land_cost_rub,
|
||
building_program,
|
||
});
|
||
},
|
||
[polygon, concept],
|
||
);
|
||
|
||
// ── Auto-run-on-first-expand ──────────────────────────────────────────────
|
||
// When the <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="Единая программа застройки: задайте число корпусов, этажность и класс — движок разложит ровно эту программу и посчитает ТЭП и финмодель по геометрии участка."
|
||
/>
|
||
|
||
{/* 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",
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
padding: "8px 0",
|
||
}}
|
||
>
|
||
Параметры и расчёт концепции
|
||
</summary>
|
||
|
||
<div style={{ display: "grid", gap: 24 }}>
|
||
{!polygon ? (
|
||
<Section title="Концепция застройки">
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Геометрия участка недоступна — концепцию построить нельзя.
|
||
Геометрия загружается из НСПД; повторите анализ позже либо
|
||
воспользуйтесь страницей «Концепция застройки», где границы
|
||
можно задать вручную.
|
||
</p>
|
||
</Section>
|
||
) : (
|
||
<>
|
||
{/* СЗЗ / нет финмодели по регламенту — честный баннер, но НЕ
|
||
тупик: /concepts работает по полигону + параметрам формы
|
||
независимо от регуляторного гейта financial_estimate. */}
|
||
{financeUnavailable && (
|
||
<div
|
||
role="note"
|
||
style={{
|
||
padding: "12px 16px",
|
||
background: "var(--warn-soft)",
|
||
border: "1px solid var(--warn)",
|
||
borderRadius: 12,
|
||
fontSize: 13,
|
||
lineHeight: "20px",
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ
|
||
либо не определена территориальная зона. Концепцию можно
|
||
рассчитать вручную: задайте число корпусов, класс, тип
|
||
застройки и этажность ниже — движок построит застройку по
|
||
геометрии участка и заданным параметрам.
|
||
</div>
|
||
)}
|
||
|
||
<ConceptProgramForm
|
||
parcelAreaSqm={parcelAreaSqm}
|
||
seeded={seeded}
|
||
hasPolygon={polygon != null}
|
||
isPending={concept.isPending}
|
||
onSubmit={handleGenerate}
|
||
/>
|
||
|
||
{/* Result region */}
|
||
{concept.isError && (
|
||
<Section title="Варианты застройки">
|
||
<p
|
||
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
|
||
role="alert"
|
||
>
|
||
Не удалось рассчитать концепцию: {concept.error.message}
|
||
</p>
|
||
</Section>
|
||
)}
|
||
|
||
{concept.isPending && (
|
||
<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 && (
|
||
<ConceptVariantsResult
|
||
parcel={polygon}
|
||
variants={concept.data.variants}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</details>
|
||
</section>
|
||
);
|
||
}
|