/** * 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; } /** Tone for a status-row dot + state label (prototype .dot / .state). */ export type PticaStatusTone = "ok" | "warn" | "bad"; export interface PticaScanRow { key: string; field: PticaField; /** * When set, the row renders as a prototype status-row (dot + label + state) * instead of the default kvrow. `field.value` is the state text. Used by the * Инженерия card (Экология stays honest kvrow placeholders, no status dots). */ status?: PticaStatusTone; } 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 ёмкость. */ export 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 — после прогноза) ───────────────────────── /** Semantic value tone for a hero stat-line (prototype .v.good / .v.warn). */ export type PticaValueTone = "good" | "warn" | "none"; export interface PticaInvestScore { score: PticaField; potential: PticaField; risk: PticaField; buySignal: PticaField; /** Hero stat-line value color for «Потенциал». */ potentialTone: PticaValueTone; /** Hero stat-line value color for «Риск». */ riskTone: PticaValueTone; } /** * 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("предв."); // Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise. const riskTone: PticaValueTone = risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn"; 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, // Потенциал stays muted until the financial model surfaces a real value. potentialTone: "none", riskTone, }; } return { score: placeholder("после прогноза (Scenarios)"), potential: placeholder("после прогноза (Scenarios)"), risk: riskField, buySignal, potentialTone: "none", riskTone, }; } // ── Development Scan cards ───────────────────────────────────────────────────── // subtype → ru label for the engineering card. const UTILITY_LABELS: Record = { 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 buildingPct = z?.max_building_pct; 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); // Пятно застройки = площадь участка × коэф. застройки (max_building_pct). // Reuses parcelAreaM2 (area_ha × 10000) — the same area КСИТ uses, and the same // computation as the DEVELOPMENT POTENTIAL card (adaptPotentialDrawer). const spotField: PticaField = areaM2 != null && buildingPct != null ? { value: `${formatInt(areaM2 * (buildingPct / 100))} м²`, isReal: true, caption: `${formatInt(buildingPct)} % площади · НСПД`, } : 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: spotField, }, ], }; } /** Ограничения · ЗОУИТ — ЗОУИТ 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: "Инженерия", // Prototype Инженерия renders status-rows (dot + label + state). The state // text is the nearest-node distance; tone is «ok» when the узел is within // ~1 km, «warn» when further (ограниченная доступность). rows: summary.map((u) => ({ key: utilityLabel(u.subtype), field: real(`${formatInt(u.nearest_m)} м`), status: (u.nearest_m <= 1000 ? "ok" : "warn") as PticaStatusTone, })), }; } /** * Рынок — REAL data from the market layer of /analyze. Median (district), * absorption (market_pulse.avg_velocity_m2), competitor count * (market_pulse.competitors_total), pipeline объектов (pipeline_24mo) and the * demand-trend label (market_trend.label). Honest «—» where a field is absent. * Prototype row order: Медиана цены / Поглощение / Конкурентов / Новых проектов / * Уровень спроса. */ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard { const median = a.district?.median_price_per_m2; const pulse = a.market_pulse; const velocity = pulse?.avg_velocity_m2; const competitors = pulse?.competitors_total; const newProjects = a.pipeline_24mo?.objects_count; const demandLabel = a.market_trend?.label; return { title: "Рынок", rows: [ { key: "Медиана цены", field: median != null ? real(formatRubPerM2(median)) : placeholder("нет данных района"), }, { key: "Поглощение", field: velocity != null ? { value: `${fmtNum(velocity, 1)} м²/мес`, isReal: true, caption: "средний темп поглощения · рынок", } : placeholder("нет данных рынка"), }, { key: "Конкурентов", field: competitors != null ? real(formatInt(competitors)) : placeholder("нет данных рынка"), }, { key: "Новых проектов", field: newProjects != null ? real(formatInt(newProjects)) : placeholder("нет пайплайна"), }, { key: "Уровень спроса", field: demandLabel ? { value: demandLabel, isReal: true, caption: "тренд цен · рынок" } : 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("после финмодели") }, ], }; } /** * Среда · экология — REAL atmospheric layer from /analyze. Шум (noise.estimated_db * + level), Воздух (air_quality.pm2_5 — honest PM2.5 reading, NOT a fabricated AQI * index), Ветер (wind dominant direction + max speed), Водоёмы (hydrology nearest * water distance). Honest «—» where a field is absent. */ export function adaptEnvironmentCard(a: ParcelAnalysis): PticaScanCard { const noise = a.noise; const aq = a.air_quality; const wind = a.wind; const hydro = a.hydrology; const nearestWater = hydro?.nearest?.[0]?.distance_m; const noiseField: PticaField = noise ? { value: `${fmtNum(noise.estimated_db, 1)} дБ`, isReal: true, caption: `уровень: ${noise.level}`, } : placeholder("нет данных шума"); // Воздух — честное прямое чтение PM2.5 (не выдуманный AQI-индекс). const airField: PticaField = aq ? { value: `PM2.5 ${fmtNum(aq.pm2_5, 1)}`, isReal: true, caption: "мкг/м³ · атмосферный слой", } : placeholder("нет данных AQI"); const windField: PticaField = wind ? wind.max_speed_m_s != null ? real( `${wind.dominant_direction_label} · ${fmtNum(wind.max_speed_m_s, 1)} м/с`, ) : real(wind.dominant_direction_label) : placeholder("нет данных ветра"); return { title: "Среда", badge: "экология", rows: [ { key: "Шум", field: noiseField }, { key: "Воздух (PM2.5)", field: airField }, { key: "Ветер", field: windField }, { key: "Водоёмы", field: nearestWater != null ? real(`${formatInt(nearestWater)} м`) : 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];