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