From b0736e3a45750e83a974f1142223e8f1582c0c1f Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 20 Jun 2026 15:48:54 +0500 Subject: [PATCH] =?UTF-8?q?feat(ptica):=20build=20=D0=A1=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B8=20tab=20(=C2=A722=20forecast)=20+=20re?= =?UTF-8?q?al=20hero=20invest-score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark cockpit Scenarios tab wired to useParcelForecastQuery (ForecastEnvelope): scenario cards (base/aggr/conserv), demand-vs-supply chart + horizons table, scenario compare table, confidence panel, recommended product (квартирография + target price + success-score), scoring transparency. Calm polling skeleton while the async forecast is pending — never an error. Hero invest-score/buy-signal now surface real values once the forecast resolves (scoring.overall×10; buy-signal from exec_summary.key_numbers.deficit_index, derived independent of the overall score), keeping the "после прогноза" placeholders while pending. advisory · §22 caption shown in both states. All forecast field paths verified against types/forecast.ts. Dark theme stays scoped under .pticaRoot[data-theme=dark]. TS strict, no any. --- .../analysis/[cad]/ptica/PticaPageContent.tsx | 28 +- .../analysis/[cad]/ptica/ptica.module.css | 383 ++++++++++++++++++ .../site-finder/ptica/InvestScoreBlock.tsx | 9 +- .../site-finder/ptica/PticaHero.tsx | 7 +- .../site-finder/ptica/ptica-adapt.ts | 72 +++- .../ptica/scenarios/ConfidencePanel.tsx | 91 +++++ .../ptica/scenarios/HorizonForecast.tsx | 214 ++++++++++ .../ptica/scenarios/PticaScenarios.tsx | 153 +++++++ .../ptica/scenarios/RecommendedProduct.tsx | 142 +++++++ .../ptica/scenarios/ScenarioCards.tsx | 123 ++++++ .../ptica/scenarios/ScenarioCompareTable.tsx | 105 +++++ .../ptica/scenarios/ScoringTransparency.tsx | 105 +++++ .../ptica/scenarios/scenario-helpers.ts | 151 +++++++ 13 files changed, 1567 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/HorizonForecast.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/RecommendedProduct.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx create mode 100644 frontend/src/components/site-finder/ptica/scenarios/scenario-helpers.ts diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx index 5242659e..00ac0eea 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx @@ -17,12 +17,16 @@ import { useState } from "react"; import Link from "next/link"; import styles from "./ptica.module.css"; -import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import { + useParcelAnalyzeQuery, + useParcelForecastQuery, +} from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; import { PticaShell } from "@/components/site-finder/ptica/PticaShell"; import type { PticaTab } from "@/components/site-finder/ptica/PticaShell"; import { PticaHero } from "@/components/site-finder/ptica/PticaHero"; import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan"; +import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios"; import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel"; interface Props { @@ -30,9 +34,15 @@ interface Props { } export function PticaPageContent({ cad }: Props) { - const [horizon] = useState(12); + const [horizon, setHorizon] = useState(12); const [tab, setTab] = useState("analysis"); const { data, isLoading, error } = useParcelAnalyzeQuery(cad, horizon); + // §22 forecast is keyed/cached by cad — call unconditionally; it polls the + // async report (enqueued by /analyze) and feeds both the hero invest-score and + // the Scenarios tab. Pending state is handled inside each consumer. + const { data: forecastData } = useParcelForecastQuery(cad); + const forecastReport = + forecastData?.status === "ready" ? forecastData.report : undefined; // ── Loading ──────────────────────────────────────────────────────────────── if (isLoading) { @@ -70,12 +80,20 @@ export function PticaPageContent({ cad }: Props) { return (
- {tab === "analysis" ? ( + {tab === "analysis" && ( <> - + - ) : ( + )} + {tab === "scenarios" && ( + + )} + {(tab === "reports" || tab === "compare") && ( @@ -36,7 +39,7 @@ export function InvestScoreBlock({ analysis }: Props) { )}
- {!inv.score.isReal && inv.score.caption && ( + {inv.score.caption && ( {inv.score.caption} )} diff --git a/frontend/src/components/site-finder/ptica/PticaHero.tsx b/frontend/src/components/site-finder/ptica/PticaHero.tsx index e1d02f2c..54f03b1f 100644 --- a/frontend/src/components/site-finder/ptica/PticaHero.tsx +++ b/frontend/src/components/site-finder/ptica/PticaHero.tsx @@ -7,6 +7,7 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import type { ParcelAnalysis } from "@/types/site-finder"; +import type { ForecastReport } from "@/types/forecast"; import { PticaMap } from "@/components/site-finder/ptica/PticaMap"; import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPassportCard"; import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; @@ -15,9 +16,11 @@ import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-ada interface Props { analysis: ParcelAnalysis; + /** §22 forecast report — threaded into the invest-score block once ready. */ + forecastReport?: ForecastReport; } -export function PticaHero({ analysis }: Props) { +export function PticaHero({ analysis, forecastReport }: Props) { const buildGauge = adaptBuildabilityGauge(analysis); return ( @@ -28,7 +31,7 @@ export function PticaHero({ analysis }: Props) {
- +
); diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index f3f2c9e8..3b1487b1 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -11,6 +11,7 @@ */ import type { ParcelAnalysis } from "@/types/site-finder"; +import type { ForecastReport } from "@/types/forecast"; // ── View-model ────────────────────────────────────────────────────────────── @@ -65,6 +66,14 @@ 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)} м²`; @@ -205,16 +214,67 @@ export interface PticaInvestScore { buySignal: PticaField; } -export function adaptInvestScore(a: ParcelAnalysis): PticaInvestScore { +/** + * 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: - risk.value != null - ? { value: risk.label, isReal: false, caption: "предв." } - : placeholder("предв."), - buySignal: placeholder("после прогноза (Scenarios)"), + risk: riskField, + buySignal, }; } diff --git a/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx b/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx new file mode 100644 index 00000000..690f113b --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/ConfidencePanel.tsx @@ -0,0 +1,91 @@ +"use client"; + +/** + * ConfidencePanel — 6.3 «Уверенность прогноза». An overall conf-pill (from + * confidence.level) + a row per confidence.factors entry, each with a high/mid/ + * low pill. The factors map carries a stray non-factor boolean (advisory_capped) + * in prod envelopes — narrowed out via a runtime guard (mirrors the light block). + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ConfidenceFactor, ReportConfidence } from "@/types/forecast"; +import { CONFIDENCE_RU, confidencePill } from "./scenario-helpers"; + +interface Props { + confidence: ReportConfidence; +} + +const FACTOR_RU: Record = { + deal_count: "Объём данных по сделкам", + analog_count: "Аналоги (ЖК)", + domrf_coverage: "Покрытие ДОМ.РФ", + history_months: "Глубина истории", + component: "Компонент", +}; + +const LEVEL_RANK: Record = { + high: 0, + medium: 1, + low: 2, +}; + +const PILL_CLASS = { + high: styles.confPillHigh, + medium: styles.confPillMedium, + low: styles.confPillLow, +} as const; + +function isConfidenceFactor(v: unknown): v is ConfidenceFactor { + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return ( + typeof o.note === "string" && + (o.level === "high" || o.level === "medium" || o.level === "low") + ); +} + +function factorLabel(key: string, factor: ConfidenceFactor): string { + if (factor.label) return factor.label; + if (FACTOR_RU[key]) return FACTOR_RU[key]; + const m = key.match(/^component(?:_(\d+))?$/); + if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент"; + return key; +} + +export function ConfidencePanel({ confidence }: Props) { + const level = confidence.level; + const drivers = Object.entries(confidence.factors) + .filter((entry): entry is [string, ConfidenceFactor] => + isConfidenceFactor(entry[1]), + ) + .sort((a, b) => LEVEL_RANK[a[1].level] - LEVEL_RANK[b[1].level]); + + return ( +
+
+

Уверенность прогноза

+ {level && ( + + {CONFIDENCE_RU[level]} + + )} +
+ {drivers.length > 0 ? ( + drivers.map(([key, factor]) => ( +
+ {factorLabel(key, factor)} + + {CONFIDENCE_RU[factor.level]} + +
+ )) + ) : ( +

Факторы достоверности недоступны.

+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/HorizonForecast.tsx b/frontend/src/components/site-finder/ptica/scenarios/HorizonForecast.tsx new file mode 100644 index 00000000..f799f686 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/HorizonForecast.tsx @@ -0,0 +1,214 @@ +"use client"; + +/** + * HorizonForecast — 6.1 «Прогноз по горизонтам». + * + * Left: a demand-vs-supply line chart (inline SVG, two polylines) over + * future_market.forecasts_by_horizon — mirrors the prototype's renderLineCharts + * (no chart lib; the dark .chart tokens drive the look). Right: a compact + * horizons table (горизонт · спрос · предложение · дефицит). + */ + +import { useMemo } from "react"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { DemandSupplyForecast } from "@/types/forecast"; +import { deficitTone, fmtInt, fmtSignedDeficit } from "./scenario-helpers"; + +interface Props { + forecasts: DemandSupplyForecast[]; + targetHorizon: number; + /** Segment caption (e.g. "Комфорт · 1–2К") — shown under the table when known. */ + segmentLabel: string | null; +} + +const CHART_W = 600; +const CHART_H = 180; +const CHART_PAD = 28; + +const DEFICIT_TONE_CLASS = { + good: styles.numPos, + bad: styles.numNeg, + warn: styles.numNeg, + neutral: "", +} as const; + +interface ChartGeometry { + gridLines: number[]; + demandPath: string; + supplyPath: string; + xLabels: { x: number; text: string }[]; +} + +function buildChart(rows: DemandSupplyForecast[]): ChartGeometry | null { + if (rows.length < 2) return null; + const demand = rows.map((r) => r.projected_demand_units); + const supply = rows.map((r) => r.projected_supply_units); + const all = [...demand, ...supply, 0]; + const min = Math.min(...all); + const max = Math.max(...all); + const span = max - min || 1; + const x = (i: number, n: number) => + CHART_PAD + (i * (CHART_W - 2 * CHART_PAD)) / (n - 1); + const y = (v: number) => + CHART_H - CHART_PAD - ((v - min) / span) * (CHART_H - 2 * CHART_PAD); + const toPath = (arr: number[]) => + arr + .map( + (v, i) => + `${i ? "L" : "M"}${x(i, arr.length).toFixed(1)},${y(v).toFixed(1)}`, + ) + .join(" "); + + const gridLines: number[] = []; + for (let g = 0; g <= 4; g++) { + gridLines.push(CHART_PAD + (g * (CHART_H - 2 * CHART_PAD)) / 4); + } + + const xLabels = rows.map((r, i) => ({ + x: x(i, rows.length), + text: `${r.horizon_months}м`, + })); + + return { + gridLines, + demandPath: toPath(demand), + supplyPath: toPath(supply), + xLabels, + }; +} + +export function HorizonForecast({ + forecasts, + targetHorizon, + segmentLabel, +}: Props) { + const rows = useMemo( + () => [...forecasts].sort((a, b) => a.horizon_months - b.horizon_months), + [forecasts], + ); + const chart = useMemo(() => buildChart(rows), [rows]); + + if (rows.length === 0) { + return ( +
+ Прогноз по горизонтам недоступен. +
+ ); + } + + return ( + <> +
+ 6.1 · прогноз по горизонтам +
+
+
+
+

Спрос vs предложение

+
+ + + Спрос + + + + Предложение + +
+
+ {chart ? ( +
+ + {chart.gridLines.map((gy, i) => ( + + ))} + + + {chart.xLabels.map((l, i) => ( + + {l.text} + + ))} + +
+ ) : ( +
+ Недостаточно точек для графика +
+ )} +
+ +
+
+

По горизонтам

+
+ + + + + + + + + + + {rows.map((f) => { + const isTarget = f.horizon_months === targetHorizon; + const tone = deficitTone(f.deficit_index); + return ( + + + + + + + ); + })} + +
ГоризонтСпросПредлож.Дефицит
+ {f.horizon_months} мес + {isTarget && ( + целевой + )} + + {fmtInt(f.projected_demand_units)} + + {fmtInt(f.projected_supply_units)} + + {fmtSignedDeficit(f.deficit_index)} +
+ {segmentLabel && ( +
+ Сегмент + {segmentLabel} +
+ )} +
+
+ + ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx b/frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx new file mode 100644 index 00000000..4a3d1c9e --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx @@ -0,0 +1,153 @@ +"use client"; + +/** + * PticaScenarios — root of the ПТИЦА dark «Сценарии» tab (§22 Прогноз). + * + * Reads the REAL async forecast via useParcelForecastQuery(cad). The forecast is + * enqueued fire-and-forget by /analyze on page mount — this hook only polls. While + * the envelope is `pending` (or the first request is loading) we render a CALM + * skeleton («Прогноз считается…»), NOT an error: the poll resolves once the report + * is assembled. Once `report` is present we compose the sub-blocks: + * 3 scenario cards · горизонты (chart + table) · сравнение · уверенность + + * рекомендованный продукт · прозрачность скоринга. + * + * The horizon (6/12/18 мес) selector lives in this tab's header; its state is + * lifted to PticaPageContent so the hero/other tabs can share it. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import { useParcelForecastQuery } from "@/lib/site-finder-api"; +import type { ForecastReport, ForecastSegment } from "@/types/forecast"; +import { ScenarioCards } from "./ScenarioCards"; +import { HorizonForecast } from "./HorizonForecast"; +import { ScenarioCompareTable } from "./ScenarioCompareTable"; +import { ConfidencePanel } from "./ConfidencePanel"; +import { RecommendedProduct } from "./RecommendedProduct"; +import { ScoringTransparency } from "./ScoringTransparency"; + +interface Props { + cad: string; + horizon: number; + onHorizonChange: (h: number) => void; +} + +const HORIZONS = [6, 12, 18]; + +function segmentLabel(segment: ForecastSegment | undefined): string | null { + if (!segment) return null; + const parts = [segment.obj_class, segment.room_bucket].filter( + (p): p is string => !!p, + ); + return parts.length > 0 ? parts.join(" · ") : null; +} + +export function PticaScenarios({ cad, horizon, onHorizonChange }: Props) { + const { data, isLoading, error } = useParcelForecastQuery(cad); + + const header = ( +
+

+ Сценарии{" "} + + прогноз спроса и предложения · §22 + +

+
+ Горизонт +
+ {HORIZONS.map((h) => ( + + ))} +
+
+
+ ); + + // ── Pending / loading → calm skeleton (NOT an error) ───────────────────────── + const report: ForecastReport | undefined = + data?.status === "ready" ? data.report : undefined; + const pending = isLoading || (!report && !error); + + if (pending) { + return ( +
+ {header} +
+ +
+ ); + } + + // ── Error / no report → honest empty panel ─────────────────────────────────── + if (error || !report) { + return ( +
+ {header} +
+ Прогноз §22 недоступен для этого участка. +
+
+ ); + } + + const fm = report.future_market; + const targetHorizon = horizon; + + return ( +
+ {header} + + + + + + + +
+ 6.3 · уверенность · 6.4 · рекомендация по продукту +
+
+ + {report.product_tz ? ( + + ) : ( +
+ Рекомендация по продукту недоступна. +
+ )} +
+ + {report.scoring && } +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/RecommendedProduct.tsx b/frontend/src/components/site-finder/ptica/scenarios/RecommendedProduct.tsx new file mode 100644 index 00000000..08dc3dfc --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/RecommendedProduct.tsx @@ -0,0 +1,142 @@ +"use client"; + +/** + * RecommendedProduct — 6.4 «Рекомендованный продукт». §13.4 product_tz: the + * recommended obj_class (tag) + квартирография as horizontal bars (доля when an + * explicit `pct` mix exists, else |deficit_index| as signal strength — the + * current overlay shape), plus the overall product success-score (scoring.overall) + * and the optional target price when the caller supplies it. + * + * GRACEFUL: returns null when product_tz carries nothing meaningful. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ProductMixEntry, ReportProductTz } from "@/types/forecast"; +import { DEFICIT_BALANCE_EPS, deficitTone, fmtNum } from "./scenario-helpers"; + +interface Props { + productTz: ReportProductTz; + /** Overall product success-score ∈ 0..1 (scoring.overall) — null when absent. */ + successScore: number | null; + /** Target price (₽/м²) when the caller can supply it (district median) — optional. */ + targetPrice: string | null; +} + +const TONE_CLASS = { + good: styles.hbarFillGood, + bad: styles.hbarFillBad, + warn: styles.hbarFillBad, + neutral: styles.hbarFillNeutral, +} as const; + +function isNonEmpty(productTz: ReportProductTz): boolean { + return ( + productTz.obj_class != null || + productTz.mix.length > 0 || + !!productTz.summary + ); +} + +function mixLabel(m: ProductMixEntry): string { + return m.bucket ?? "формат"; +} + +interface MixBar { + key: string; + label: string; + widthPct: number; + valueStr: string; + tone: "good" | "warn" | "bad" | "neutral"; +} + +function buildBars(mix: ProductMixEntry[]): { + bars: MixBar[]; + byShare: boolean; +} { + const rows = mix.filter((m) => m.pct != null || m.deficit_index != null); + const byShare = rows.some((m) => m.pct != null); + const bars = rows.map((m, i): MixBar => { + if (m.pct != null) { + const pct = Math.max(0, Math.min(100, m.pct)); + return { + key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`, + label: mixLabel(m), + widthPct: pct, + valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`, + tone: "neutral", + }; + } + const di = m.deficit_index ?? 0; + const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS; + return { + key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`, + label: mixLabel(m), + widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)), + valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`, + tone: balanced ? "neutral" : deficitTone(di), + }; + }); + return { bars, byShare }; +} + +export function RecommendedProduct({ + productTz, + successScore, + targetPrice, +}: Props) { + if (!isNonEmpty(productTz)) return null; + + const { bars, byShare } = buildBars(productTz.mix); + + return ( +
+
+

Рекомендованный продукт

+ {productTz.obj_class && ( + {capitalize(productTz.obj_class)} + )} +
+ + {bars.length > 0 && ( +
+ {bars.map((b) => ( +
+ {b.label} +
+
+
+ {b.valueStr} +
+ ))} +
+ )} + + {!byShare && bars.length > 0 && ( +

+ Длина полосы — сила сигнала по индексу дефицита формата: зелёный — + недонасыщенность, красный — затоварка. +

+ )} + + {targetPrice && ( +
+ Целевая цена + {targetPrice} +
+ )} +
+ Success-score формата + + {successScore != null ? fmtNum(successScore, 2) : "—"} + +
+
+ ); +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx new file mode 100644 index 00000000..a14d8737 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * ScenarioCards — 3 dark cockpit cards (base / aggressive / conservative) from + * report.scenarios.by_scenario. Each card shows the deficit-index (big, signed, + * colour by sign) at the target horizon, the demand/supply projection, and the + * months-of-inventory (MOI). Mirrors the §22 light ScenariosBlock field mapping. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ReportScenarios, ScenariosSummary } from "@/types/forecast"; +import { + DEFICIT_BALANCE_EPS, + SCENARIO_ACCENT, + SCENARIO_HINT, + SCENARIO_ORDER, + SCENARIO_RU, + deficitForScenario, + deficitTone, + deficitWord, + fmtInt, + fmtNum, + fmtSignedDeficit, + pickHorizonForecast, +} from "./scenario-helpers"; + +interface Props { + scenarios: ReportScenarios; + scenariosSummary: ScenariosSummary | null; + targetHorizon: number; +} + +const DEFICIT_TONE_CLASS = { + good: styles.deficitPos, + bad: styles.deficitNeg, + warn: styles.deficitNeg, + neutral: styles.deficitFlat, +} as const; + +const ACCENT_CLASS = { + base: styles.scAccentBase, + aggr: styles.scAccentAggr, + cons: styles.scAccentCons, +} as const; + +export function ScenarioCards({ + scenarios, + scenariosSummary, + targetHorizon, +}: Props) { + const present = SCENARIO_ORDER.filter( + (k) => scenarios.by_scenario[k] != null || scenariosSummary != null, + ); + + if (present.length === 0) { + return ( +
+ Сценарный прогноз недоступен. +
+ ); + } + + return ( +
+ {present.map((key) => { + const sc = scenarios.by_scenario[key]; + const di = deficitForScenario( + key, + scenarios, + scenariosSummary, + targetHorizon, + ); + const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null; + const labelHorizon = di.horizon ?? targetHorizon; + const value = di.value; + const tone = value != null ? deficitTone(value) : "neutral"; + const word = + value != null + ? Math.abs(value) < DEFICIT_BALANCE_EPS + ? "баланс" + : deficitWord(value) + : "нет данных"; + + return ( +
+
{SCENARIO_RU[key]}
+
+ {value != null ? fmtSignedDeficit(value) : "—"} +
+
+ + Дефицит-индекс ({labelHorizon} мес) + + {word} +
+
+ Прогноз спроса + + {fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"} + +
+
+ Прогноз предложения + + {fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"} + +
+
+ Месяцев запаса (MOI) + + {fc ? fmtNum(fc.months_of_inventory, 1) : "—"} + +
+
{SCENARIO_HINT[key]}
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx new file mode 100644 index 00000000..d76602db --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx @@ -0,0 +1,105 @@ +"use client"; + +/** + * ScenarioCompareTable — 6.2 «Сравнение сценариев». One row per scenario + * (base / aggressive / conservative): deficit-index · demand · supply · вывод. + * Reads the same by_scenario forecasts as ScenarioCards at the target horizon. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { + ScenarioKey, + ReportScenarios, + ScenariosSummary, +} from "@/types/forecast"; +import { + SCENARIO_ORDER, + deficitForScenario, + deficitTone, + deficitVerdict, + fmtInt, + fmtSignedDeficit, + pickHorizonForecast, +} from "./scenario-helpers"; + +interface Props { + scenarios: ReportScenarios; + scenariosSummary: ScenariosSummary | null; + targetHorizon: number; +} + +const SCENARIO_SHORT: Record = { + base: "Базовый", + aggressive: "Агрессивный", + conservative: "Консервативный", +}; + +const DEFICIT_TONE_CLASS = { + good: styles.numPos, + bad: styles.numNeg, + warn: styles.numNeg, + neutral: "", +} as const; + +export function ScenarioCompareTable({ + scenarios, + scenariosSummary, + targetHorizon, +}: Props) { + const present = SCENARIO_ORDER.filter( + (k) => scenarios.by_scenario[k] != null || scenariosSummary != null, + ); + if (present.length === 0) return null; + + return ( + <> +
+ 6.2 · сравнение сценариев +
+
+ + + + + + + + + + + + {present.map((key) => { + const sc = scenarios.by_scenario[key]; + const di = deficitForScenario( + key, + scenarios, + scenariosSummary, + targetHorizon, + ); + const fc = sc + ? pickHorizonForecast(sc.forecasts, targetHorizon) + : null; + const value = di.value; + const tone = value != null ? deficitTone(value) : "neutral"; + return ( + + + + + + + + ); + })} + +
СценарийДефицит-индексСпросПредлож.Вывод
{SCENARIO_SHORT[key]} + {value != null ? fmtSignedDeficit(value) : "—"} + + {fc ? fmtInt(fc.projected_demand_units) : "—"} + + {fc ? fmtInt(fc.projected_supply_units) : "—"} + {value != null ? deficitVerdict(value) : "—"}
+
+ + ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx new file mode 100644 index 00000000..bd2628a8 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/ScoringTransparency.tsx @@ -0,0 +1,105 @@ +"use client"; + +/** + * ScoringTransparency — 6.5 «Прозрачность скоринга». §13.6 scoring.product_scores: + * one row per §14.2 product score — factor (RU label) · вес (value ∈ 0..1) · + * направление. Score semantics (HARD): value ∈ [0,1], выше = лучше для девелопера + * (risk scores pre-inverted at the source), anchored on 0.5 = balance. So the + * direction reads off the 0.5 midpoint: ≥0.55 «сильный» / 0.45–0.55 «нейтральный» + * / <0.45 «слабый». Rows with value=null (thin data) render «нет данных». + * + * GRACEFUL: returns null when scoring carries no product scores. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { + ProductScore, + ProductScoreCard, + ReportScoring, +} from "@/types/forecast"; +import { fmtNum } from "./scenario-helpers"; + +interface Props { + scoring: ReportScoring; +} + +const SCORE_GOOD_THRESHOLD = 0.55; +const SCORE_WEAK_THRESHOLD = 0.45; + +const PRODUCT_SCORE_RU: Record = { + market_fit: "Соответствие рынку", + demand: "Спрос", + supply_risk: "Риск избытка предложения", + future_competition: "Будущая конкуренция", + price_feasibility: "Доступность цены", + infra_fit: "Инфраструктура", + mortgage_sensitivity: "Чувствительность к ставке", + differentiation: "Дифференциация", + commercial: "Коммерция", + confidence: "Надёжность данных", +}; + +interface Direction { + text: string; + toneClass: string; +} + +function scoreDirection(value: number | null): Direction { + if (value == null) return { text: "—", toneClass: "" }; + if (value >= SCORE_GOOD_THRESHOLD) + return { text: "сигнал ↑", toneClass: styles.numPos }; + if (value >= SCORE_WEAK_THRESHOLD) + return { text: "нейтрально →", toneClass: "" }; + return { text: "сигнал ↓", toneClass: styles.numNeg }; +} + +function scoreRows(card: ProductScoreCard | null): ProductScore[] { + if (card == null) return []; + return Object.values(card.scores).filter( + (s): s is ProductScore => s != null && typeof s.key === "string", + ); +} + +export function ScoringTransparency({ scoring }: Props) { + const scores = scoreRows(scoring.product_scores); + if (scores.length === 0) return null; + + return ( + <> +
+ 6.5 · прозрачность скоринга +
+
+ + + + + + + + + + {scores.map((s) => { + const dir = scoreDirection(s.value); + return ( + + + + + + ); + })} + +
Фактор прогнозаВесНаправление
+ {PRODUCT_SCORE_RU[s.key] ?? s.key} + + {s.value != null ? fmtNum(s.value, 2) : "нет данных"} + {dir.text}
+

+ Вес — значение скора ∈ 0…1 (выше — лучше для девелопера; риск-скоры + уже инвертированы). Направление — относительно баланса 0.5. +

+
+ + ); +} diff --git a/frontend/src/components/site-finder/ptica/scenarios/scenario-helpers.ts b/frontend/src/components/site-finder/ptica/scenarios/scenario-helpers.ts new file mode 100644 index 00000000..1fa4e809 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/scenarios/scenario-helpers.ts @@ -0,0 +1,151 @@ +/** + * scenario-helpers — framework-agnostic mapping helpers for the ПТИЦА dark + * «Сценарии» tab. Shares the deficit semantics + RU formatting used by the light + * Section 6 blocks (single source of truth = forecast-helpers), re-expressed here + * with dark-cockpit "tone" classes (good/warn/bad) instead of light BadgeVariant. + * + * Deficit semantics (HARD): >0 недонасыщенность (повод строить), + * <0 затоварка, ≈0 баланс. Mirrors forecast-helpers.deficitWord. + */ + +import type { + DemandSupplyForecast, + ReportScenarios, + ScenarioKey, + ScenariosSummary, +} from "@/types/forecast"; + +/** Below this |deficit_index| the market reads as balanced (neutral tone). */ +export const DEFICIT_BALANCE_EPS = 0.05; + +/** Dark cockpit severity tone — drives the accent colour via CSS classes. */ +export type Tone = "good" | "warn" | "bad" | "neutral"; + +/** deficit_index → tone (>0 good / <0 bad / ≈0 neutral). */ +export function deficitTone(deficitIndex: number): Tone { + if (deficitIndex > DEFICIT_BALANCE_EPS) return "good"; + if (deficitIndex < -DEFICIT_BALANCE_EPS) return "bad"; + return "neutral"; +} + +/** deficit_index → RU word (недонасыщенность / затоварка / баланс). */ +export function deficitWord(deficitIndex: number): string { + if (deficitIndex > DEFICIT_BALANCE_EPS) return "недонасыщенность"; + if (deficitIndex < -DEFICIT_BALANCE_EPS) return "затоварка"; + return "баланс"; +} + +/** Short verdict for the compare table «Вывод» column. */ +export function deficitVerdict(deficitIndex: number): string { + if (deficitIndex > 0.25) return "Сильный дефицит — премия к цене"; + if (deficitIndex > DEFICIT_BALANCE_EPS) + return "Недонасыщенность — окно для выхода"; + if (deficitIndex < -DEFICIT_BALANCE_EPS) return "Затоварка — сдерживать темп"; + return "Баланс — контроль темпа продаж"; +} + +// ── Confidence ────────────────────────────────────────────────────────────── + +import type { ConfidenceLevel } from "@/types/forecast"; + +export const CONFIDENCE_RU: Record = { + high: "высокая", + medium: "средняя", + low: "низкая", +}; + +/** ConfidenceLevel → conf-pill modifier class key (high/medium/low). */ +export function confidencePill( + level: ConfidenceLevel, +): "high" | "medium" | "low" { + return level; +} + +// ── Number formatting (ru typography) ───────────────────────────────────────── + +/** + * Fixed-decimal RU number. Normalises the leading ASCII minus to the Unicode + * «−» (U+2212) per ui-microcopy. `.replace` hits only the leading sign (RU + * grouping uses NBSP, not "-"), so the numeric part is untouched. + */ +export function fmtNum(value: number, digits = 1): string { + return value + .toLocaleString("ru", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }) + .replace("-", "−"); +} + +/** Signed deficit_index, e.g. «+0.18» / «−1.00» (Unicode minus). */ +export function fmtSignedDeficit(deficitIndex: number, digits = 2): string { + const body = fmtNum(deficitIndex, digits); + return deficitIndex > 0 ? `+${body}` : body; +} + +/** Round to integer with RU thousands grouping (квартиры). */ +export function fmtInt(value: number): string { + return Math.round(value).toLocaleString("ru-RU"); +} + +// ── Scenario selection ──────────────────────────────────────────────────────── + +/** Display order: base → aggressive → conservative (own → best → worst). */ +export const SCENARIO_ORDER: ScenarioKey[] = [ + "base", + "aggressive", + "conservative", +]; + +export const SCENARIO_RU: Record = { + base: "Базовый сценарий", + aggressive: "Агрессивный", + conservative: "Консервативный", +}; + +export const SCENARIO_HINT: Record = { + base: "ставка без изменений", + aggressive: "ставка ниже", + conservative: "ставка выше", +}; + +/** Border-top accent class key per scenario (base blue / aggr green / cons yellow). */ +export const SCENARIO_ACCENT: Record = { + base: "base", + aggressive: "aggr", + conservative: "cons", +}; + +/** + * Pick the forecast row at the target horizon, falling back to the longest + * available horizon (mirrors ScenariosBlock.pickHorizonForecast). + */ +export function pickHorizonForecast( + forecasts: DemandSupplyForecast[], + targetHorizon: number, +): DemandSupplyForecast | null { + if (forecasts.length === 0) return null; + const exact = forecasts.find((f) => f.horizon_months === targetHorizon); + if (exact) return exact; + return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0]; +} + +/** The deficit_index for a scenario at the target horizon (+ the horizon used). */ +export interface ScenarioDeficit { + value: number | null; + horizon: number | null; +} + +export function deficitForScenario( + key: ScenarioKey, + scenarios: ReportScenarios, + scenariosSummary: ScenariosSummary | null, + targetHorizon: number, +): ScenarioDeficit { + const sc = scenarios.by_scenario[key]; + const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null; + if (fc) return { value: fc.deficit_index, horizon: fc.horizon_months }; + if (scenariosSummary) + return { value: scenariosSummary[key], horizon: targetHorizon }; + return { value: null, horizon: null }; +}