feat(concept): живой 3D-massing → пересчёт финмодели в «7. Концепция» (#1965 Stage 2b) #2026

Merged
bot-backend merged 1 commit from feat/concept-live-massing-1965-stage2b into main 2026-06-27 20:10:42 +00:00
4 changed files with 593 additions and 10 deletions

View file

@ -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>
);
}

View file

@ -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

View file

@ -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}>

View file

@ -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})`;
}