Compare commits

..

No commits in common. "7866479653f06e72e4b670ae76a86aff62e9ccf8" and "09656077c118f2c47d032f00c9a5492ef78b7161" have entirely different histories.

4 changed files with 43 additions and 267 deletions

View file

@ -51,7 +51,7 @@ import {
DrawerActions, DrawerActions,
} from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
/** КСИТ-цель (FAR) — regulation default when НСПД max_far is absent. */ /** КСИТ-цель (FAR) — regulation default, see caption in the 3D sandbox. */
const MASSING_MAX_FAR = 3.5; const MASSING_MAX_FAR = 3.5;
/** Fallback parcel area (m²) when geometry_suitability.area_ha is absent. */ /** Fallback parcel area (m²) when geometry_suitability.area_ha is absent. */
const MASSING_FALLBACK_AREA = 6800; const MASSING_FALLBACK_AREA = 6800;
@ -65,21 +65,6 @@ function massingAreaM2(analysis: ParcelAnalysis): number {
return MASSING_FALLBACK_AREA; return MASSING_FALLBACK_AREA;
} }
/** Real КСИТ from НСПД-зонирование (PR #1847), else the regulation default. */
function massingMaxFar(analysis: ParcelAnalysis): number {
const maxFar = analysis.nspd_zoning?.max_far;
if (typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0) {
return maxFar;
}
return MASSING_MAX_FAR;
}
/** True when the КСИТ used by the massing sandbox is the real НСПД value. */
function massingFarIsReal(analysis: ParcelAnalysis): boolean {
const maxFar = analysis.nspd_zoning?.max_far;
return typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0;
}
const SRC_UPDATED = "источник: /analyze"; const SRC_UPDATED = "источник: /analyze";
// ── shared helpers ──────────────────────────────────────────────────────────── // ── shared helpers ────────────────────────────────────────────────────────────
@ -822,31 +807,22 @@ export function OksContent() {
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
export function Future3dContent({ analysis }: { analysis: ParcelAnalysis }) { export function Future3dContent({ analysis }: { analysis: ParcelAnalysis }) {
const maxFar = massingMaxFar(analysis);
const farReal = massingFarIsReal(analysis);
return ( return (
<> <>
<DrawerSection> <DrawerSection>
<SummaryBox> <SummaryBox>
Генеративная масса по регламенту: КСИТ-цель {maxFar.toFixed(1)} · Генеративная масса по регламенту: КСИТ-цель {MASSING_MAX_FAR} · пятно
пятно и высота подбираются под целевую ёмкость, GFA и КСИТ-факт и высота подбираются под целевую ёмкость, GFA и КСИТ-факт считаются от
считаются от вводных. Орбита, зум и солнце интерактивные. вводных. Орбита, зум и солнце интерактивные.
</SummaryBox> </SummaryBox>
</DrawerSection> </DrawerSection>
<DrawerSection> <DrawerSection>
<MassingViewer <MassingViewer
areaM2={massingAreaM2(analysis)} areaM2={massingAreaM2(analysis)}
maxFar={maxFar} maxFar={MASSING_MAX_FAR}
farIsReal={farReal}
/> />
</DrawerSection> </DrawerSection>
<SourceLine <SourceLine source="Песочница массинга · КСИТ-цель регламент-дефолт (НСПД)" />
source={
farReal
? "Песочница массинга · КСИТ-цель регламент НСПД (max_far)"
: "Песочница массинга · КСИТ-цель регламент-дефолт (НСПД)"
}
/>
</> </>
); );
} }
@ -864,8 +840,7 @@ export function InsolationContent({ analysis }: { analysis: ParcelAnalysis }) {
<DrawerSection> <DrawerSection>
<MassingViewer <MassingViewer
areaM2={massingAreaM2(analysis)} areaM2={massingAreaM2(analysis)}
maxFar={massingMaxFar(analysis)} maxFar={MASSING_MAX_FAR}
farIsReal={massingFarIsReal(analysis)}
/> />
</DrawerSection> </DrawerSection>
<DrawerSection heading="ЧТО ДОБАВИТСЯ В МОДУЛЕ"> <DrawerSection heading="ЧТО ДОБАВИТСЯ В МОДУЛЕ">

View file

@ -26,10 +26,8 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
export interface MassingSceneProps { export interface MassingSceneProps {
/** Real parcel area in m² when available (fallback ≈ 6800). */ /** Real parcel area in m² when available (fallback ≈ 6800). */
areaM2?: number; areaM2?: number;
/** КСИТ-цель (FAR target) — real НСПД-регламент when present, else default. */ /** КСИТ-цель (FAR target) — regulation default, not parcel-specific. */
maxFar?: number; maxFar?: number;
/** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */
farIsReal?: boolean;
} }
// ── constants (scene units = metres) ────────────────────────────────────────── // ── constants (scene units = metres) ──────────────────────────────────────────
@ -300,7 +298,6 @@ function modelMetrics(model: MassingModel): Metrics {
export default function MassingScene({ export default function MassingScene({
areaM2, areaM2,
maxFar, maxFar,
farIsReal = false,
}: 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;
@ -708,8 +705,7 @@ export default function MassingScene({
</div> </div>
<div className={styles.massingCaption}> <div className={styles.massingCaption}>
КСИТ-цель {farTarget.toFixed(1)} ·{" "} КСИТ-цель {farTarget.toFixed(1)} · регламент-дефолт, источник НСПД ·
{farIsReal ? "регламент НСПД" : "регламент-дефолт, источник НСПД"} ·
масса генеративная (этажи/секции/время исследовательские вводные) масса генеративная (этажи/секции/время исследовательские вводные)
</div> </div>
</div> </div>

View file

@ -14,7 +14,6 @@ import type {
ParcelAnalysis, ParcelAnalysis,
ParcelAnalysisCompetitor, ParcelAnalysisCompetitor,
} from "@/types/site-finder"; } from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast"; import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ────────────────────────────────────────────────────────────── // ── View-model ──────────────────────────────────────────────────────────────
@ -91,29 +90,6 @@ function formatRubPerM2(n: number): string {
return `${formatInt(n)} ₽/м²`; return `${formatInt(n)} ₽/м²`;
} }
// ── НСПД-регламент (ПЗЗ) helpers ──────────────────────────────────────────────
/** КСИТ (max_far) as a plain RU number — e.g. "3,5". */
function formatFar(far: number): string {
return fmtNum(far, 1);
}
/**
* Real parcel area in m² when geometry is known (area_ha × 10000); null
* otherwise. The single point at which we turn area_ha into m² for ёмкость.
*/
function parcelAreaM2(a: ParcelAnalysis): number | null {
const areaHa = a.geometry_suitability?.area_ha;
return areaHa != null ? areaHa * 10000 : null;
}
/** Human regulation zone — prefer the readable index ("Ж-2") over reg-number. */
function regulationZone(z: NspdZoning | null | undefined): string | null {
return z?.regulation_zone_index ?? z?.zone_code ?? null;
}
const SRC_NSPD_ZONING = "источник НСПД-зонирование";
// ── Passport ────────────────────────────────────────────────────────────────── // ── Passport ──────────────────────────────────────────────────────────────────
export interface PticaPassport { export interface PticaPassport {
@ -320,49 +296,25 @@ function utilityLabel(subtype: string): string {
return UTILITY_LABELS[subtype] ?? subtype; return UTILITY_LABELS[subtype] ?? subtype;
} }
/** /** Градостроительство — mostly placeholder (НСПД-зонирование / источники). */
* Градостроительство REAL ПЗЗ-регламент when nspd_zoning carries it
* (PR #1847), honest placeholder otherwise. Зона prefers the human index
* ("Ж-2"); КСИТ / высота / плотность light up once the backend field flows.
*/
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
const z = a.nspd_zoning; const zoneCode = a.nspd_zoning?.zone_code;
const zone = regulationZone(z);
const maxFar = z?.max_far;
const maxHeight = z?.max_height_m;
const areaM2 = parcelAreaM2(a);
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
const densityField =
maxFar != null
? areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
: placeholder(SRC_NSPD_ZONING);
return { return {
title: "Градостроительство", title: "Градостроительство",
rows: [ rows: [
{ {
key: "Зона", key: "Зона",
field: zone field: zoneCode
? { value: zone, isReal: true, caption: "НСПД-зонирование" } ? { value: zoneCode, isReal: true, caption: "НСПД-зонирование" }
: placeholder(SRC_NSPD_ZONING), : placeholder("источник НСПД-зонирование"),
}, },
{ {
key: "Пред. высота", key: "Пред. высота",
field: field: placeholder("источник НСПД-зонирование"),
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: placeholder(SRC_NSPD_ZONING),
}, },
{ {
key: "Плотность (КСИТ)", key: "Плотность (КСИТ)",
field: densityField, field: placeholder("источник НСПД-зонирование"),
}, },
{ {
key: "Пятно застройки", key: "Пятно застройки",
@ -640,40 +592,6 @@ export function adaptBuildabilityDrawer(
}, },
]; ];
// ── Пятно / продаваемая площадь — REAL when ПЗЗ max_far flows (PR #1847) ──
// КСИТ ёмкость (надземная GFA) = площадь × max_far. Footprint (пятно) needs
// the building-coverage % (max_building_pct); sellable still depends on
// МОП/parking, so it stays an estimate placeholder.
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far;
const farReal = maxFar != null;
const buildingPct = z?.max_building_pct;
const spotRow: DrawerKvRow =
areaM2 != null && buildingPct != null
? {
k: "Пятно застройки",
field: {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
},
}
: { k: "Пятно застройки", field: notReal(SRC_NSPD_ZONING) };
const sellableRow: DrawerKvRow =
farReal && areaM2 != null
? {
k: "Продаваемая площадь",
field: {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
},
}
: { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") };
const geometry: DrawerKvRow[] = [ const geometry: DrawerKvRow[] = [
{ {
k: "Площадь", k: "Площадь",
@ -700,8 +618,8 @@ export function adaptBuildabilityDrawer(
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`) ? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
: notReal("нет геометрии"), : notReal("нет геометрии"),
}, },
spotRow, { k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
sellableRow, { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") },
]; ];
const penalties = (geom?.penalties ?? []).map((p) => ({ const penalties = (geom?.penalties ?? []).map((p) => ({
@ -709,11 +627,8 @@ export function adaptBuildabilityDrawer(
impact: "штраф", impact: "штраф",
})); }));
const summary = farReal const summary =
? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " + "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
: "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " + "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после расчёта потенциала (max_far НСПД) и финмодели."; "значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
@ -738,34 +653,13 @@ export interface PticaUrbanDrawer {
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer { export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
const zoning = a.nspd_zoning; const zoning = a.nspd_zoning;
// Территориальная зона: prefer human index ("Ж-2"), else reg-number code. const zoneCode = zoning?.zone_code
const zone = regulationZone(zoning); ? real(zoning.zone_code)
const zoneCode = zone ? real(zone) : notReal(SRC_NSPD_ZONING); : notReal("источник НСПД-зонирование");
const zoneName = zoning?.zone_name const zoneName = zoning?.zone_name
? real(zoning.zone_name) ? real(zoning.zone_name)
: notReal(SRC_NSPD_ZONING); : notReal("источник НСПД-зонирование");
const maxFar = zoning?.max_far;
const maxHeight = zoning?.max_height_m;
const buildingPct = zoning?.max_building_pct;
const areaM2 = parcelAreaM2(a);
const regSource = zoning?.regulation_source;
// КСИТ ёмкость (надземная GFA) = площадь × max_far.
const densityField: PticaField =
maxFar != null && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: regSource
? `КСИТ ${formatFar(maxFar)} · ${regSource}`
: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: maxFar != null
? notReal("нужна геометрия участка")
: notReal("после расчёта потенциала");
const farReal = maxFar != null;
const regulation: DrawerKvRow[] = [ const regulation: DrawerKvRow[] = [
{ k: "Территориальная зона", field: zoneCode }, { k: "Территориальная зона", field: zoneCode },
{ k: "Наименование зоны", field: zoneName }, { k: "Наименование зоны", field: zoneName },
@ -775,42 +669,17 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
? real(a.egrn.permitted_use_text) ? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"), : notReal("нет данных ЕГРН"),
}, },
{ { k: "КСИТ (max FAR)", field: notReal("нужен max_far (НСПД)") },
k: "КСИТ (max FAR)", { k: "Предельная высота", field: notReal("источник НСПД-зонирование") },
field: farReal
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
},
{
k: "Предельная высота",
field:
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: notReal(SRC_NSPD_ZONING),
},
{
k: "Этажность (предельная)",
field:
zoning?.max_floors != null
? real(`${formatInt(zoning.max_floors)} эт.`)
: notReal(SRC_NSPD_ZONING),
},
{ {
k: "Коэфф. застройки (пятно)", k: "Коэфф. застройки (пятно)",
field: field: notReal("источник НСПД-зонирование"),
buildingPct != null
? real(`${formatInt(buildingPct)} %`)
: notReal(SRC_NSPD_ZONING),
}, },
{ k: "Плотность (КСИТ)", field: densityField }, { k: "Плотность (КСИТ)", field: notReal("после расчёта потенциала") },
]; ];
const summary = farReal const summary =
? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " + "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
"Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
"застройки — реальные регламентные значения; плотность рассчитана от " +
"площади участка."
: "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " + "НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
"недоступны в выгрузке — отмечены «—» с источником."; "недоступны в выгрузке — отмечены «—» с источником.";
@ -1429,75 +1298,20 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`) ? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
: notReal("нет геометрии"); : notReal("нет геометрии");
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far;
const farReal = maxFar != null;
const buildingPct = z?.max_building_pct;
const maxHeight = z?.max_height_m;
const maxFloors = z?.max_floors;
// КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs.
const capacityField: PticaField =
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · площадь × КСИТ`,
}
: notReal("нужен max_far (НСПД)");
// Пятно застройки = площадь × коэф. застройки (max_building_pct).
const spotField: PticaField =
areaM2 != null && buildingPct != null
? {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: notReal("нужен % застройки (НСПД)");
// Расчётная этажность — прямо из регламента, либо высота / 3 м (этаж).
const floorsField: PticaField =
maxFloors != null
? real(`${formatInt(maxFloors)} эт.`)
: maxHeight != null
? {
value: `${formatInt(maxHeight / 3)} эт.`,
isReal: true,
caption: `от предельной высоты ${formatInt(maxHeight)} м`,
}
: notReal("нужна предельная высота (НСПД)");
const rows: DrawerKvRow[] = [ const rows: DrawerKvRow[] = [
{ k: "Площадь участка", field: areaField }, { k: "Площадь участка", field: areaField },
{ k: "КСИТ (max_far)", field: notReal("нужен max_far (НСПД)") },
{ k: "Ёмкость по КСИТ", field: notReal("нужен max_far (НСПД)") },
{ k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
{ k: "Продаваемая площадь", field: notReal("после расчёта потенциала") },
{ {
k: "КСИТ (max_far)", k: "Расчётная этажность",
field: farReal field: notReal("нужна предельная высота (НСПД)"),
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
}, },
{ k: "Ёмкость по КСИТ", field: capacityField },
{ k: "Пятно застройки", field: spotField },
{
k: "Продаваемая площадь",
field:
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
}
: notReal("после расчёта потенциала"),
},
{ k: "Расчётная этажность", field: floorsField },
]; ];
const summary = farReal const summary =
? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " + "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
"max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
"(без вычета МОП и паркинга), уточняется финмоделью."
: "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка."; "КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
return { summary, rows }; return { summary, rows };

View file

@ -7,15 +7,6 @@ export interface NspdZoning {
zone_name: string | null; zone_name: string | null;
source?: string; source?: string;
raw_props?: Record<string, unknown>; raw_props?: Record<string, unknown>;
// Real ПЗЗ regulation (PR #1847) — optional + nullable so the cockpit stays
// backward-compatible until #1847 deploys, then auto-lights-up.
max_far?: number | null; // КСИТ (предельный коэф. плотности застройки)
max_height_m?: number | null; // предельная высота, м
max_floors?: number | null; // предельная этажность
max_building_pct?: number | null; // коэф. застройки (процент пятна)
min_parcel_area_m2?: number | null; // мин. площадь участка, м²
regulation_zone_index?: string | null; // human zone index, e.g. "Ж-2"
regulation_source?: string | null; // источник регламента
} }
export interface NspdZouitOverlap { export interface NspdZouitOverlap {