feat(site-finder): add «7. Концепция» section to light analysis report
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 58s
CI / openapi-codegen-check (pull_request) Successful in 1m58s

Fold the generative concept generator (3 strategy variants: placement map +
ТЭП + finance) into the light analysis report (/site-finder/analysis/{cad}) as
a new section after «6. Прогноз», so Концепция lives in one place alongside the
report instead of a separate flow (epic #1953, #1965 Stage 1).

Frontend-only: reuses ConceptParamsForm / ConceptVariantsResult and the
/concepts mutation (useCreateConcept) as-is — no duplication, no backend change.
Inputs are seeded from the analysis the report already has (no re-entry of the
cadastre/polygon): polygon ← extractPolygon(geom_geojson); housing_class /
development_type ← financial_estimate inferred values; land_cost ←
egrn.cadastral_value_rub; falls back to comfort/mid_rise.

Runs on demand (heavy generative call — never auto on mount); result block
(Leaflet map) is lazy-mounted via dynamic({ ssr: false }) and the section is
collapsed by default. When financial_estimate is null (ЗОУИТ/СЗЗ/нет зоны) it
shows an honest banner but still allows a manual run, since /concepts is
independent of the financial_estimate gate. Graceful empty state when geometry
is missing/unsupported. Stage 2 (interactive 3D + /concepts/recompute) left as
a seam, not built.
This commit is contained in:
Light1YT 2026-06-28 00:13:57 +05:00
parent 3187419eb8
commit e809063ca3
2 changed files with 334 additions and 0 deletions

View file

@ -13,6 +13,7 @@ import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysi
import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate";
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
@ -218,6 +219,11 @@ export function AnalysisPageContent({ cad }: Props) {
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} selectedHorizon={horizon} />
{/* 7. Концепция генеративный дизайн застройки (#1965 Stage 1).
Seeded из анализа (geom + financial_estimate), считается по
запросу; result lazy-mounted (Leaflet). */}
<Section7Concept analysis={analysis} />
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
<ChatPanel cadNum={cad} />
</div>
@ -364,6 +370,7 @@ const NAV_GROUPS: NavGroup[] = [
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
{ id: "section-7", label: "7. Концепция" },
],
},
];

View file

@ -0,0 +1,327 @@
"use client";
/**
* Section7Concept «7. Концепция» (epic #1953, #1965 Stage 1).
*
* Folds the generative concept generator (3 strategy variants: placement map +
* ТЭП + financial model) into the LIGHT analysis report as a new section AFTER
* «6. Прогноз». Frontend-only: it reuses the existing concept components
* (ConceptParamsForm / ConceptVariantsResult) and the /concepts mutation
* (useCreateConcept) AS-IS no duplication, no backend change.
*
* Inputs are seeded from the analysis the report already has (no re-entry of the
* cadastre / polygon):
* 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.
*
* On-demand only: the generative call is heavy, so it runs from a button never
* auto on mount. The result block (Leaflet map) is lazy-mounted via `dynamic(…,
* { ssr: false })`. The section defaults to collapsed (above-the-fold rule).
*
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
* polygon + the form inputs (it is independent of the financial_estimate
* regulatory gate), so we show an honest banner and STILL allow a manual run.
*
* Stage 2 (separate PR) wires an interactive 3D MassingScene + a /concepts/
* recompute endpoint for live economics left as a clear seam here, not built.
*/
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { Section } from "@/components/analytics/Section";
import {
ConceptParamsForm,
DEFAULT_PARAMS,
type ConceptParams,
} from "@/components/concept/ConceptParamsForm";
import {
extractPolygon,
polygonAreaSqm,
useCreateConcept,
type DevelopmentType,
type HousingClass,
} 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: 200,
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;
// ── 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;
}
const ha = (sqm: number) =>
(sqm / 10_000).toLocaleString("ru-RU", { maximumFractionDigits: 2 });
// ── 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 form 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.
const fin = analysis.financial_estimate;
const seededParams = useMemo<ConceptParams>(() => {
return {
housing_class:
asHousingClass(fin?.housing_class_inferred) ??
DEFAULT_PARAMS.housing_class,
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);
const concept = useCreateConcept();
const financeUnavailable = fin == null;
function handleGenerate() {
if (!polygon) return;
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,
});
}
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
return (
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title="7. Концепция"
subtitle="Генеративный дизайн застройки: три стратегии размещения с расчётом ТЭП и финансовой модели. Считается по запросу из геометрии и параметров этого участка."
/>
{/* Collapsed by default (above-the-fold rule) form + button inside;
heavy result is lazy-mounted only after a run. */}
<details style={{ marginTop: 12 }}>
<summary
style={{
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary)",
padding: "8px 0",
}}
>
Рассчитать концепцию застройки для участка
</summary>
<div style={{ marginTop: 12 }}>
{/* Graceful empty state — geometry missing / unsupported. */}
{!polygon ? (
<Section title="Концепция застройки">
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Геометрия участка недоступна концепцию построить нельзя.
Геометрия загружается из НСПД; повторите анализ позже либо
воспользуйтесь страницей «Концепция застройки», где границы
можно задать вручную.
</p>
</Section>
) : (
<>
{/* СЗЗ / нет финмодели по регламенту честный баннер, но НЕ
тупик: /concepts работает по полигону + параметрам формы
независимо от регуляторного гейта financial_estimate. */}
{financeUnavailable && (
<div
role="note"
style={{
marginBottom: 12,
padding: "12px 16px",
background: "var(--warn-soft)",
border: "1px solid var(--warn)",
borderRadius: 12,
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-primary)",
}}
>
Финмодель по регламенту недоступна на участке ЗОУИТ / СЗЗ
либо не определена территориальная зона. Концепцию можно
рассчитать вручную: укажите класс, тип застройки и этажность
ниже движок построит варианты по геометрии участка и
заданным параметрам.
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 320px",
gap: 16,
alignItems: "start",
}}
>
<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>
</Section>
<Section
title="Параметры проекта"
subtitle="Класс, тип застройки и этажность."
>
<ConceptParamsForm
params={params}
onChange={setParams}
onSubmit={handleGenerate}
hasPolygon={polygon != null}
isPending={concept.isPending}
/>
</Section>
</div>
{/* Results */}
{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>
)}
{concept.isPending && (
<div style={{ marginTop: 16 }}>
<Section title="Варианты застройки">
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Движок рассчитывает варианты размещения
</p>
</Section>
</div>
)}
{concept.isSuccess && (
<div style={{ marginTop: 24 }}>
<ConceptVariantsResult
parcel={polygon}
variants={concept.data.variants}
/>
</div>
)}
</>
)}
</div>
</details>
</section>
);
}