Merge pull request 'feat(site-finder): «7. Концепция» в light-отчёте — Stage 1 #1965' (#2023) from feat/concept-section-1965-stage1 into main
This commit is contained in:
commit
fed33fce27
2 changed files with 334 additions and 0 deletions
|
|
@ -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. Концепция" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
327
frontend/src/components/site-finder/analysis/Section7Concept.tsx
Normal file
327
frontend/src/components/site-finder/analysis/Section7Concept.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue