feat(concept): живой 3D-massing → пересчёт финмодели в «7. Концепция» (#1965 Stage 2b) #2026
4 changed files with 593 additions and 10 deletions
|
|
@ -0,0 +1,352 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
|
||||||
|
* epic #1953).
|
||||||
|
*
|
||||||
|
* Driven by the interactive 3D MassingScene: every time the user drags the
|
||||||
|
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
|
||||||
|
* result + the analysis context into a `MassingProgram` and hands it here via
|
||||||
|
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
|
||||||
|
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
|
||||||
|
* прибыль / ROI).
|
||||||
|
*
|
||||||
|
* Robustness:
|
||||||
|
* • debounce — slider drags fire many programs; only the settled one is sent.
|
||||||
|
* • latest-wins — an in-flight request is superseded by a newer one via a
|
||||||
|
* monotonic request id; a stale response is dropped, never overwriting a
|
||||||
|
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
|
||||||
|
* • last-good — on a failed recompute we keep the last successful values and
|
||||||
|
* show a subtle inline note rather than blanking the panel.
|
||||||
|
* • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
|
||||||
|
* flight (no shimmer, per ui-conventions).
|
||||||
|
*
|
||||||
|
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
|
||||||
|
* this strip uses the light KPI tokens via the shared KpiCard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
|
import { Section } from "@/components/analytics/Section";
|
||||||
|
import {
|
||||||
|
priceSourceCaption,
|
||||||
|
useRecomputeMassing,
|
||||||
|
type FinancialModel,
|
||||||
|
type MassingProgram,
|
||||||
|
type MassingRecomputeOutput,
|
||||||
|
type Teap,
|
||||||
|
} from "@/lib/concept-api";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
|
||||||
|
|
||||||
|
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
|
||||||
|
function formatMoneyCompact(rub: number): string {
|
||||||
|
const abs = Math.abs(rub);
|
||||||
|
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
|
||||||
|
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
|
||||||
|
return `${nf.format(Math.round(rub))} ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInt(n: number): string {
|
||||||
|
return nf.format(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(fraction: number): string {
|
||||||
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFar(far: number): string {
|
||||||
|
return far.toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KPI grid ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface KpiGridProps {
|
||||||
|
teap: Teap;
|
||||||
|
financial: FinancialModel;
|
||||||
|
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
|
||||||
|
farTarget: number;
|
||||||
|
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
|
||||||
|
ksitOver: boolean;
|
||||||
|
/** Dim the strip while a fresher recompute is in flight (last-good values). */
|
||||||
|
stale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiGrid({
|
||||||
|
teap,
|
||||||
|
financial,
|
||||||
|
farTarget,
|
||||||
|
ksitOver,
|
||||||
|
stale,
|
||||||
|
}: KpiGridProps) {
|
||||||
|
const netPositive =
|
||||||
|
financial.net_profit_rub > 0
|
||||||
|
? true
|
||||||
|
: financial.net_profit_rub < 0
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: stale ? 0.55 : 1,
|
||||||
|
transition: "opacity 150ms linear",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ТЭП */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Общая площадь (GFA)"
|
||||||
|
value={formatInt(teap.total_floor_area_sqm)}
|
||||||
|
unit="м²"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Продаваемая площадь"
|
||||||
|
value={formatInt(teap.residential_area_sqm)}
|
||||||
|
unit="м²"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Квартир"
|
||||||
|
value={formatInt(teap.apartments_count)}
|
||||||
|
unit="шт"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="КСИТ — факт / цель"
|
||||||
|
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
|
||||||
|
delta={{
|
||||||
|
value: ksitOver
|
||||||
|
? "Факт превышает регламентный потолок"
|
||||||
|
: "В пределах регламента",
|
||||||
|
positive: ksitOver ? false : true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Финмодель */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Выручка (GDV)"
|
||||||
|
value={formatMoneyCompact(financial.revenue_rub)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Себестоимость"
|
||||||
|
value={formatMoneyCompact(financial.cost_rub)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Чистая прибыль"
|
||||||
|
value={formatMoneyCompact(financial.net_profit_rub)}
|
||||||
|
delta={{
|
||||||
|
value:
|
||||||
|
netPositive === true
|
||||||
|
? "Положительная (после НДС и налога на прибыль)"
|
||||||
|
: netPositive === false
|
||||||
|
? "Отрицательная (после НДС и налога на прибыль)"
|
||||||
|
: "Нулевая",
|
||||||
|
positive: netPositive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="ROI на затраты"
|
||||||
|
value={formatPct(financial.roi)}
|
||||||
|
delta={{
|
||||||
|
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
||||||
|
positive: null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="NPV (DCF)"
|
||||||
|
value={formatMoneyCompact(financial.npv_rub)}
|
||||||
|
delta={{
|
||||||
|
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
|
||||||
|
positive:
|
||||||
|
financial.npv_rub > 0
|
||||||
|
? true
|
||||||
|
: financial.npv_rub < 0
|
||||||
|
? false
|
||||||
|
: null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="IRR (DCF, годовой)"
|
||||||
|
value={formatPct(financial.irr)}
|
||||||
|
delta={{
|
||||||
|
value: financial.irr_is_proxy
|
||||||
|
? "Оценочный (вырожденный поток, не DCF)"
|
||||||
|
: "Дисконтированный денежный поток",
|
||||||
|
positive: financial.irr_is_proxy
|
||||||
|
? null
|
||||||
|
: financial.irr > financial.discount_rate_used
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Цена продажи жилья"
|
||||||
|
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
||||||
|
delta={{ value: priceSourceCaption(financial), positive: null }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
|
||||||
|
|
||||||
|
function SkeletonGrid() {
|
||||||
|
const cells = Array.from({ length: 7 });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{cells.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 92,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* The current massing program (Σ footprint × floors + context), mapped by
|
||||||
|
* Section7Concept from the 3D scene's computeModel result. `null` until the
|
||||||
|
* scene has fired its first onModelChange (or when geometry is missing).
|
||||||
|
*/
|
||||||
|
program: MassingProgram | null;
|
||||||
|
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
|
||||||
|
farTarget: number;
|
||||||
|
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
|
||||||
|
ksitOver: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
|
||||||
|
const recompute = useRecomputeMassing();
|
||||||
|
|
||||||
|
// Last successful result kept locally so a failed/stale recompute never blanks
|
||||||
|
// the panel (last-good values stay on screen).
|
||||||
|
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
|
||||||
|
// Monotonic request id → latest-wins: a response is applied only if it belongs
|
||||||
|
// to the most recently issued request, so out-of-order arrivals are dropped.
|
||||||
|
const reqIdRef = useRef(0);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Stable JSON key so we only recompute when the program actually changes
|
||||||
|
// (not on every parent re-render that hands an equal-but-new object).
|
||||||
|
const programKey = program ? JSON.stringify(program) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!program) return;
|
||||||
|
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const id = ++reqIdRef.current;
|
||||||
|
recompute
|
||||||
|
.mutateAsync(program)
|
||||||
|
.then((out) => {
|
||||||
|
if (id !== reqIdRef.current) return; // stale — a newer request won.
|
||||||
|
setResult(out);
|
||||||
|
setErrored(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (id !== reqIdRef.current) return; // stale failure — ignore.
|
||||||
|
setErrored(true); // keep last-good `result`.
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
// recompute is a stable mutation object; we key off the serialized program.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [programKey]);
|
||||||
|
|
||||||
|
// No program yet → nothing to show (parent gates this on geometry anyway).
|
||||||
|
if (!program) return null;
|
||||||
|
|
||||||
|
// First recompute in flight, no last-good value yet → skeleton.
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title="Экономика по 3D-модели"
|
||||||
|
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
|
||||||
|
>
|
||||||
|
<SkeletonGrid />
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresher request is in flight over the last-good values.
|
||||||
|
const stale = recompute.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title="Экономика по 3D-модели"
|
||||||
|
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
|
||||||
|
>
|
||||||
|
<KpiGrid
|
||||||
|
teap={result.teap}
|
||||||
|
financial={result.financial}
|
||||||
|
farTarget={farTarget}
|
||||||
|
ksitOver={ksitOver}
|
||||||
|
stale={stale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errored ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
margin: "12px 0 0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--warn)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||||
|
Не удалось пересчитать экономику по последнему изменению — показаны
|
||||||
|
предыдущие значения. Измените параметры ещё раз для повторного
|
||||||
|
расчёта.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,10 +30,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
import { Section } from "@/components/analytics/Section";
|
import { Section } from "@/components/analytics/Section";
|
||||||
|
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
|
||||||
|
import { MassingEconomics } from "@/components/site-finder/analysis/MassingEconomics";
|
||||||
import {
|
import {
|
||||||
ConceptParamsForm,
|
ConceptParamsForm,
|
||||||
DEFAULT_PARAMS,
|
DEFAULT_PARAMS,
|
||||||
|
|
@ -42,10 +44,13 @@ import {
|
||||||
import {
|
import {
|
||||||
extractPolygon,
|
extractPolygon,
|
||||||
polygonAreaSqm,
|
polygonAreaSqm,
|
||||||
|
polygonCentroidWkt,
|
||||||
useCreateConcept,
|
useCreateConcept,
|
||||||
type DevelopmentType,
|
type DevelopmentType,
|
||||||
type HousingClass,
|
type HousingClass,
|
||||||
|
type MassingProgram,
|
||||||
} from "@/lib/concept-api";
|
} from "@/lib/concept-api";
|
||||||
|
import type { MassingModel } from "@/components/site-finder/ptica/massing/MassingScene";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
|
||||||
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
||||||
|
|
@ -77,8 +82,41 @@ const ConceptVariantsResult = dynamic(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The interactive 3D massing is Three.js — lazy-mount, no SSR, so WebGL never
|
||||||
|
// touches the server and the chunk loads only when the section is expanded.
|
||||||
|
const MassingViewer = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/site-finder/ptica/massing/MassingViewer").then(
|
||||||
|
(m) => m.MassingViewer,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 380,
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка 3D-модели…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const SCROLL_MARGIN = 72;
|
const SCROLL_MARGIN = 72;
|
||||||
|
|
||||||
|
// Fallback КСИТ-цель when the parcel has no resolved ПЗЗ max_far (keeps the live
|
||||||
|
// massing usable; matches MassingScene's own FALLBACK_FAR).
|
||||||
|
const FALLBACK_FAR = 3.5;
|
||||||
|
|
||||||
// ── Narrowing helpers (inferred values are typed `string` on the analysis) ────
|
// ── Narrowing helpers (inferred values are typed `string` on the analysis) ────
|
||||||
|
|
||||||
const HOUSING_CLASSES: readonly HousingClass[] = [
|
const HOUSING_CLASSES: readonly HousingClass[] = [
|
||||||
|
|
@ -149,6 +187,65 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
|
|
||||||
const financeUnavailable = fin == null;
|
const financeUnavailable = fin == null;
|
||||||
|
|
||||||
|
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
||||||
|
|
||||||
|
// КСИТ-цель (max_far) fed to the 3D scene + economics: real ПЗЗ-регламент when
|
||||||
|
// resolved (arrives as a STRING on the wire → coerce), else a sane fallback.
|
||||||
|
const farTarget = coerceFloat(analysis.nspd_zoning?.max_far) ?? FALLBACK_FAR;
|
||||||
|
const farIsReal = coerceFloat(analysis.nspd_zoning?.max_far) != null;
|
||||||
|
|
||||||
|
// Centroid hint so the recompute endpoint can DB-resolve a market price only as
|
||||||
|
// a fallback (we forward the genuine pre-resolved price below when available).
|
||||||
|
const centroidWkt = useMemo(
|
||||||
|
() => (polygon ? polygonCentroidWkt(polygon) : null),
|
||||||
|
[polygon],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The live massing program (Σ footprint × floors + context). Updated by the 3D
|
||||||
|
// scene's onModelChange; null until the scene fires its first build. The
|
||||||
|
// economics panel debounces + recomputes off it.
|
||||||
|
const [program, setProgram] = useState<MassingProgram | null>(null);
|
||||||
|
const [ksitOver, setKsitOver] = useState(false);
|
||||||
|
|
||||||
|
// Map the scene's pure computeModel result → the recompute contract. Building
|
||||||
|
// program ← model; site / class / price ← this analysis (no re-entry):
|
||||||
|
// • total_footprint_sqm / floors / sections ← model
|
||||||
|
// • site_area_sqm ← parcel polygon area
|
||||||
|
// • housing_class / development_type ← inferred (fallback comfort / mid_rise)
|
||||||
|
// • land_cost_rub ← egrn.cadastral_value_rub
|
||||||
|
// • market_price_per_sqm / price_source ← financial_estimate (genuine source)
|
||||||
|
// • parcel_centroid_wkt ← polygon centroid (DB price fallback hint)
|
||||||
|
const handleModelChange = useCallback(
|
||||||
|
(model: MassingModel) => {
|
||||||
|
setKsitOver(model.farActual > model.maxFar + 0.001);
|
||||||
|
setProgram({
|
||||||
|
total_footprint_sqm: model.totalFootprint,
|
||||||
|
floors: model.floors,
|
||||||
|
sections: model.sections,
|
||||||
|
site_area_sqm: parcelAreaSqm > 0 ? parcelAreaSqm : model.parcelArea,
|
||||||
|
housing_class:
|
||||||
|
asHousingClass(fin?.housing_class_inferred) ??
|
||||||
|
DEFAULT_PARAMS.housing_class,
|
||||||
|
development_type:
|
||||||
|
asDevelopmentType(fin?.development_type_inferred) ??
|
||||||
|
DEFAULT_PARAMS.development_type,
|
||||||
|
land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null,
|
||||||
|
market_price_per_sqm: fin?.price_per_sqm_used ?? null,
|
||||||
|
price_source: fin?.price_source ?? null,
|
||||||
|
parcel_centroid_wkt: centroidWkt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
parcelAreaSqm,
|
||||||
|
fin?.housing_class_inferred,
|
||||||
|
fin?.development_type_inferred,
|
||||||
|
fin?.price_per_sqm_used,
|
||||||
|
fin?.price_source,
|
||||||
|
analysis.egrn?.cadastral_value_rub,
|
||||||
|
centroidWkt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
function handleGenerate() {
|
function handleGenerate() {
|
||||||
if (!polygon) return;
|
if (!polygon) return;
|
||||||
concept.mutate({
|
concept.mutate({
|
||||||
|
|
@ -160,8 +257,6 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
<HeadlineBar
|
<HeadlineBar
|
||||||
|
|
@ -228,12 +323,49 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── LIVE 3D массинг + экономика (#1965 Stage 2b) ──────────────
|
||||||
|
Interactive massing on the left; the economics strip recomputes
|
||||||
|
live from the financial model as floors / sections change. The
|
||||||
|
3D viewport keeps its dark canvas; the KPI strip is light. The
|
||||||
|
economics Section renders its own card, so the 3D side gets a
|
||||||
|
matching framed wrapper rather than nesting two Sections. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0, 480px) minmax(0, 1fr)",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section
|
||||||
|
title="Интерактивная масса застройки"
|
||||||
|
subtitle={`Меняйте этажность и число секций — экономика справа пересчитывается по финмодели. КСИТ-цель ${farTarget.toFixed(
|
||||||
|
1,
|
||||||
|
)} (${
|
||||||
|
farIsReal ? "регламент НСПД" : "дефолт, источник НСПД"
|
||||||
|
}).`}
|
||||||
|
>
|
||||||
|
<MassingViewer
|
||||||
|
areaM2={parcelAreaSqm > 0 ? parcelAreaSqm : undefined}
|
||||||
|
maxFar={farTarget}
|
||||||
|
farIsReal={farIsReal}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<MassingEconomics
|
||||||
|
program={program}
|
||||||
|
farTarget={farTarget}
|
||||||
|
ksitOver={ksitOver}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "minmax(0, 1fr) 320px",
|
gridTemplateColumns: "minmax(0, 1fr) 320px",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
|
marginTop: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section
|
<Section
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,14 @@ export interface MassingSceneProps {
|
||||||
* Инсоляционная матрица lower-grid card).
|
* Инсоляционная матрица lower-grid card).
|
||||||
*/
|
*/
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
|
/**
|
||||||
|
* Stage 2b (#1965) — fired with the recomputed model whenever the BUILDING
|
||||||
|
* program changes (floors / sections), including the initial mount build. NOT
|
||||||
|
* fired on the time-of-day (sun) slider — that only moves light, not mass. A
|
||||||
|
* no-op when absent, so the /ptica cockpit + preview/drawer keep their existing
|
||||||
|
* behaviour. Section7 wires this to a debounced live financial recompute.
|
||||||
|
*/
|
||||||
|
onModelChange?: (model: MassingModel) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── constants (scene units = metres) ──────────────────────────────────────────
|
// ── constants (scene units = metres) ──────────────────────────────────────────
|
||||||
|
|
@ -54,7 +62,7 @@ const HOUR_MAX = 20;
|
||||||
|
|
||||||
// ── derived massing model ─────────────────────────────────────────────────────
|
// ── derived massing model ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface MassingModel {
|
export interface MassingModel {
|
||||||
floors: number;
|
floors: number;
|
||||||
sections: number;
|
sections: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
@ -308,6 +316,7 @@ export default function MassingScene({
|
||||||
maxFar,
|
maxFar,
|
||||||
farIsReal = false,
|
farIsReal = false,
|
||||||
preview = false,
|
preview = false,
|
||||||
|
onModelChange,
|
||||||
}: MassingSceneProps): React.JSX.Element {
|
}: MassingSceneProps): React.JSX.Element {
|
||||||
const parcelArea =
|
const parcelArea =
|
||||||
areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA;
|
areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA;
|
||||||
|
|
@ -329,6 +338,11 @@ export default function MassingScene({
|
||||||
const massGroupRef = useRef<THREE.Group | null>(null);
|
const massGroupRef = useRef<THREE.Group | null>(null);
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
|
// Keep the latest onModelChange in a ref so the floors/sections effect fires
|
||||||
|
// it without taking the callback's identity as a dependency (a new closure each
|
||||||
|
// render must not retrigger a mass rebuild / extra recompute).
|
||||||
|
const onModelChangeRef = useRef(onModelChange);
|
||||||
|
onModelChangeRef.current = onModelChange;
|
||||||
|
|
||||||
const model = computeModel(parcelArea, farTarget, floors, sections);
|
const model = computeModel(parcelArea, farTarget, floors, sections);
|
||||||
const metrics = modelMetrics(model);
|
const metrics = modelMetrics(model);
|
||||||
|
|
@ -593,10 +607,15 @@ export default function MassingScene({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// apply floors / sections changes by rebuilding the mass (no full re-init).
|
// apply floors / sections changes by rebuilding the mass (no full re-init) and
|
||||||
|
// notify the live-economics consumer (Stage 2b) with the recomputed program.
|
||||||
|
// Fires on the initial mount build and on every floors/sections change — never
|
||||||
|
// on the time-of-day slider (that effect only moves the sun).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sceneRef.current) return;
|
if (!sceneRef.current) return;
|
||||||
rebuildMass(computeModel(parcelArea, farTarget, floors, sections));
|
const next = computeModel(parcelArea, farTarget, floors, sections);
|
||||||
|
rebuildMass(next);
|
||||||
|
onModelChangeRef.current?.(next);
|
||||||
}, [floors, sections, parcelArea, farTarget, rebuildMass]);
|
}, [floors, sections, parcelArea, farTarget, rebuildMass]);
|
||||||
|
|
||||||
// apply time-of-day changes by repositioning the sun.
|
// apply time-of-day changes by repositioning the sun.
|
||||||
|
|
@ -613,7 +632,9 @@ export default function MassingScene({
|
||||||
if (webglFailed) {
|
if (webglFailed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={preview ? styles.massingPreviewViewport : styles.massingViewport}
|
className={
|
||||||
|
preview ? styles.massingPreviewViewport : styles.massingViewport
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.massingFallback}>
|
<div className={styles.massingFallback}>
|
||||||
<div className={styles.massingFallbackTitle}>
|
<div className={styles.massingFallbackTitle}>
|
||||||
|
|
|
||||||
|
|
@ -122,9 +122,7 @@ export interface FinancialModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PriceSource =
|
export type PriceSource =
|
||||||
| "objective_district_median"
|
"objective_district_median" | "district_reference" | "class_norm";
|
||||||
| "district_reference"
|
|
||||||
| "class_norm";
|
|
||||||
|
|
||||||
export interface ConceptVariant {
|
export interface ConceptVariant {
|
||||||
strategy: ConceptStrategy;
|
strategy: ConceptStrategy;
|
||||||
|
|
@ -198,6 +196,61 @@ export function useCreateConcept() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Live recompute contract (Stage 2b, #1965) ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MassingProgram — the aggregated building program POSTed to
|
||||||
|
* `/api/v1/concepts/recompute`. Mirrors `backend/app/schemas/concept.py`
|
||||||
|
* (and `components["schemas"]["MassingProgram"]` in api-types.ts): an already
|
||||||
|
* FOLDED program from the interactive 3D massing's `computeModel`
|
||||||
|
* (Σ footprint × floors), без покомпонентной геометрии секций. The endpoint
|
||||||
|
* synthesises ТЭП from it and runs the same `compute_financial` → live economics.
|
||||||
|
*/
|
||||||
|
export interface MassingProgram {
|
||||||
|
/** Суммарное пятно застройки всех секций, кв.м (скаляр). */
|
||||||
|
total_footprint_sqm: number;
|
||||||
|
/** Этажность программы. */
|
||||||
|
floors: number;
|
||||||
|
/** Число секций (метаданные программы). */
|
||||||
|
sections: number;
|
||||||
|
/** Площадь участка для плотности (FAR). */
|
||||||
|
site_area_sqm: number;
|
||||||
|
housing_class: HousingClass;
|
||||||
|
development_type: DevelopmentType;
|
||||||
|
/** Стоимость участка для финмодели (опционально). */
|
||||||
|
land_cost_rub?: number | null;
|
||||||
|
/** Предрезолвленная рыночная цена жилья, ₽/м² (skip backend DB lookup). */
|
||||||
|
market_price_per_sqm?: number | null;
|
||||||
|
/** Подлинный источник предрезолвленной цены (forward из financial_estimate). */
|
||||||
|
price_source?: string | null;
|
||||||
|
/** WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback. */
|
||||||
|
parcel_centroid_wkt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output of `/api/v1/concepts/recompute`: synthesised ТЭП + finmodel. */
|
||||||
|
export interface MassingRecomputeOutput {
|
||||||
|
teap: Teap;
|
||||||
|
financial: FinancialModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/concepts/recompute — live financial recompute for an interactive
|
||||||
|
* massing program. Action (slider-driven) → `useMutation` per the data-layer
|
||||||
|
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
||||||
|
*
|
||||||
|
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
||||||
|
* with latest-wins sequencing handled by the caller (see MassingEconomics).
|
||||||
|
*/
|
||||||
|
export function useRecomputeMassing() {
|
||||||
|
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||||||
|
mutationFn: (payload) =>
|
||||||
|
apiFetch<MassingRecomputeOutput>("/api/v1/concepts/recompute", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hook: useCadastreGeom ─────────────────────────────────────────────────────
|
// ── Hook: useCadastreGeom ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -280,3 +333,28 @@ export function polygonAreaSqm(polygon: Polygon): number {
|
||||||
}
|
}
|
||||||
return Math.abs((area * R * R) / 2);
|
return Math.abs((area * R * R) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centroid of a WGS84 polygon's outer ring as a WKT `POINT(lon lat)` string —
|
||||||
|
* used as `parcel_centroid_wkt` so the recompute endpoint can DB-resolve a
|
||||||
|
* market price when no pre-resolved one is forwarded. Simple ring-average
|
||||||
|
* (good enough as a point hint; not a true area centroid). Returns null on a
|
||||||
|
* degenerate ring.
|
||||||
|
*/
|
||||||
|
export function polygonCentroidWkt(polygon: Polygon): string | null {
|
||||||
|
const ring = polygon.coordinates[0];
|
||||||
|
if (!ring || ring.length < 4) return null;
|
||||||
|
// Skip the closing vertex (== first) when averaging.
|
||||||
|
const pts = ring.slice(0, ring.length - 1);
|
||||||
|
if (pts.length === 0) return null;
|
||||||
|
let sumLon = 0;
|
||||||
|
let sumLat = 0;
|
||||||
|
for (const [lon, lat] of pts) {
|
||||||
|
sumLon += lon;
|
||||||
|
sumLat += lat;
|
||||||
|
}
|
||||||
|
const lon = sumLon / pts.length;
|
||||||
|
const lat = sumLat / pts.length;
|
||||||
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||||
|
return `POINT(${lon} ${lat})`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue