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 { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
|
||||
import { MassingEconomics } from "@/components/site-finder/analysis/MassingEconomics";
|
||||
import {
|
||||
ConceptParamsForm,
|
||||
DEFAULT_PARAMS,
|
||||
|
|
@ -42,10 +44,13 @@ import {
|
|||
import {
|
||||
extractPolygon,
|
||||
polygonAreaSqm,
|
||||
polygonCentroidWkt,
|
||||
useCreateConcept,
|
||||
type DevelopmentType,
|
||||
type HousingClass,
|
||||
type MassingProgram,
|
||||
} from "@/lib/concept-api";
|
||||
import type { MassingModel } from "@/components/site-finder/ptica/massing/MassingScene";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
|
||||
// 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;
|
||||
|
||||
// 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) ────
|
||||
|
||||
const HOUSING_CLASSES: readonly HousingClass[] = [
|
||||
|
|
@ -149,6 +187,65 @@ export function Section7Concept({ analysis }: Props) {
|
|||
|
||||
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() {
|
||||
if (!polygon) return;
|
||||
concept.mutate({
|
||||
|
|
@ -160,8 +257,6 @@ export function Section7Concept({ analysis }: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
|
||||
|
||||
return (
|
||||
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||
<HeadlineBar
|
||||
|
|
@ -228,12 +323,49 @@ export function Section7Concept({ analysis }: Props) {
|
|||
</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
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) 320px",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<Section
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ export interface MassingSceneProps {
|
|||
* Инсоляционная матрица lower-grid card).
|
||||
*/
|
||||
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) ──────────────────────────────────────────
|
||||
|
|
@ -54,7 +62,7 @@ const HOUR_MAX = 20;
|
|||
|
||||
// ── derived massing model ─────────────────────────────────────────────────────
|
||||
|
||||
interface MassingModel {
|
||||
export interface MassingModel {
|
||||
floors: number;
|
||||
sections: number;
|
||||
height: number;
|
||||
|
|
@ -308,6 +316,7 @@ export default function MassingScene({
|
|||
maxFar,
|
||||
farIsReal = false,
|
||||
preview = false,
|
||||
onModelChange,
|
||||
}: MassingSceneProps): React.JSX.Element {
|
||||
const parcelArea =
|
||||
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 rafRef = useRef<number>(0);
|
||||
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 metrics = modelMetrics(model);
|
||||
|
|
@ -593,10 +607,15 @@ export default function MassingScene({
|
|||
// 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(() => {
|
||||
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]);
|
||||
|
||||
// apply time-of-day changes by repositioning the sun.
|
||||
|
|
@ -613,7 +632,9 @@ export default function MassingScene({
|
|||
if (webglFailed) {
|
||||
return (
|
||||
<div
|
||||
className={preview ? styles.massingPreviewViewport : styles.massingViewport}
|
||||
className={
|
||||
preview ? styles.massingPreviewViewport : styles.massingViewport
|
||||
}
|
||||
>
|
||||
<div className={styles.massingFallback}>
|
||||
<div className={styles.massingFallbackTitle}>
|
||||
|
|
|
|||
|
|
@ -122,9 +122,7 @@ export interface FinancialModel {
|
|||
}
|
||||
|
||||
export type PriceSource =
|
||||
| "objective_district_median"
|
||||
| "district_reference"
|
||||
| "class_norm";
|
||||
"objective_district_median" | "district_reference" | "class_norm";
|
||||
|
||||
export interface ConceptVariant {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -280,3 +333,28 @@ export function polygonAreaSqm(polygon: Polygon): number {
|
|||
}
|
||||
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