feat(ptica): Сценарии tab (§22 forecast) + real hero invest-score [PR#2] #1832

Merged
lekss361 merged 1 commit from feat/ptica-cockpit-pr2-scenarios into main 2026-06-20 12:33:08 +00:00
13 changed files with 1567 additions and 16 deletions

View file

@ -17,12 +17,16 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import styles from "./ptica.module.css"; 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 type { ParcelAnalysis } from "@/types/site-finder";
import { PticaShell } from "@/components/site-finder/ptica/PticaShell"; import { PticaShell } from "@/components/site-finder/ptica/PticaShell";
import type { PticaTab } from "@/components/site-finder/ptica/PticaShell"; import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
import { PticaHero } from "@/components/site-finder/ptica/PticaHero"; import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan"; 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"; import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel";
interface Props { interface Props {
@ -30,9 +34,15 @@ interface Props {
} }
export function PticaPageContent({ cad }: Props) { export function PticaPageContent({ cad }: Props) {
const [horizon] = useState<number>(12); const [horizon, setHorizon] = useState<number>(12);
const [tab, setTab] = useState<PticaTab>("analysis"); const [tab, setTab] = useState<PticaTab>("analysis");
const { data, isLoading, error } = useParcelAnalyzeQuery(cad, horizon); 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 ──────────────────────────────────────────────────────────────── // ── Loading ────────────────────────────────────────────────────────────────
if (isLoading) { if (isLoading) {
@ -70,12 +80,20 @@ export function PticaPageContent({ cad }: Props) {
return ( return (
<div className={styles.pticaRoot} data-theme="dark"> <div className={styles.pticaRoot} data-theme="dark">
<PticaShell activeTab={tab} onTabChange={setTab}> <PticaShell activeTab={tab} onTabChange={setTab}>
{tab === "analysis" ? ( {tab === "analysis" && (
<> <>
<PticaHero analysis={analysis} /> <PticaHero analysis={analysis} forecastReport={forecastReport} />
<DevelopmentScan analysis={analysis} /> <DevelopmentScan analysis={analysis} />
</> </>
) : ( )}
{tab === "scenarios" && (
<PticaScenarios
cad={cad}
horizon={horizon}
onHorizonChange={setHorizon}
/>
)}
{(tab === "reports" || tab === "compare") && (
<PticaPlaceholderPanel <PticaPlaceholderPanel
label="В разработке" label="В разработке"
hint="Раздел появится в следующем релизе платформы ПТИЦА." hint="Раздел появится в следующем релизе платформы ПТИЦА."

View file

@ -688,6 +688,382 @@
font-size: 13px; font-size: 13px;
} }
/* ===================== SCENARIOS (§22 Прогноз) ===================== */
.scenariosRoot {
display: flex;
flex-direction: column;
gap: 12px;
}
/* view header (title + horizon selector) */
.viewHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.viewHead h2 {
margin: 0;
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-strong);
}
.viewSub {
font-size: 10px;
color: var(--text-soft);
letter-spacing: 0.08em;
margin-left: 8px;
text-transform: none;
}
.horizonSel {
display: flex;
align-items: center;
gap: 8px;
}
.horizonLab {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-soft);
}
.seg {
display: inline-flex;
border: var(--line) solid var(--border);
border-radius: var(--radius-xs);
overflow: hidden;
}
.seg button {
padding: 6px 14px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 11px;
border-right: var(--line) solid var(--border);
transition:
color 0.15s,
background 0.15s;
}
.seg button:last-child {
border-right: none;
}
.seg button:hover {
color: var(--text);
}
.segActive,
.seg button.segActive {
background: var(--accent-blue);
color: #fff;
}
/* 3 scenario cards */
.scenarioGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.scenarioCard {
border-top: 2px solid var(--accent-cyan);
}
.scAccentBase {
border-top-color: var(--accent-blue);
}
.scAccentAggr {
border-top-color: var(--accent-green);
}
.scAccentCons {
border-top-color: var(--accent-yellow);
}
.scTag {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-soft);
}
.scDeficit {
font-size: 30px;
font-weight: 700;
font-family: var(--font-mono);
margin: 4px 0;
line-height: 1.05;
}
.deficitPos {
color: var(--accent-green);
}
.deficitNeg {
color: var(--accent-red);
}
.deficitFlat {
color: var(--text);
}
.scKvRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 11px;
padding: 3px 0;
}
.scK {
color: var(--text-muted);
}
.scV {
font-weight: 600;
color: var(--text);
text-align: right;
}
.scNote {
margin-top: 6px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
}
/* forecast grid (chart + table; confidence + product) */
.forecastGrid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 10px;
}
/* inline legend */
.legendInline {
display: flex;
gap: 14px;
flex-wrap: wrap;
font-size: 10px;
color: var(--text-muted);
}
.legendInline span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legendInline i {
width: 14px;
height: 3px;
border-radius: 2px;
}
.legendDemand {
background: var(--accent-cyan);
}
.legendSupply {
background: var(--accent-yellow);
}
/* inline SVG line chart */
.chart {
width: 100%;
height: 180px;
}
.chart svg {
width: 100%;
height: 100%;
overflow: visible;
}
.chartGrid {
stroke: var(--border-faint);
stroke-width: 1;
}
.chartDemand {
fill: none;
stroke: var(--accent-cyan);
stroke-width: 2;
}
.chartSupply {
fill: none;
stroke: var(--accent-yellow);
stroke-width: 2;
stroke-dasharray: 4 4;
}
.chartLbl {
fill: var(--text-soft);
font-size: 9px;
font-family: var(--font-mono);
}
/* data table */
.dtable {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.dtable th,
.dtable td {
text-align: left;
padding: 8px 9px;
border-bottom: var(--line) solid var(--border-faint);
}
.dtable th {
color: var(--text-soft);
text-transform: uppercase;
font-size: 8.5px;
letter-spacing: 0.06em;
font-weight: 500;
}
.thNum {
text-align: right;
}
.tdNum {
text-align: right;
font-family: var(--font-mono);
}
.dtable tbody tr:hover td {
background: var(--surface-2);
}
.numPos {
color: var(--accent-green);
}
.numNeg {
color: var(--accent-red);
}
.rowTarget td {
background: rgba(47, 111, 159, 0.16);
}
.targetTag {
margin-left: 8px;
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent-cyan);
}
/* confidence rows + pills */
.confRow {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0;
font-size: 11px;
}
.confName {
flex: 1;
color: var(--text-muted);
}
.confPill {
padding: 2px 9px;
border-radius: 999px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
border: var(--line) solid currentColor;
}
.confPillHigh {
color: var(--accent-green);
}
.confPillMedium {
color: var(--accent-yellow);
}
.confPillLow {
color: var(--accent-red);
}
/* tag (recommended class) */
.tag {
display: inline-block;
padding: 1px 7px;
border-radius: var(--radius-xs);
font-size: 9px;
background: var(--surface-muted);
color: var(--text);
}
/* horizontal bars (квартирография) */
.hbars {
display: grid;
gap: 7px;
}
.hbar {
display: grid;
grid-template-columns: 110px 1fr 56px;
gap: 9px;
align-items: center;
font-size: 11px;
}
.hbarName {
color: var(--text-muted);
}
.hbarTrack {
height: 5px;
background: var(--surface-muted);
border-radius: 3px;
overflow: hidden;
}
.hbarFill {
height: 100%;
border-radius: 3px;
}
.hbarFillGood {
background: var(--accent-green);
}
.hbarFillBad {
background: var(--accent-red);
}
.hbarFillNeutral {
background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
}
.hbarValue {
text-align: right;
font-family: var(--font-mono);
font-weight: 600;
}
/* hints + empty panels */
.panelHint {
margin: 10px 0 0;
font-size: 10px;
line-height: 1.5;
color: var(--text-soft);
}
.emptyPanel {
display: grid;
place-items: center;
min-height: 96px;
text-align: center;
color: var(--text-soft);
font-size: 11px;
}
/* pending / polling skeleton (calm, NOT an error) */
.forecastPending {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 260px;
text-align: center;
}
.pendingPulse {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent-cyan);
animation: pticaSpin 0.9s linear infinite;
}
@keyframes pticaSpin {
to {
transform: rotate(360deg);
}
}
.pendingTitle {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.pendingHint {
margin: 0;
font-size: 10px;
color: var(--text-soft);
max-width: 320px;
line-height: 1.5;
}
/* ===================== RESPONSIVE ===================== */ /* ===================== RESPONSIVE ===================== */
@media (max-width: 1500px) { @media (max-width: 1500px) {
.hero { .hero {
@ -704,6 +1080,9 @@
.scanGrid { .scanGrid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.forecastGrid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.shell { .shell {
@ -722,4 +1101,8 @@
.tabs { .tabs {
display: none; display: none;
} }
.scenarioGrid,
.forecastGrid {
grid-template-columns: 1fr;
}
} }

View file

@ -9,14 +9,17 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt"; import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt";
interface Props { interface Props {
analysis: ParcelAnalysis; analysis: ParcelAnalysis;
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
forecastReport?: ForecastReport;
} }
export function InvestScoreBlock({ analysis }: Props) { export function InvestScoreBlock({ analysis, forecastReport }: Props) {
const inv = adaptInvestScore(analysis); const inv = adaptInvestScore(analysis, forecastReport);
return ( return (
<div className={styles.scoreStack}> <div className={styles.scoreStack}>
@ -36,7 +39,7 @@ export function InvestScoreBlock({ analysis }: Props) {
</span> </span>
)} )}
</div> </div>
{!inv.score.isReal && inv.score.caption && ( {inv.score.caption && (
<span className={styles.fieldCaption}>{inv.score.caption}</span> <span className={styles.fieldCaption}>{inv.score.caption}</span>
)} )}
</div> </div>

View file

@ -7,6 +7,7 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import { PticaMap } from "@/components/site-finder/ptica/PticaMap"; import { PticaMap } from "@/components/site-finder/ptica/PticaMap";
import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPassportCard"; import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPassportCard";
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
@ -15,9 +16,11 @@ import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-ada
interface Props { interface Props {
analysis: ParcelAnalysis; 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); const buildGauge = adaptBuildabilityGauge(analysis);
return ( return (
@ -28,7 +31,7 @@ export function PticaHero({ analysis }: Props) {
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}> <div id="potential" className={`${styles.panel} ${styles.scorePanel}`}>
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" /> <BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
<InvestScoreBlock analysis={analysis} /> <InvestScoreBlock analysis={analysis} forecastReport={forecastReport} />
</div> </div>
</section> </section>
); );

View file

@ -11,6 +11,7 @@
*/ */
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
// ── View-model ────────────────────────────────────────────────────────────── // ── View-model ──────────────────────────────────────────────────────────────
@ -65,6 +66,14 @@ function formatInt(n: number): string {
return Math.round(n).toLocaleString("ru-RU"); 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). */ /** м² from area_ha (×10000). */
function areaM2FromHa(areaHa: number): string { function areaM2FromHa(areaHa: number): string {
return `${formatInt(areaHa * 10000)} м²`; return `${formatInt(areaHa * 10000)} м²`;
@ -205,16 +214,67 @@ export interface PticaInvestScore {
buySignal: PticaField; 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 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 { return {
score: placeholder("после прогноза (Scenarios)"), score: placeholder("после прогноза (Scenarios)"),
potential: placeholder("после прогноза (Scenarios)"), potential: placeholder("после прогноза (Scenarios)"),
risk: risk: riskField,
risk.value != null buySignal,
? { value: risk.label, isReal: false, caption: "предв." }
: placeholder("предв."),
buySignal: placeholder("после прогноза (Scenarios)"),
}; };
} }

View file

@ -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<string, string> = {
deal_count: "Объём данных по сделкам",
analog_count: "Аналоги (ЖК)",
domrf_coverage: "Покрытие ДОМ.РФ",
history_months: "Глубина истории",
component: "Компонент",
};
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
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<string, unknown>;
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 (
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Уверенность прогноза</h3>
{level && (
<span
className={`${styles.confPill} ${PILL_CLASS[confidencePill(level)]}`}
>
{CONFIDENCE_RU[level]}
</span>
)}
</div>
{drivers.length > 0 ? (
drivers.map(([key, factor]) => (
<div key={key} className={styles.confRow}>
<span className={styles.confName}>{factorLabel(key, factor)}</span>
<span
className={`${styles.confPill} ${PILL_CLASS[confidencePill(factor.level)]}`}
>
{CONFIDENCE_RU[factor.level]}
</span>
</div>
))
) : (
<p className={styles.panelHint}>Факторы достоверности недоступны.</p>
)}
</div>
);
}

View file

@ -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. "Комфорт · 12К") — 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 (
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Прогноз по горизонтам недоступен.
</div>
);
}
return (
<>
<div className={styles.sectionLabel}>
6.1 · <b>прогноз по горизонтам</b>
</div>
<section className={styles.forecastGrid}>
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Спрос vs предложение</h3>
<div className={styles.legendInline}>
<span>
<i className={styles.legendDemand} />
Спрос
</span>
<span>
<i className={styles.legendSupply} />
Предложение
</span>
</div>
</div>
{chart ? (
<div className={styles.chart}>
<svg
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
preserveAspectRatio="none"
role="img"
aria-label="Прогноз спроса и предложения по горизонтам, квартир"
>
{chart.gridLines.map((gy, i) => (
<line
key={i}
className={styles.chartGrid}
x1={CHART_PAD}
y1={gy}
x2={CHART_W - CHART_PAD}
y2={gy}
/>
))}
<path className={styles.chartDemand} d={chart.demandPath} />
<path className={styles.chartSupply} d={chart.supplyPath} />
{chart.xLabels.map((l, i) => (
<text
key={i}
className={styles.chartLbl}
x={l.x.toFixed(1)}
y={CHART_H - 8}
textAnchor="middle"
>
{l.text}
</text>
))}
</svg>
</div>
) : (
<div className={styles.emptyState}>
Недостаточно точек для графика
</div>
)}
</div>
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>По горизонтам</h3>
</div>
<table className={styles.dtable}>
<thead>
<tr>
<th>Горизонт</th>
<th className={styles.thNum}>Спрос</th>
<th className={styles.thNum}>Предлож.</th>
<th className={styles.thNum}>Дефицит</th>
</tr>
</thead>
<tbody>
{rows.map((f) => {
const isTarget = f.horizon_months === targetHorizon;
const tone = deficitTone(f.deficit_index);
return (
<tr
key={f.horizon_months}
className={isTarget ? styles.rowTarget : undefined}
>
<td>
{f.horizon_months} мес
{isTarget && (
<span className={styles.targetTag}>целевой</span>
)}
</td>
<td className={styles.tdNum}>
{fmtInt(f.projected_demand_units)}
</td>
<td className={styles.tdNum}>
{fmtInt(f.projected_supply_units)}
</td>
<td
className={`${styles.tdNum} ${DEFICIT_TONE_CLASS[tone]}`}
>
{fmtSignedDeficit(f.deficit_index)}
</td>
</tr>
);
})}
</tbody>
</table>
{segmentLabel && (
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
<span className={styles.scK}>Сегмент</span>
<span className={styles.scV}>{segmentLabel}</span>
</div>
)}
</div>
</section>
</>
);
}

View file

@ -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 = (
<div className={styles.viewHead}>
<h2>
Сценарии{" "}
<span className={styles.viewSub}>
прогноз спроса и предложения · §22
</span>
</h2>
<div className={styles.horizonSel}>
<span className={styles.horizonLab}>Горизонт</span>
<div className={styles.seg} role="group" aria-label="Горизонт прогноза">
{HORIZONS.map((h) => (
<button
key={h}
type="button"
className={h === horizon ? styles.segActive : undefined}
aria-pressed={h === horizon}
onClick={() => onHorizonChange(h)}
>
{h} мес
</button>
))}
</div>
</div>
</div>
);
// ── 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 (
<div className={styles.scenariosRoot}>
{header}
<div className={`${styles.panel} ${styles.forecastPending}`}>
<div className={styles.pendingPulse} aria-hidden="true" />
<div className={styles.pendingTitle}>Прогноз считается</div>
<p className={styles.pendingHint}>
Сценарный прогноз §22 формируется в фоне после запуска анализа.
Обычно занимает 12 минуты обновится автоматически.
</p>
</div>
</div>
);
}
// ── Error / no report → honest empty panel ───────────────────────────────────
if (error || !report) {
return (
<div className={styles.scenariosRoot}>
{header}
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Прогноз §22 недоступен для этого участка.
</div>
</div>
);
}
const fm = report.future_market;
const targetHorizon = horizon;
return (
<div className={styles.scenariosRoot}>
{header}
<ScenarioCards
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
/>
<HorizonForecast
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
segmentLabel={segmentLabel(report.meta.segment)}
/>
<ScenarioCompareTable
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
/>
<div className={styles.sectionLabel}>
6.3 · <b>уверенность</b> · 6.4 · <b>рекомендация по продукту</b>
</div>
<section className={styles.forecastGrid}>
<ConfidencePanel confidence={report.confidence} />
{report.product_tz ? (
<RecommendedProduct
productTz={report.product_tz}
successScore={report.scoring?.overall ?? null}
targetPrice={null}
/>
) : (
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Рекомендация по продукту недоступна.
</div>
)}
</section>
{report.scoring && <ScoringTransparency scoring={report.scoring} />}
</div>
);
}

View file

@ -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 (
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Рекомендованный продукт</h3>
{productTz.obj_class && (
<span className={styles.tag}>{capitalize(productTz.obj_class)}</span>
)}
</div>
{bars.length > 0 && (
<div className={styles.hbars}>
{bars.map((b) => (
<div key={b.key} className={styles.hbar}>
<span className={styles.hbarName}>{b.label}</span>
<div className={styles.hbarTrack}>
<div
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
style={{ width: `${b.widthPct}%` }}
/>
</div>
<span className={styles.hbarValue}>{b.valueStr}</span>
</div>
))}
</div>
)}
{!byShare && bars.length > 0 && (
<p className={styles.panelHint}>
Длина полосы сила сигнала по индексу дефицита формата: зелёный
недонасыщенность, красный затоварка.
</p>
)}
{targetPrice && (
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
<span className={styles.scK}>Целевая цена</span>
<span className={styles.scV}>{targetPrice}</span>
</div>
)}
<div className={styles.scKvRow}>
<span className={styles.scK}>Success-score формата</span>
<span className={styles.scV}>
{successScore != null ? fmtNum(successScore, 2) : "—"}
</span>
</div>
</div>
);
}
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

View file

@ -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 (
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Сценарный прогноз недоступен.
</div>
);
}
return (
<section className={styles.scenarioGrid}>
{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 (
<div
key={key}
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[key]]}`}
>
<div className={styles.scTag}>{SCENARIO_RU[key]}</div>
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
{value != null ? fmtSignedDeficit(value) : "—"}
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>
Дефицит-индекс ({labelHorizon} мес)
</span>
<span className={styles.scV}>{word}</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз спроса</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз предложения</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Месяцев запаса (MOI)</span>
<span className={styles.scV}>
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
</span>
</div>
<div className={styles.scNote}>{SCENARIO_HINT[key]}</div>
</div>
);
})}
</section>
);
}

View file

@ -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<ScenarioKey, string> = {
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 (
<>
<div className={styles.sectionLabel}>
6.2 · <b>сравнение сценариев</b>
</div>
<div className={styles.panel}>
<table className={styles.dtable}>
<thead>
<tr>
<th>Сценарий</th>
<th className={styles.thNum}>Дефицит-индекс</th>
<th className={styles.thNum}>Спрос</th>
<th className={styles.thNum}>Предлож.</th>
<th>Вывод</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={key}>
<td>{SCENARIO_SHORT[key]}</td>
<td className={`${styles.tdNum} ${DEFICIT_TONE_CLASS[tone]}`}>
{value != null ? fmtSignedDeficit(value) : "—"}
</td>
<td className={styles.tdNum}>
{fc ? fmtInt(fc.projected_demand_units) : "—"}
</td>
<td className={styles.tdNum}>
{fc ? fmtInt(fc.projected_supply_units) : "—"}
</td>
<td>{value != null ? deficitVerdict(value) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
}

View file

@ -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.450.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<string, string> = {
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 (
<>
<div className={styles.sectionLabel}>
6.5 · <b>прозрачность скоринга</b>
</div>
<div className={styles.panel}>
<table className={styles.dtable}>
<thead>
<tr>
<th>Фактор прогноза</th>
<th className={styles.thNum}>Вес</th>
<th>Направление</th>
</tr>
</thead>
<tbody>
{scores.map((s) => {
const dir = scoreDirection(s.value);
return (
<tr key={s.key}>
<td title={s.reason || undefined}>
{PRODUCT_SCORE_RU[s.key] ?? s.key}
</td>
<td className={styles.tdNum}>
{s.value != null ? fmtNum(s.value, 2) : "нет данных"}
</td>
<td className={dir.toneClass}>{dir.text}</td>
</tr>
);
})}
</tbody>
</table>
<p className={styles.panelHint}>
Вес значение скора 01 (выше лучше для девелопера; риск-скоры
уже инвертированы). Направление относительно баланса 0.5.
</p>
</div>
</>
);
}

View file

@ -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<ConfidenceLevel, string> = {
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<ScenarioKey, string> = {
base: "Базовый сценарий",
aggressive: "Агрессивный",
conservative: "Консервативный",
};
export const SCENARIO_HINT: Record<ScenarioKey, string> = {
base: "ставка без изменений",
aggressive: "ставка ниже",
conservative: "ставка выше",
};
/** Border-top accent class key per scenario (base blue / aggr green / cons yellow). */
export const SCENARIO_ACCENT: Record<ScenarioKey, "base" | "aggr" | "cons"> = {
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 };
}