gendesign/frontend/src/components/site-finder/analysis/Section7Concept.tsx
bot-backend e359fb4b95
All checks were successful
Deploy / deploy (push) Successful in 1m14s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m13s
feat(section7): unify «Концепция» into one engine driven by building_program (#1953) (#2101)
2026-06-30 08:35:55 +00:00

348 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}