diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx
new file mode 100644
index 00000000..59fb7687
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx
@@ -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 (
+
+ {/* ТЭП */}
+
+
+
+
+
+
+
+ {/* Финмодель */}
+
+
+
+
+
+ 0
+ ? true
+ : financial.npv_rub < 0
+ ? false
+ : null,
+ }}
+ />
+ financial.discount_rate_used
+ ? true
+ : false,
+ }}
+ />
+
+
+
+ );
+}
+
+// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
+
+function SkeletonGrid() {
+ const cells = Array.from({ length: 7 });
+ return (
+
+ {cells.map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// ── 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(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 | 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 (
+
+ );
+ }
+
+ // A fresher request is in flight over the last-good values.
+ const stale = recompute.isPending;
+
+ return (
+
+
+
+ {errored ? (
+
+
+ Не удалось пересчитать экономику по последнему изменению — показаны
+ предыдущие значения. Измените параметры ещё раз для повторного
+ расчёта.
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx
index 18efeab2..19223f24 100644
--- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx
+++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx
@@ -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: () => (
+
+ Загрузка 3D-модели…
+
+ ),
+ },
+);
+
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(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 (
)}
+ {/* ── 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. */}
+
+
+ 0 ? parcelAreaSqm : undefined}
+ maxFar={farTarget}
+ farIsReal={farIsReal}
+ onModelChange={handleModelChange}
+ />
+
+
+
+
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(null);
const rafRef = useRef(0);
const resizeObserverRef = useRef(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 (
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
index f6ad7a2f..f0d1cb1c 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -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({
+ mutationFn: (payload) =>
+ apiFetch("/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})`;
+}