Port the prototype's missing sections — the React cockpit had only hero +
Development Scan, so the deployed page was incomplete («поплывшее»). Add:
- lower-grid: ОКС · Development Potential · Recommended Product (квартирография)
· Инсоляционная матрица (opens 3D);
- bottom-grid: Investment Clearance · Buy Signal · Legal Status · Site Verdict
(«Приобретать/Нельзя/Нужна проверка» plaque + checklist).
Move ОКС + Инсоляция out of DevelopmentScan → scan = 7 cards (prototype IA).
Data honesty: Development Potential (real КСИТ/площадь/плотность), Recommended
Product (forecast product_tz), Buy Signal + Site Verdict (forecast deficit +
gate_verdict, real, three-state), Legal (zouit/red_lines/gate). Investment
Clearance all «—» «после финмодели (§23)» — no fabricated numbers. Typed
{value,isReal,caption} adapters; scoped dark CSS; forecast-pending safe.
Cleanup: drop orphaned adaptOksCard/adaptInsolationCard; add .metric/.bignum.
1993 lines
70 KiB
TypeScript
1993 lines
70 KiB
TypeScript
/**
|
||
* ptica-adapt — PURE typed mappers ParcelAnalysis → cockpit view-models.
|
||
*
|
||
* INCREMENT 1 contract: render REAL data where the backend provides it, and an
|
||
* honest PLACEHOLDER ("—" + caption) where a field is absent or not yet wired.
|
||
* Every value the cockpit renders flows through here as a {value, isReal,
|
||
* caption?} view-model so the UI never presents a fake hard number as live.
|
||
*
|
||
* No React, no side effects — just narrowing + formatting. The single source of
|
||
* truth for the real-vs-placeholder decision.
|
||
*/
|
||
|
||
import type {
|
||
ParcelAnalysis,
|
||
ParcelAnalysisCompetitor,
|
||
} from "@/types/site-finder";
|
||
import type { NspdZoning } from "@/types/nspd";
|
||
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
||
|
||
// ── View-model ──────────────────────────────────────────────────────────────
|
||
|
||
/** A single field as shown in the cockpit. `caption` annotates placeholders. */
|
||
export interface PticaField {
|
||
value: string;
|
||
isReal: boolean;
|
||
caption?: string;
|
||
}
|
||
|
||
/** Gauge view-model — value 0..100 (null = no data → render muted track). */
|
||
export interface PticaGauge {
|
||
/** 0..100 progress, or null when the metric is unknown. */
|
||
value: number | null;
|
||
/** Center caption under the number (e.g. "Можно строить"). */
|
||
label: string;
|
||
/** Severity bucket drives the stroke color via CSS tokens. */
|
||
tone: "good" | "warn" | "bad" | "neutral";
|
||
isReal: boolean;
|
||
/** Footnote (e.g. "предварительная оценка"). */
|
||
footnote?: string;
|
||
}
|
||
|
||
/** A titled scan card: metric rows + optional footnote. */
|
||
export interface PticaScanCard {
|
||
title: string;
|
||
/** Subtitle shown next to the title (e.g. "предв."). */
|
||
badge?: string;
|
||
rows: PticaScanRow[];
|
||
/** Empty-state message when there is nothing to render. */
|
||
emptyState?: string;
|
||
}
|
||
|
||
export interface PticaScanRow {
|
||
key: string;
|
||
field: PticaField;
|
||
}
|
||
|
||
const PLACEHOLDER = "—";
|
||
|
||
function real(value: string): PticaField {
|
||
return { value, isReal: true };
|
||
}
|
||
|
||
function placeholder(caption: string): PticaField {
|
||
return { value: PLACEHOLDER, isReal: false, caption };
|
||
}
|
||
|
||
// ── Formatting helpers (ru typography) ────────────────────────────────────────
|
||
|
||
function formatInt(n: number): string {
|
||
return Math.round(n).toLocaleString("ru-RU");
|
||
}
|
||
|
||
/** Fixed-decimal RU number (used for the invest-score on a /10 scale). */
|
||
function fmtNum(value: number, digits = 1): string {
|
||
return value.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
});
|
||
}
|
||
|
||
/** м² from area_ha (×10000). */
|
||
function areaM2FromHa(areaHa: number): string {
|
||
return `${formatInt(areaHa * 10000)} м²`;
|
||
}
|
||
|
||
function formatHa(areaHa: number): string {
|
||
return `${areaHa.toFixed(2)} га`;
|
||
}
|
||
|
||
function formatRubPerM2(n: number): string {
|
||
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 ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface PticaPassport {
|
||
cadNum: string;
|
||
district: PticaField;
|
||
area: PticaField;
|
||
address: PticaField;
|
||
landCategory: PticaField;
|
||
vri: PticaField;
|
||
status: PticaField;
|
||
encumbrances: PticaField;
|
||
}
|
||
|
||
export function adaptPassport(a: ParcelAnalysis): PticaPassport {
|
||
const areaHa = a.geometry_suitability?.area_ha;
|
||
const area =
|
||
areaHa != null
|
||
? real(`${formatHa(areaHa)} · ${areaM2FromHa(areaHa)}`)
|
||
: placeholder("нет геометрии");
|
||
|
||
const districtName = a.district?.district_name;
|
||
const address = a.egrn?.address;
|
||
const landCategory = a.egrn?.land_category;
|
||
const vri = a.egrn?.permitted_use_text;
|
||
const status = a.egrn?.parcel_status;
|
||
|
||
return {
|
||
cadNum: a.cad_num,
|
||
district: districtName ? real(districtName) : placeholder("нет данных"),
|
||
area,
|
||
address: address ? real(address) : placeholder("нет данных ЕГРН"),
|
||
landCategory: landCategory
|
||
? real(landCategory)
|
||
: placeholder("нет данных ЕГРН"),
|
||
vri: vri ? real(vri) : placeholder("нет данных ЕГРН"),
|
||
status: status ? real(status) : placeholder("нет данных ЕГРН"),
|
||
// Matches the existing analysis page: encumbrances are not surfaced yet.
|
||
encumbrances: { value: PLACEHOLDER, isReal: true },
|
||
};
|
||
}
|
||
|
||
// ── Buildability gauge (soft proxy — PLACEHOLDER, isReal:false) ────────────────
|
||
|
||
/**
|
||
* Derive a soft buildability proxy from the gate verdict + geometry suitability.
|
||
* This is NOT a backend score — it's a preliminary heuristic, flagged with a
|
||
* "предварительная оценка" footnote so it never reads as a live index.
|
||
*/
|
||
export function adaptBuildabilityGauge(a: ParcelAnalysis): PticaGauge {
|
||
const gate = a.gate_verdict;
|
||
const geom = a.geometry_suitability;
|
||
|
||
// Gate contributes the bulk; geometry suitability nudges within the band.
|
||
let base: number | null = null;
|
||
let tone: PticaGauge["tone"] = "neutral";
|
||
let label = "нет данных";
|
||
|
||
if (gate) {
|
||
if (gate.can_build_mkd === true) {
|
||
base = 78;
|
||
tone = "good";
|
||
label = "Можно строить";
|
||
} else if (gate.can_build_mkd === false) {
|
||
base = 24;
|
||
tone = "bad";
|
||
label = "Нельзя";
|
||
} else {
|
||
base = 50;
|
||
tone = "warn";
|
||
label = "Нужна проверка";
|
||
}
|
||
// Each blocker/warning trims the proxy a little.
|
||
base -= gate.blockers.length * 8 + gate.warnings.length * 3;
|
||
}
|
||
|
||
// Geometry suitability (0..1) shifts the proxy ±10.
|
||
if (base != null && geom?.suitability_score != null) {
|
||
base += (geom.suitability_score - 0.5) * 20;
|
||
}
|
||
|
||
const value =
|
||
base == null ? null : Math.max(0, Math.min(100, Math.round(base)));
|
||
|
||
return {
|
||
value,
|
||
label,
|
||
tone,
|
||
isReal: false,
|
||
footnote: "предварительная оценка",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Risk gauge — inverse soft proxy from the same signals. Lower is better.
|
||
* PLACEHOLDER ("предв.") until the real risk model is wired.
|
||
*/
|
||
export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge {
|
||
const build = adaptBuildabilityGauge(a);
|
||
const value = build.value == null ? null : 100 - build.value;
|
||
|
||
let tone: PticaGauge["tone"] = "neutral";
|
||
let label = "нет данных";
|
||
if (value != null) {
|
||
if (value <= 33) {
|
||
tone = "good";
|
||
label = "Низкий";
|
||
} else if (value <= 66) {
|
||
tone = "warn";
|
||
label = "Средний";
|
||
} else {
|
||
tone = "bad";
|
||
label = "Высокий";
|
||
}
|
||
}
|
||
|
||
return { value, label, tone, isReal: false, footnote: "предв." };
|
||
}
|
||
|
||
// ── Invest score block (PLACEHOLDER — после прогноза) ─────────────────────────
|
||
|
||
export interface PticaInvestScore {
|
||
score: PticaField;
|
||
potential: PticaField;
|
||
risk: PticaField;
|
||
buySignal: PticaField;
|
||
}
|
||
|
||
/**
|
||
* Buy-signal from the §22 forecast exec-summary key numbers. Derived from the
|
||
* (signed) deficit_index — the long `verdict` sentence is a paragraph, not a
|
||
* badge, so we map the structured KPI instead. >0.05 недонасыщенность →
|
||
* «приобретать», <−0.05 затоварка → «пропустить», иначе «наблюдать». Flagged
|
||
* advisory (the forecast itself is advisory). Independent of the overall score,
|
||
* so it surfaces as soon as the report is ready even if scoring is thin.
|
||
*/
|
||
function buySignalFromForecast(report: ForecastReport): PticaField {
|
||
const kn = report.exec_summary.key_numbers;
|
||
const di = kn.deficit_index;
|
||
if (di == null) {
|
||
return { value: "наблюдать", isReal: true, caption: "advisory · §22" };
|
||
}
|
||
const word =
|
||
di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
|
||
return { value: word, isReal: true, caption: "advisory · §22" };
|
||
}
|
||
|
||
/**
|
||
* Invest-score view-model. INCREMENT 1: PLACEHOLDER until the §22 forecast is
|
||
* ready; PR#2: when `report` is present, the score is overall_score×10 (∈ 0..10)
|
||
* and the buy-signal is derived from the exec-summary KPIs. While the forecast is
|
||
* still pending (`report` undefined) the «после прогноза» placeholders stand.
|
||
*/
|
||
export function adaptInvestScore(
|
||
a: ParcelAnalysis,
|
||
report?: ForecastReport,
|
||
): PticaInvestScore {
|
||
const risk = adaptRiskGauge(a);
|
||
const riskField: PticaField =
|
||
risk.value != null
|
||
? { value: risk.label, isReal: false, caption: "предв." }
|
||
: placeholder("предв.");
|
||
|
||
const overall =
|
||
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
|
||
|
||
// Buy-signal is derived from the deficit_index alone, so it surfaces whenever
|
||
// the report is ready — independent of whether the overall score is present.
|
||
const buySignal = report
|
||
? buySignalFromForecast(report)
|
||
: placeholder("после прогноза (Scenarios)");
|
||
|
||
if (report && overall != null) {
|
||
// overall_score ∈ 0..1 → /10 scale (one decimal), advisory.
|
||
const scoreOn10 = real(fmtNum(overall * 10, 1));
|
||
scoreOn10.caption = "advisory · §22";
|
||
return {
|
||
score: scoreOn10,
|
||
potential: placeholder("после финмодели"),
|
||
risk: riskField,
|
||
buySignal,
|
||
};
|
||
}
|
||
|
||
return {
|
||
score: placeholder("после прогноза (Scenarios)"),
|
||
potential: placeholder("после прогноза (Scenarios)"),
|
||
risk: riskField,
|
||
buySignal,
|
||
};
|
||
}
|
||
|
||
// ── Development Scan cards ─────────────────────────────────────────────────────
|
||
|
||
// subtype → ru label for the engineering card.
|
||
const UTILITY_LABELS: Record<string, string> = {
|
||
substation: "Подстанция",
|
||
pipeline: "Трубопровод",
|
||
power_line: "ЛЭП",
|
||
water_intake: "Водозабор",
|
||
pumping_station: "Насосная станция",
|
||
};
|
||
|
||
function utilityLabel(subtype: string): string {
|
||
return UTILITY_LABELS[subtype] ?? subtype;
|
||
}
|
||
|
||
/**
|
||
* Градостроительство — 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 {
|
||
const z = a.nspd_zoning;
|
||
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 {
|
||
title: "Градостроительство",
|
||
rows: [
|
||
{
|
||
key: "Зона",
|
||
field: zone
|
||
? { value: zone, isReal: true, caption: "НСПД-зонирование" }
|
||
: placeholder(SRC_NSPD_ZONING),
|
||
},
|
||
{
|
||
key: "Пред. высота",
|
||
field:
|
||
maxHeight != null
|
||
? real(`${formatInt(maxHeight)} м`)
|
||
: placeholder(SRC_NSPD_ZONING),
|
||
},
|
||
{
|
||
key: "Плотность (КСИТ)",
|
||
field: densityField,
|
||
},
|
||
{
|
||
key: "Пятно застройки",
|
||
field: placeholder("после расчёта потенциала"),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Ограничения · ЗОУИТ — ЗОУИТ count is real; the rest placeholder. */
|
||
export function adaptRestrictionsCard(a: ParcelAnalysis): PticaScanCard {
|
||
const zouit = a.nspd_zouit_overlaps;
|
||
return {
|
||
title: "Ограничения · ЗОУИТ",
|
||
rows: [
|
||
{
|
||
key: "ЗОУИТ",
|
||
field:
|
||
zouit != null
|
||
? real(formatInt(zouit.length))
|
||
: placeholder("нет данных НСПД"),
|
||
},
|
||
{ key: "Красные линии", field: placeholder("источник НСПД-граддок") },
|
||
{ key: "ОКН", field: placeholder("источник НСПД") },
|
||
{ key: "Сервитуты", field: placeholder("источник ЕГРН") },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Инженерия — utilities.summary rows (subtype → label + nearest_m). REAL. */
|
||
export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard {
|
||
const summary = a.utilities?.summary ?? [];
|
||
if (summary.length === 0) {
|
||
return {
|
||
title: "Инженерия",
|
||
rows: [],
|
||
emptyState: "Нет данных по инженерным сетям",
|
||
};
|
||
}
|
||
return {
|
||
title: "Инженерия",
|
||
rows: summary.map((u) => ({
|
||
key: utilityLabel(u.subtype),
|
||
field: real(`${formatInt(u.nearest_m)} м`),
|
||
})),
|
||
};
|
||
}
|
||
|
||
/** Рынок — median price + pipeline count are REAL; demand/absorption placeholder. */
|
||
export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
|
||
const median = a.district?.median_price_per_m2;
|
||
const newProjects = a.pipeline_24mo?.objects_count;
|
||
return {
|
||
title: "Рынок",
|
||
rows: [
|
||
{
|
||
key: "Медиана цены",
|
||
field:
|
||
median != null
|
||
? real(formatRubPerM2(median))
|
||
: placeholder("нет данных района"),
|
||
},
|
||
{
|
||
key: "Новых проектов",
|
||
field:
|
||
newProjects != null
|
||
? real(formatInt(newProjects))
|
||
: placeholder("нет пайплайна"),
|
||
},
|
||
{ key: "Поглощение", field: placeholder("после прогноза") },
|
||
{ key: "Уровень спроса", field: placeholder("после прогноза") },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Экономика — PLACEHOLDER (предв.), surfaced after the financial model. */
|
||
export function adaptEconomyCard(): PticaScanCard {
|
||
return {
|
||
title: "Экономика",
|
||
badge: "предв.",
|
||
rows: [
|
||
{ key: "Потенц. выручки", field: placeholder("после финмодели") },
|
||
{ key: "Маржа", field: placeholder("после финмодели") },
|
||
{ key: "ROI", field: placeholder("после финмодели") },
|
||
{ key: "IRR", field: placeholder("после финмодели") },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Среда · экология — PLACEHOLDER for INCREMENT 1. */
|
||
export function adaptEnvironmentCard(): PticaScanCard {
|
||
return {
|
||
title: "Среда",
|
||
badge: "экология",
|
||
rows: [
|
||
{ key: "Шум", field: placeholder("после атмосферного слоя") },
|
||
{ key: "Воздух (AQI)", field: placeholder("после атмосферного слоя") },
|
||
{ key: "Зелёных зон", field: placeholder("после атмосферного слоя") },
|
||
],
|
||
};
|
||
}
|
||
|
||
// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// Each drawer consumes a typed view-model below rather than reaching into
|
||
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
|
||
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
|
||
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
|
||
// muted by the drawer content so they never read as live.
|
||
|
||
const DASH = "—";
|
||
|
||
function notReal(caption: string): PticaField {
|
||
return { value: DASH, isReal: false, caption };
|
||
}
|
||
|
||
/** A single "Параметр → Значение" row with real/placeholder provenance. */
|
||
export interface DrawerKvRow {
|
||
k: string;
|
||
field: PticaField;
|
||
}
|
||
|
||
function metersField(
|
||
m: number | null | undefined,
|
||
caption: string,
|
||
): PticaField {
|
||
return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
|
||
}
|
||
|
||
// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────
|
||
|
||
export interface PticaPassportDrawer {
|
||
summary: string;
|
||
registry: DrawerKvRow[];
|
||
zouitCount: number | null;
|
||
zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
|
||
}
|
||
|
||
export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
|
||
const p = adaptPassport(a);
|
||
const areaHa = a.geometry_suitability?.area_ha;
|
||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||
|
||
const districtPart = a.district?.district_name
|
||
? `, ${a.district.district_name} р-н`
|
||
: "";
|
||
const summary =
|
||
`Участок ${a.cad_num}${districtPart}. ` +
|
||
(a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
|
||
(a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
|
||
(a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");
|
||
|
||
const registry: DrawerKvRow[] = [
|
||
{ k: "Кадастровый номер", field: real(a.cad_num) },
|
||
{ k: "Адрес", field: p.address },
|
||
{ k: "Район", field: p.district },
|
||
{ k: "Площадь", field: p.area },
|
||
{
|
||
k: "Площадь, га",
|
||
field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
|
||
},
|
||
{ k: "Категория земель", field: p.landCategory },
|
||
{ k: "ВРИ", field: p.vri },
|
||
{ k: "Статус ЕГРН", field: p.status },
|
||
{
|
||
k: "Кадастровая стоимость",
|
||
field:
|
||
a.egrn?.cadastral_value_rub != null
|
||
? real(`${formatInt(a.egrn.cadastral_value_rub)} ₽`)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "Стоимость, ₽/м²",
|
||
field:
|
||
a.egrn?.cost_per_m2_rub != null
|
||
? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "Дата обновления ЕГРН",
|
||
field: a.egrn?.last_egrn_update_date
|
||
? real(a.egrn.last_egrn_update_date)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{ k: "Обременения", field: { value: DASH, isReal: true } },
|
||
];
|
||
|
||
const zouitOverlaps = (zouit ?? []).map((z) => ({
|
||
layer: z.layer,
|
||
name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
|
||
sub: z.subcategory != null ? String(z.subcategory) : DASH,
|
||
}));
|
||
|
||
return {
|
||
summary,
|
||
registry,
|
||
zouitCount: zouit != null ? zouit.length : null,
|
||
zouitOverlaps,
|
||
};
|
||
}
|
||
|
||
// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────
|
||
|
||
export interface PticaFactorBar {
|
||
name: string;
|
||
pct: number;
|
||
value: string;
|
||
tone: "good" | "bad" | "neutral";
|
||
reason: string;
|
||
}
|
||
|
||
export interface PticaBuildabilityDrawer {
|
||
gauge: PticaGauge;
|
||
summary: string;
|
||
factors: PticaFactorBar[];
|
||
geometry: DrawerKvRow[];
|
||
penalties: Array<{ label: string; impact: string }>;
|
||
isReal: boolean;
|
||
}
|
||
|
||
export function adaptBuildabilityDrawer(
|
||
a: ParcelAnalysis,
|
||
): PticaBuildabilityDrawer {
|
||
const gauge = adaptBuildabilityGauge(a);
|
||
const gate = a.gate_verdict;
|
||
const geom = a.geometry_suitability;
|
||
const utilCount = a.utilities?.summary?.length ?? null;
|
||
|
||
// Soft, derived factor contributions — labelled «предв.» everywhere.
|
||
const factors: PticaFactorBar[] = [
|
||
{
|
||
name: "Гейт-вердикт",
|
||
pct:
|
||
gate?.can_build_mkd === true
|
||
? 90
|
||
: gate?.can_build_mkd === false
|
||
? 20
|
||
: 55,
|
||
value: gate?.verdict_label ?? DASH,
|
||
tone:
|
||
gate?.can_build_mkd === true
|
||
? "good"
|
||
: gate?.can_build_mkd === false
|
||
? "bad"
|
||
: "neutral",
|
||
reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
|
||
},
|
||
{
|
||
name: "Геометрия",
|
||
pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
|
||
value:
|
||
geom?.suitability_score != null
|
||
? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
|
||
: DASH,
|
||
tone: "neutral",
|
||
reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
|
||
},
|
||
{
|
||
name: "Инженерия",
|
||
pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
|
||
value: utilCount != null ? `${utilCount} сетей` : DASH,
|
||
tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
|
||
reason: "близость инж. узлов (utilities)",
|
||
},
|
||
];
|
||
|
||
// ── Пятно / продаваемая площадь — 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[] = [
|
||
{
|
||
k: "Площадь",
|
||
field:
|
||
geom?.area_ha != null
|
||
? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
|
||
: notReal("нет геометрии"),
|
||
},
|
||
{
|
||
k: "Периметр",
|
||
field: metersField(geom?.perimeter_m, "нет геометрии"),
|
||
},
|
||
{
|
||
k: "Соотношение сторон",
|
||
field:
|
||
geom?.aspect_ratio != null
|
||
? real(fmtNum(geom.aspect_ratio, 2))
|
||
: notReal("нет геометрии"),
|
||
},
|
||
{
|
||
k: "Suitability score",
|
||
field:
|
||
geom?.suitability_score != null
|
||
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
|
||
: notReal("нет геометрии"),
|
||
},
|
||
spotRow,
|
||
sellableRow,
|
||
];
|
||
|
||
const penalties = (geom?.penalties ?? []).map((p) => ({
|
||
label: p,
|
||
impact: "штраф",
|
||
}));
|
||
|
||
const summary = farReal
|
||
? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
|
||
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
|
||
"значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
|
||
: "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
|
||
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
|
||
"значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
|
||
|
||
return {
|
||
gauge,
|
||
summary,
|
||
factors,
|
||
geometry,
|
||
penalties,
|
||
isReal: false,
|
||
};
|
||
}
|
||
|
||
// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────
|
||
|
||
export interface PticaUrbanDrawer {
|
||
summary: string;
|
||
regulation: DrawerKvRow[];
|
||
zoneCode: PticaField;
|
||
zoneName: PticaField;
|
||
}
|
||
|
||
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
|
||
const zoning = a.nspd_zoning;
|
||
// Территориальная зона: prefer human index ("Ж-2"), else reg-number code.
|
||
const zone = regulationZone(zoning);
|
||
const zoneCode = zone ? real(zone) : notReal(SRC_NSPD_ZONING);
|
||
const zoneName = zoning?.zone_name
|
||
? real(zoning.zone_name)
|
||
: notReal(SRC_NSPD_ZONING);
|
||
|
||
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[] = [
|
||
{ k: "Территориальная зона", field: zoneCode },
|
||
{ k: "Наименование зоны", field: zoneName },
|
||
{
|
||
k: "ВРИ (основной)",
|
||
field: a.egrn?.permitted_use_text
|
||
? real(a.egrn.permitted_use_text)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "КСИТ (max FAR)",
|
||
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: "Коэфф. застройки (пятно)",
|
||
field:
|
||
buildingPct != null
|
||
? real(`${formatInt(buildingPct)} %`)
|
||
: notReal(SRC_NSPD_ZONING),
|
||
},
|
||
{ k: "Плотность (КСИТ)", field: densityField },
|
||
];
|
||
|
||
const summary = farReal
|
||
? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " +
|
||
"Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
|
||
"застройки — реальные регламентные значения; плотность рассчитана от " +
|
||
"площади участка."
|
||
: "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
|
||
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
|
||
"недоступны в выгрузке — отмечены «—» с источником.";
|
||
|
||
return { summary, regulation, zoneCode, zoneName };
|
||
}
|
||
|
||
// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────
|
||
|
||
export interface PticaRestrictionsDrawer {
|
||
summary: string;
|
||
zouitCount: number | null;
|
||
zouitRows: Array<{ layer: string; sub: string; reg: string }>;
|
||
statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
|
||
}
|
||
|
||
export function adaptRestrictionsDrawer(
|
||
a: ParcelAnalysis,
|
||
): PticaRestrictionsDrawer {
|
||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||
const count = zouit != null ? zouit.length : null;
|
||
|
||
const zouitRows = (zouit ?? []).map((z) => ({
|
||
layer: z.name ?? z.type_zone ?? z.layer,
|
||
sub:
|
||
z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
|
||
reg: z.group_key,
|
||
}));
|
||
|
||
const statusRows: Array<{
|
||
label: string;
|
||
state: string;
|
||
tone: "ok" | "warn";
|
||
}> = [
|
||
{
|
||
label: "ЗОУИТ (зоны с особыми условиями)",
|
||
state: count != null ? `${count}` : "нет данных НСПД",
|
||
tone: count != null && count > 0 ? "warn" : "ok",
|
||
},
|
||
{
|
||
label: "Красные линии",
|
||
state:
|
||
a.nspd_red_lines != null
|
||
? `${a.nspd_red_lines.length}`
|
||
: "источник НСПД",
|
||
tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
|
||
},
|
||
{
|
||
label: "ОКН (объекты культ. наследия)",
|
||
state: "источник НСПД",
|
||
tone: "ok",
|
||
},
|
||
{ label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
|
||
];
|
||
|
||
const summary =
|
||
count != null
|
||
? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
|
||
"Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
|
||
: "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
|
||
"сервитуты отмечены источником.";
|
||
|
||
return { summary, zouitCount: count, zouitRows, statusRows };
|
||
}
|
||
|
||
export interface PticaLegalDrawer {
|
||
summary: string;
|
||
gate: DrawerKvRow[];
|
||
restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
|
||
registry: DrawerKvRow[];
|
||
}
|
||
|
||
export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
|
||
const gate = a.gate_verdict;
|
||
const restr = adaptRestrictionsDrawer(a);
|
||
|
||
const gateRows: DrawerKvRow[] = [
|
||
{
|
||
k: "Можно строить МКД",
|
||
field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
|
||
},
|
||
{
|
||
k: "Статус участка",
|
||
field: a.egrn?.parcel_status
|
||
? real(a.egrn.parcel_status)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "Категория земель",
|
||
field: a.egrn?.land_category
|
||
? real(a.egrn.land_category)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "ВРИ",
|
||
field: a.egrn?.permitted_use_text
|
||
? real(a.egrn.permitted_use_text)
|
||
: notReal("нет данных ЕГРН"),
|
||
},
|
||
{
|
||
k: "Зона ПЗЗ",
|
||
field: a.nspd_zoning?.zone_code
|
||
? real(a.nspd_zoning.zone_code)
|
||
: notReal("источник НСПД"),
|
||
},
|
||
];
|
||
|
||
const registry: DrawerKvRow[] = [
|
||
{
|
||
k: "ЗОУИТ",
|
||
field:
|
||
restr.zouitCount != null
|
||
? real(formatInt(restr.zouitCount))
|
||
: notReal("источник НСПД"),
|
||
},
|
||
{ k: "Обременения", field: { value: DASH, isReal: true } },
|
||
{
|
||
k: "Блокеры (gate)",
|
||
field: gate
|
||
? real(formatInt(gate.blockers.length))
|
||
: notReal("нет gate_verdict"),
|
||
},
|
||
{
|
||
k: "Предупреждения (gate)",
|
||
field: gate
|
||
? real(formatInt(gate.warnings.length))
|
||
: notReal("нет gate_verdict"),
|
||
},
|
||
];
|
||
|
||
const summary =
|
||
"Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
|
||
"ограничений. Обременения в текущей выгрузке не раскрываются («—»).";
|
||
|
||
return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
|
||
}
|
||
|
||
// ── engineering — utilities.summary (REAL) ────────────────────────────────────
|
||
|
||
export interface PticaEngineeringDrawer {
|
||
summary: string;
|
||
rows: Array<{ label: string; nearest: string; name: string; count: string }>;
|
||
bars: PticaFactorBar[];
|
||
hasData: boolean;
|
||
}
|
||
|
||
export function adaptEngineeringDrawer(
|
||
a: ParcelAnalysis,
|
||
): PticaEngineeringDrawer {
|
||
const summaryArr = a.utilities?.summary ?? [];
|
||
const hasData = summaryArr.length > 0;
|
||
|
||
const rows = summaryArr.map((u) => ({
|
||
label: utilityLabel(u.subtype),
|
||
nearest: `${formatInt(u.nearest_m)} м`,
|
||
name: u.name ?? DASH,
|
||
count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
|
||
}));
|
||
|
||
// bar = inverse distance (closer → fuller). Cap reference 2000 m.
|
||
const bars: PticaFactorBar[] = summaryArr.map((u) => ({
|
||
name: utilityLabel(u.subtype),
|
||
pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
|
||
value: `${formatInt(u.nearest_m)} м`,
|
||
tone: u.nearest_m <= 700 ? "good" : "neutral",
|
||
reason: u.name ?? "",
|
||
}));
|
||
|
||
const summary = hasData
|
||
? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
|
||
"анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
|
||
: "Данные по инженерным сетям недоступны для участка.";
|
||
|
||
return { summary, rows, bars, hasData };
|
||
}
|
||
|
||
// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────
|
||
|
||
export interface PticaMarketCompetitor {
|
||
name: string;
|
||
dev: string;
|
||
cls: string;
|
||
flats: string;
|
||
distance: string;
|
||
status: string;
|
||
}
|
||
|
||
export interface PticaMarketDrawer {
|
||
summary: string;
|
||
competitors: PticaMarketCompetitor[];
|
||
medianField: PticaField;
|
||
trend: {
|
||
field: PticaField;
|
||
recent: string;
|
||
prior: string;
|
||
count: string;
|
||
available: boolean;
|
||
};
|
||
speed: {
|
||
field: PticaField;
|
||
velocityScore: string;
|
||
monthly: string;
|
||
median: string;
|
||
confidence: string;
|
||
available: boolean;
|
||
};
|
||
plan: {
|
||
available: boolean;
|
||
note: string;
|
||
pipelineByClass: Array<{ cls: string; count: number }>;
|
||
};
|
||
}
|
||
|
||
function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
|
||
return {
|
||
name: c.comm_name ?? `#${c.obj_id}`,
|
||
dev: c.dev_name ?? DASH,
|
||
cls: c.obj_class ?? DASH,
|
||
flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
|
||
distance: `${formatInt(c.distance_m)} м`,
|
||
status: c.site_status ?? DASH,
|
||
};
|
||
}
|
||
|
||
export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
|
||
const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
|
||
const median = a.district?.median_price_per_m2;
|
||
const medianField =
|
||
median != null
|
||
? real(formatRubPerM2(median))
|
||
: notReal("нет данных района");
|
||
|
||
const mt = a.market_trend;
|
||
const trend = {
|
||
field:
|
||
mt != null
|
||
? real(
|
||
`${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`,
|
||
)
|
||
: notReal("после прогноза"),
|
||
recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH,
|
||
prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH,
|
||
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
|
||
available: mt != null,
|
||
};
|
||
|
||
const v = a.velocity ?? null;
|
||
const speed = {
|
||
field:
|
||
v != null
|
||
? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
|
||
: notReal("после прогноза"),
|
||
velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
|
||
monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
|
||
median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
|
||
confidence: v != null ? v.confidence : DASH,
|
||
available: v != null,
|
||
};
|
||
|
||
const pipeline = a.pipeline_24mo ?? null;
|
||
const pipelineByClass = pipeline
|
||
? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
|
||
: [];
|
||
const plan = {
|
||
available: pipeline != null,
|
||
note:
|
||
pipeline != null
|
||
? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
|
||
`${formatInt(pipeline.flats_total)} квартир.`
|
||
: "Планировки и квартирография — после прогноза (§22).",
|
||
pipelineByClass,
|
||
};
|
||
|
||
const summary =
|
||
`Конкурентов в радиусе анализа: ${competitors.length}` +
|
||
(median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
|
||
(pipeline != null
|
||
? `, ${pipeline.objects_count} проектов в пайплайне`
|
||
: "") +
|
||
". Тренд, скорость и планировки уточняются прогнозом §22.";
|
||
|
||
return { summary, competitors, medianField, trend, speed, plan };
|
||
}
|
||
|
||
// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────
|
||
|
||
export interface PticaEnvironmentDrawer {
|
||
summary: string;
|
||
noise: {
|
||
rows: DrawerKvRow[];
|
||
sources: Array<{ name: string; db: string; dist: string }>;
|
||
available: boolean;
|
||
};
|
||
air: { rows: DrawerKvRow[]; available: boolean };
|
||
wind: { rows: DrawerKvRow[]; available: boolean };
|
||
weather: { rows: DrawerKvRow[]; available: boolean };
|
||
}
|
||
|
||
const WIND_DIR_NOTE = "источник: прогноз ветра";
|
||
|
||
export function adaptEnvironmentDrawer(
|
||
a: ParcelAnalysis,
|
||
): PticaEnvironmentDrawer {
|
||
const n = a.noise;
|
||
const aq = a.air_quality;
|
||
const w = a.wind;
|
||
const wt = a.weather;
|
||
|
||
const noiseRows: DrawerKvRow[] = n
|
||
? [
|
||
{ k: "Уровень", field: real(n.level) },
|
||
{ k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
|
||
{ k: "Скор", field: real(fmtNum(n.score, 2)) },
|
||
{ k: "Источников шума", field: real(formatInt(n.sources.length)) },
|
||
]
|
||
: [{ k: "Шум", field: notReal("нет данных шума") }];
|
||
|
||
const airRows: DrawerKvRow[] = aq
|
||
? [
|
||
{ k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
|
||
{ k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
|
||
{ k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
|
||
{ k: "Источник", field: real(aq.source) },
|
||
]
|
||
: [{ k: "Воздух", field: notReal("нет данных AQI") }];
|
||
|
||
const windRows: DrawerKvRow[] = w
|
||
? [
|
||
{
|
||
k: "Направление",
|
||
field: real(
|
||
`${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
|
||
),
|
||
},
|
||
{
|
||
k: "Макс. скорость",
|
||
field:
|
||
w.max_speed_m_s != null
|
||
? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
|
||
: notReal(WIND_DIR_NOTE),
|
||
},
|
||
{
|
||
k: "Горизонт прогноза",
|
||
field: real(`${formatInt(w.forecast_days)} дн.`),
|
||
},
|
||
]
|
||
: [{ k: "Ветер", field: notReal("нет данных ветра") }];
|
||
|
||
const weatherRows: DrawerKvRow[] = wt
|
||
? [
|
||
{
|
||
k: "Ср. макс. °C",
|
||
field:
|
||
wt.temperature.avg_max_c != null
|
||
? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
|
||
: notReal("нет данных погоды"),
|
||
},
|
||
{
|
||
k: "Ср. мин. °C",
|
||
field:
|
||
wt.temperature.avg_min_c != null
|
||
? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
|
||
: notReal("нет данных погоды"),
|
||
},
|
||
{
|
||
k: "Осадки (сумма)",
|
||
field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
|
||
},
|
||
{ k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
|
||
]
|
||
: [{ k: "Погода", field: notReal("нет данных погоды") }];
|
||
|
||
const sources = (n?.sources ?? []).map((s) => ({
|
||
name: s.name ?? s.source_type,
|
||
db: `${formatInt(s.estimated_db)} дБ`,
|
||
dist: `${formatInt(s.distance_m)} м`,
|
||
}));
|
||
|
||
const summary =
|
||
"Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
|
||
"погодный фон. Данные из атмосферного слоя /analyze.";
|
||
|
||
return {
|
||
summary,
|
||
noise: { rows: noiseRows, sources, available: n != null },
|
||
air: { rows: airRows, available: aq != null },
|
||
wind: { rows: windRows, available: w != null },
|
||
weather: { rows: weatherRows, available: wt != null },
|
||
};
|
||
}
|
||
|
||
// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────
|
||
|
||
export interface PticaGeorisksDrawer {
|
||
summary: string;
|
||
geology: {
|
||
rows: DrawerKvRow[];
|
||
links: Array<{ label: string; href: string }>;
|
||
available: boolean;
|
||
};
|
||
hydrology: { rows: DrawerKvRow[]; available: boolean };
|
||
geotech: { rows: DrawerKvRow[]; available: boolean };
|
||
}
|
||
|
||
export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
|
||
const g = a.geology;
|
||
const h = a.hydrology;
|
||
const gt = a.geotech_risk;
|
||
|
||
const geologyRows: DrawerKvRow[] = g
|
||
? [
|
||
{
|
||
k: "Данные геологии",
|
||
field: g.data_available
|
||
? real("доступны")
|
||
: notReal("нет ИГИ — нужен полевой расчёт"),
|
||
},
|
||
{ k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
|
||
]
|
||
: [{ k: "Геология", field: notReal("нет данных геологии") }];
|
||
|
||
const geologyLinks = g
|
||
? [
|
||
{ label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
|
||
{ label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
|
||
]
|
||
: [];
|
||
|
||
const hydrologyRows: DrawerKvRow[] = h
|
||
? [
|
||
{
|
||
k: "Риск подтопления",
|
||
field: real(h.flood_risk_flag ? "есть" : "нет"),
|
||
},
|
||
{
|
||
k: "Ближайший водоём",
|
||
field:
|
||
h.nearest.length > 0
|
||
? real(`${formatInt(h.nearest[0].distance_m)} м`)
|
||
: notReal("нет данных"),
|
||
},
|
||
{ k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
|
||
]
|
||
: [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];
|
||
|
||
const geotechRows: DrawerKvRow[] = gt
|
||
? [
|
||
{ k: "Сейсмика", field: real(gt.seismic_label) },
|
||
{
|
||
k: "Баллы (сейсм.)",
|
||
field:
|
||
gt.seismic_intensity_balls != null
|
||
? real(formatInt(gt.seismic_intensity_balls))
|
||
: notReal("нет данных"),
|
||
},
|
||
{
|
||
k: "Многолетняя мерзлота",
|
||
field: real(gt.permafrost ? "есть" : "нет"),
|
||
},
|
||
{
|
||
k: "Пром. объектов <500 м",
|
||
field: real(formatInt(gt.industrial_within_500m)),
|
||
},
|
||
]
|
||
: [{ k: "Геотехника", field: notReal("нет данных геотехники") }];
|
||
|
||
const summary =
|
||
"Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
|
||
"риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";
|
||
|
||
return {
|
||
summary,
|
||
geology: { rows: geologyRows, links: geologyLinks, available: g != null },
|
||
hydrology: { rows: hydrologyRows, available: h != null },
|
||
geotech: { rows: geotechRows, available: gt != null },
|
||
};
|
||
}
|
||
|
||
// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────
|
||
|
||
export interface PticaRisksDrawer {
|
||
gauge: PticaGauge;
|
||
summary: string;
|
||
breakdown: PticaFactorBar[];
|
||
blockers: Array<{ code: string; detail: string }>;
|
||
warnings: Array<{ code: string; detail: string }>;
|
||
isReal: boolean;
|
||
}
|
||
|
||
export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
|
||
const gauge = adaptRiskGauge(a);
|
||
const gate = a.gate_verdict;
|
||
const geom = a.geometry_suitability;
|
||
const n = a.noise;
|
||
|
||
const breakdown: PticaFactorBar[] = [
|
||
{
|
||
name: "Правовые / гейт",
|
||
pct: gate
|
||
? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
|
||
: 0,
|
||
value: gate
|
||
? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
|
||
: DASH,
|
||
tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
|
||
reason: gate ? "из gate_verdict" : "нет gate_verdict",
|
||
},
|
||
{
|
||
name: "Геометрия",
|
||
pct:
|
||
geom?.suitability_score != null
|
||
? (1 - geom.suitability_score) * 100
|
||
: 0,
|
||
value:
|
||
geom?.suitability_score != null
|
||
? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
|
||
: DASH,
|
||
tone: "neutral",
|
||
reason: geom?.label ? geom.label : "нет геометрии",
|
||
},
|
||
{
|
||
name: "Шум / среда",
|
||
pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
|
||
value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
|
||
tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
|
||
reason: n != null ? "из шумового слоя" : "нет данных шума",
|
||
},
|
||
];
|
||
|
||
const summary =
|
||
"Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
|
||
"средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";
|
||
|
||
return {
|
||
gauge,
|
||
summary,
|
||
breakdown,
|
||
blockers: gate?.blockers ?? [],
|
||
warnings: gate?.warnings ?? [],
|
||
isReal: false,
|
||
};
|
||
}
|
||
|
||
// ── product — §22 product_tz («после прогноза» while absent) ───────────────────
|
||
|
||
export interface PticaProductDrawer {
|
||
available: boolean;
|
||
summary: string;
|
||
objClass: PticaField;
|
||
mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
|
||
usp: Array<{ text: string; cls: string }>;
|
||
reasons: string[];
|
||
}
|
||
|
||
function mixBarPct(entry: ProductMixEntry, total: number): number {
|
||
if (entry.pct != null) return entry.pct;
|
||
// No explicit share → even split as a visual hint only.
|
||
return total > 0 ? Math.round(100 / total) : 0;
|
||
}
|
||
|
||
export function adaptProductDrawer(
|
||
report?: ForecastReport,
|
||
): PticaProductDrawer {
|
||
const tz = report?.product_tz;
|
||
if (!tz) {
|
||
return {
|
||
available: false,
|
||
summary:
|
||
"Продуктовая стратегия (класс, квартирография, USP) формируется в " +
|
||
"асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
|
||
objClass: notReal("после прогноза (Scenarios)"),
|
||
mix: [],
|
||
usp: [],
|
||
reasons: [],
|
||
};
|
||
}
|
||
|
||
const mix = tz.mix.map((m) => ({
|
||
bucket: m.bucket ?? DASH,
|
||
cls: m.obj_class ?? DASH,
|
||
signal: m.signal ?? DASH,
|
||
pct: mixBarPct(m, tz.mix.length),
|
||
}));
|
||
|
||
const usp = tz.usp.map((u) => ({
|
||
text: u.usp_text ?? DASH,
|
||
cls: u.obj_class ?? DASH,
|
||
}));
|
||
|
||
const reasons = tz.reasons
|
||
.map((r) => r.why)
|
||
.filter((w): w is string => Boolean(w));
|
||
|
||
return {
|
||
available: true,
|
||
summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
|
||
objClass: tz.obj_class
|
||
? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
|
||
: notReal("нет рекомендации класса"),
|
||
mix,
|
||
usp,
|
||
reasons,
|
||
};
|
||
}
|
||
|
||
// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────
|
||
|
||
export interface PticaPotentialDrawer {
|
||
summary: string;
|
||
rows: DrawerKvRow[];
|
||
}
|
||
|
||
export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
|
||
const areaHa = a.geometry_suitability?.area_ha;
|
||
const areaField =
|
||
areaHa != null
|
||
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
|
||
: 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[] = [
|
||
{ k: "Площадь участка", field: areaField },
|
||
{
|
||
k: "КСИТ (max_far)",
|
||
field: farReal
|
||
? 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
|
||
? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " +
|
||
"max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
|
||
"(без вычета МОП и паркинга), уточняется финмоделью."
|
||
: "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
|
||
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
|
||
|
||
return { summary, rows };
|
||
}
|
||
|
||
// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────
|
||
|
||
export interface PticaEconomyDrawer {
|
||
summary: string;
|
||
rows: DrawerKvRow[];
|
||
}
|
||
|
||
export function adaptEconomyDrawer(): PticaEconomyDrawer {
|
||
return {
|
||
summary:
|
||
"Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
|
||
"финансовой модели (§22). Пока показатели недоступны.",
|
||
rows: [
|
||
{ k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
|
||
{ k: "Себестоимость", field: notReal("после финмодели") },
|
||
{ k: "Маржа", field: notReal("после финмодели") },
|
||
{ k: "ROI", field: notReal("после финмодели") },
|
||
{ k: "IRR", field: notReal("после финмодели") },
|
||
],
|
||
};
|
||
}
|
||
|
||
export function adaptFinanceDrawer(): PticaEconomyDrawer {
|
||
return {
|
||
summary:
|
||
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
|
||
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
|
||
rows: [
|
||
{ k: "GDV (выручка)", field: notReal("после финмодели") },
|
||
{ k: "Совокупные затраты", field: notReal("после финмодели") },
|
||
{ k: "Чистая прибыль", field: notReal("после финмодели") },
|
||
{ k: "ROI / IRR", field: notReal("после финмодели") },
|
||
{ k: "Срок проекта", field: notReal("после финмодели") },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ── oks — Нет данных ──────────────────────────────────────────────────────────
|
||
|
||
export interface PticaOksDrawer {
|
||
summary: string;
|
||
available: boolean;
|
||
}
|
||
|
||
export function adaptOksDrawer(): PticaOksDrawer {
|
||
return {
|
||
summary:
|
||
"Объекты капитального строительства на участке в текущей выгрузке /analyze " +
|
||
"не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
|
||
available: false,
|
||
};
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COCKPIT LOWER-GRID / BOTTOM-GRID SECTIONS — typed view-models for the cards
|
||
// rendered below DevelopmentScan (porting the prototype `.lower-grid` /
|
||
// `.bottom-grid`). Same {value, isReal, caption} honesty contract: REAL where the
|
||
// backend provides it, honest "—"/placeholder otherwise. No fabricated numbers.
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ── ОКС (lower-grid card) — empty-state «Нет данных» ───────────────────────────
|
||
|
||
export interface PticaOksLowerCard {
|
||
rows: DrawerKvRow[];
|
||
/** When false the card shows the honest empty-state instead of rows. */
|
||
hasData: boolean;
|
||
emptyState: string;
|
||
}
|
||
|
||
/**
|
||
* ОКС on the parcel are not surfaced by /analyze, so this is an honest
|
||
* empty-state card. The isometric building visual is rendered by the React
|
||
* component; this adapter only carries the (currently empty) data + message.
|
||
*/
|
||
export function adaptOksLowerCard(): PticaOksLowerCard {
|
||
return {
|
||
rows: [
|
||
{ k: "ОКСы на участке", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||
{ k: "Общая площадь", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||
{ k: "Статус", field: notReal("нет реестра ОКС (ЕГРН)") },
|
||
],
|
||
hasData: false,
|
||
emptyState: "Нет данных",
|
||
};
|
||
}
|
||
|
||
// ── Development Potential (lower-grid card) — REAL (post-#1847) ─────────────────
|
||
|
||
/** One big-metric tile: a label, a value (split unit) + real/placeholder flag. */
|
||
export interface PticaPotentialMetric {
|
||
label: string;
|
||
value: string;
|
||
unit?: string;
|
||
isReal: boolean;
|
||
caption?: string;
|
||
/** Larger type for the two headline tiles (площадь, КСИТ). */
|
||
lead?: boolean;
|
||
}
|
||
|
||
export interface PticaPotentialCard {
|
||
metrics: PticaPotentialMetric[];
|
||
}
|
||
|
||
/**
|
||
* Development Potential — REAL ёмкость-метрики из геометрии + НСПД-регламента,
|
||
* mirroring `adaptPotentialDrawer` but as the compact lower-grid tiles. Площадь
|
||
* участка (geometry), КСИТ (max_far), плотность = площадь×КСИТ, предельная
|
||
* высота, пятно = площадь×max_building_pct, продаваемая ≈ ёмкость по КСИТ.
|
||
* Honest «—» (placeholder) where an input is absent.
|
||
*/
|
||
export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
|
||
const areaHa = a.geometry_suitability?.area_ha;
|
||
const areaM2 = parcelAreaM2(a);
|
||
const z = a.nspd_zoning;
|
||
const maxFar = z?.max_far;
|
||
const maxHeight = z?.max_height_m;
|
||
const buildingPct = z?.max_building_pct;
|
||
|
||
const areaMetric: PticaPotentialMetric =
|
||
areaM2 != null
|
||
? {
|
||
label: "Площадь участка",
|
||
value: formatInt(areaM2),
|
||
unit: "м²",
|
||
isReal: true,
|
||
lead: true,
|
||
}
|
||
: {
|
||
label: "Площадь участка",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: "нет геометрии",
|
||
lead: true,
|
||
};
|
||
|
||
const farMetric: PticaPotentialMetric =
|
||
maxFar != null
|
||
? {
|
||
label: "КСИТ",
|
||
value: formatFar(maxFar),
|
||
isReal: true,
|
||
caption: "НСПД",
|
||
lead: true,
|
||
}
|
||
: {
|
||
label: "КСИТ",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: SRC_NSPD_ZONING,
|
||
lead: true,
|
||
};
|
||
|
||
const densityMetric: PticaPotentialMetric =
|
||
maxFar != null && areaM2 != null
|
||
? {
|
||
label: "Плотность (КСИТ)",
|
||
value: formatInt(areaM2 * maxFar),
|
||
unit: "м²",
|
||
isReal: true,
|
||
caption: `площадь × КСИТ ${formatFar(maxFar)}`,
|
||
}
|
||
: {
|
||
label: "Плотность (КСИТ)",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: SRC_NSPD_ZONING,
|
||
};
|
||
|
||
const heightMetric: PticaPotentialMetric =
|
||
maxHeight != null
|
||
? {
|
||
label: "Предельная высота",
|
||
value: formatInt(maxHeight),
|
||
unit: "м",
|
||
isReal: true,
|
||
caption: "НСПД",
|
||
}
|
||
: {
|
||
label: "Предельная высота",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: SRC_NSPD_ZONING,
|
||
};
|
||
|
||
const spotMetric: PticaPotentialMetric =
|
||
areaM2 != null && buildingPct != null
|
||
? {
|
||
label: "Пятно застройки",
|
||
value: formatInt(areaM2 * (buildingPct / 100)),
|
||
unit: "м²",
|
||
isReal: true,
|
||
caption: `${formatInt(buildingPct)} % площади · НСПД`,
|
||
}
|
||
: {
|
||
label: "Пятно застройки",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: SRC_NSPD_ZONING,
|
||
};
|
||
|
||
const sellableMetric: PticaPotentialMetric =
|
||
maxFar != null && areaM2 != null
|
||
? {
|
||
label: "Продаваемая",
|
||
value: `≈ ${formatInt(areaM2 * maxFar)}`,
|
||
unit: "м²",
|
||
isReal: true,
|
||
caption: "оценка по КСИТ-ёмкости · без МОП/паркинга",
|
||
}
|
||
: {
|
||
label: "Продаваемая",
|
||
value: PLACEHOLDER,
|
||
isReal: false,
|
||
caption: "после расчёта потенциала",
|
||
};
|
||
|
||
return {
|
||
metrics: [
|
||
areaMetric,
|
||
farMetric,
|
||
densityMetric,
|
||
heightMetric,
|
||
spotMetric,
|
||
sellableMetric,
|
||
],
|
||
};
|
||
}
|
||
|
||
// ── Investment Clearance (bottom-grid) — finance bignums (PLACEHOLDER, §23) ─────
|
||
|
||
export interface PticaClearanceBignum {
|
||
label: string;
|
||
value: string;
|
||
unit?: string;
|
||
isReal: boolean;
|
||
caption?: string;
|
||
}
|
||
|
||
export interface PticaInvestmentClearance {
|
||
bignums: PticaClearanceBignum[];
|
||
caption: string;
|
||
}
|
||
|
||
/**
|
||
* Investment Clearance — GDV/COST/PROFIT + ROI/IRR. The §23 finance model is NOT
|
||
* in the backend, so EVERY value is an honest placeholder «—» (NO fabricated
|
||
* numbers). The bignum layout matches the prototype; the caption explains why.
|
||
*/
|
||
export function adaptInvestmentClearance(): PticaInvestmentClearance {
|
||
const fin = "после финмодели (§23)";
|
||
return {
|
||
bignums: [
|
||
{
|
||
label: "GDV · выручка",
|
||
value: PLACEHOLDER,
|
||
unit: "млрд ₽",
|
||
isReal: false,
|
||
caption: fin,
|
||
},
|
||
{
|
||
label: "Cost · затраты",
|
||
value: PLACEHOLDER,
|
||
unit: "млрд ₽",
|
||
isReal: false,
|
||
caption: fin,
|
||
},
|
||
{
|
||
label: "Profit · прибыль",
|
||
value: PLACEHOLDER,
|
||
unit: "млрд ₽",
|
||
isReal: false,
|
||
caption: fin,
|
||
},
|
||
{ label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin },
|
||
{ label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin },
|
||
],
|
||
caption: "после финмодели (§23)",
|
||
};
|
||
}
|
||
|
||
// ── Buy Signal (bottom-grid) — gauge from §22 forecast deficit_index ───────────
|
||
|
||
export interface PticaBuySignalCard {
|
||
gauge: PticaGauge;
|
||
/** The verdict word shown under the gauge (Приобретать / Наблюдать / Пропустить). */
|
||
word: string;
|
||
available: boolean;
|
||
caption: string;
|
||
}
|
||
|
||
/**
|
||
* Buy Signal — derived from the §22 forecast exec-summary deficit_index, the SAME
|
||
* logic as `adaptInvestScore`'s buySignal (>0.05 приобретать / <−0.05 пропустить /
|
||
* else наблюдать). The gauge value maps the (signed) deficit_index onto 0..100 for
|
||
* the visual; advisory (§22). While the forecast is pending → «после прогноза».
|
||
*/
|
||
export function adaptBuySignalGauge(
|
||
report?: ForecastReport,
|
||
): PticaBuySignalCard {
|
||
if (!report) {
|
||
return {
|
||
gauge: {
|
||
value: null,
|
||
label: "после прогноза",
|
||
tone: "neutral",
|
||
isReal: false,
|
||
footnote: "Scenarios §22",
|
||
},
|
||
word: PLACEHOLDER,
|
||
available: false,
|
||
caption: "после прогноза (Scenarios)",
|
||
};
|
||
}
|
||
|
||
const di = report.exec_summary.key_numbers.deficit_index;
|
||
// Map signed deficit_index (roughly −0.5..+0.5) onto a 0..100 gauge value.
|
||
const gaugeValue =
|
||
di == null ? 50 : Math.max(0, Math.min(100, Math.round(50 + di * 100)));
|
||
|
||
let tone: PticaGauge["tone"] = "neutral";
|
||
let word = "Наблюдать";
|
||
if (di != null) {
|
||
if (di > 0.05) {
|
||
tone = "good";
|
||
word = "Приобретать";
|
||
} else if (di < -0.05) {
|
||
tone = "bad";
|
||
word = "Пропустить";
|
||
}
|
||
}
|
||
|
||
return {
|
||
gauge: {
|
||
value: gaugeValue,
|
||
label: word,
|
||
tone,
|
||
isReal: true,
|
||
footnote: "advisory · §22",
|
||
},
|
||
word,
|
||
available: true,
|
||
caption: "advisory · §22",
|
||
};
|
||
}
|
||
|
||
// ── Legal Status (bottom-grid) — ОКН / сервитуты / красные линии / ЗОУИТ ────────
|
||
|
||
export interface PticaLegalStatus {
|
||
rows: DrawerKvRow[];
|
||
}
|
||
|
||
/**
|
||
* Legal Status — REAL where /analyze provides it (ЗОУИТ count, красные линии,
|
||
* gate verdict label), honest placeholder for the rest (ОКН / сервитуты /
|
||
* аренда / ограничения оборота / обременения — not in the dump). Mirrors the
|
||
* prototype's Legal table (Нет / значение rows).
|
||
*/
|
||
export function adaptLegalStatus(a: ParcelAnalysis): PticaLegalStatus {
|
||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||
const redLines = a.nspd_red_lines ?? null;
|
||
const gate = a.gate_verdict;
|
||
|
||
const rows: DrawerKvRow[] = [
|
||
{
|
||
k: "ЗОУИТ",
|
||
field:
|
||
zouit != null
|
||
? real(formatInt(zouit.length))
|
||
: notReal("источник НСПД"),
|
||
},
|
||
{
|
||
k: "Красные линии",
|
||
field:
|
||
redLines != null
|
||
? real(redLines.length > 0 ? formatInt(redLines.length) : "Нет")
|
||
: notReal("источник НСПД-граддок"),
|
||
},
|
||
{ k: "ОКН", field: notReal("источник НСПД") },
|
||
{ k: "Сервитуты", field: notReal("источник ЕГРН") },
|
||
{ k: "Аренда", field: notReal("источник ЕГРН") },
|
||
{
|
||
k: "Огранич. оборота",
|
||
field: gate
|
||
? real(
|
||
gate.blockers.length > 0
|
||
? `${formatInt(gate.blockers.length)} блок.`
|
||
: "Нет",
|
||
)
|
||
: notReal("источник ЕГРН"),
|
||
},
|
||
];
|
||
|
||
return { rows };
|
||
}
|
||
|
||
// ── Site Verdict (bottom-grid) — headline plaque + checklist ───────────────────
|
||
|
||
export interface PticaVerdictCheckItem {
|
||
text: string;
|
||
tone: "ok" | "warn" | "bad";
|
||
}
|
||
|
||
export interface PticaSiteVerdict {
|
||
/** The big verdict word (Приобретать / Можно / Нельзя / Нужна проверка). */
|
||
word: string;
|
||
tone: "good" | "warn" | "bad";
|
||
checklist: PticaVerdictCheckItem[];
|
||
}
|
||
|
||
/**
|
||
* Site Verdict — the JTBD «стоит ли брать участок» answer. The verdict word comes
|
||
* from gate_verdict.can_build_mkd (true → «Приобретать» green, false → «Нельзя»
|
||
* red, "unknown" → «Нужна проверка» amber). The checklist is derived from REAL
|
||
* signals where available (рыночный спрос via median price, потенциал застройки
|
||
* via max_far, инженерная обеспеченность via utilities count) and falls back to
|
||
* the gate blockers/warnings.
|
||
*/
|
||
export function adaptSiteVerdict(
|
||
a: ParcelAnalysis,
|
||
report?: ForecastReport,
|
||
): PticaSiteVerdict {
|
||
const gate = a.gate_verdict;
|
||
|
||
let word = "Нужна проверка";
|
||
let tone: PticaSiteVerdict["tone"] = "warn";
|
||
if (gate) {
|
||
if (gate.can_build_mkd === true) {
|
||
word = "Приобретать";
|
||
tone = "good";
|
||
} else if (gate.can_build_mkd === false) {
|
||
word = "Нельзя";
|
||
tone = "bad";
|
||
}
|
||
}
|
||
|
||
const checklist: PticaVerdictCheckItem[] = [];
|
||
|
||
// Рыночный спрос — derived from §22 deficit_index when ready, else from the
|
||
// presence of a district median (proxy «рынок отслеживается»).
|
||
const di = report?.exec_summary.key_numbers.deficit_index ?? null;
|
||
if (di != null) {
|
||
if (di > 0.05) {
|
||
checklist.push({ text: "Высокий спрос", tone: "ok" });
|
||
checklist.push({ text: "Дефицит предложения", tone: "ok" });
|
||
} else if (di < -0.05) {
|
||
checklist.push({ text: "Признаки затоварки рынка", tone: "warn" });
|
||
} else {
|
||
checklist.push({ text: "Рынок сбалансирован", tone: "warn" });
|
||
}
|
||
} else if (a.district?.median_price_per_m2 != null) {
|
||
checklist.push({ text: "Рынок района отслеживается", tone: "ok" });
|
||
}
|
||
|
||
// Потенциал застройки — REAL when КСИТ flows from НСПД.
|
||
if (a.nspd_zoning?.max_far != null) {
|
||
checklist.push({ text: "Потенциал застройки (КСИТ)", tone: "ok" });
|
||
} else {
|
||
checklist.push({ text: "Потенциал застройки не рассчитан", tone: "warn" });
|
||
}
|
||
|
||
// Инженерная обеспеченность — REAL from utilities count.
|
||
const utilCount = a.utilities?.summary?.length ?? 0;
|
||
if (utilCount >= 3) {
|
||
checklist.push({ text: "Хорошая инженерная обеспеченность", tone: "ok" });
|
||
} else if (utilCount > 0) {
|
||
checklist.push({
|
||
text: "Частичная инженерная обеспеченность",
|
||
tone: "warn",
|
||
});
|
||
} else {
|
||
checklist.push({ text: "Нет данных по инженерии", tone: "warn" });
|
||
}
|
||
|
||
// Gate blockers/warnings — surface the real risk items.
|
||
if (gate) {
|
||
for (const b of gate.blockers.slice(0, 2)) {
|
||
checklist.push({ text: b.detail || b.code, tone: "bad" });
|
||
}
|
||
for (const w of gate.warnings.slice(0, 2)) {
|
||
checklist.push({ text: w.detail || w.code, tone: "warn" });
|
||
}
|
||
}
|
||
|
||
// Always show at least one item so the plaque never reads empty.
|
||
if (checklist.length === 0) {
|
||
checklist.push({ text: "Недостаточно данных для чек-листа", tone: "warn" });
|
||
}
|
||
|
||
return { word, tone, checklist };
|
||
}
|
||
|
||
// ── Map geometry ──────────────────────────────────────────────────────────────
|
||
|
||
/** EKB fallback center (lat, lon). */
|
||
export const EKB_CENTER: [number, number] = [56.838, 60.6];
|