feat(site-finder): forecast deficit/scenario chart in Section 6 #1112

Merged
bot-backend merged 1 commit from feat/forecast-chart-section6 into main 2026-06-07 09:19:23 +00:00
2 changed files with 307 additions and 0 deletions

View file

@ -0,0 +1,303 @@
"use client";
/**
* ForecastChart §22 deficit-index trajectory across horizons × scenarios.
*
* Renders ABOVE the 6.1 horizons table (chart-then-table) on the Site Finder
* analysis screen. Visualises what the 6.1 / 6.2 tables show as numbers:
*
* Primary (left y-axis «Индекс дефицита», ~[-1,1]): one line per scenario
* (base / aggressive / conservative), deficit_index at each horizon.
* Colours from --viz-1..3 IN ORDER (base = viz-1). Falls back to a single
* «Дефицит» line from future_market.forecasts_by_horizon if no scenarios.
* Secondary (right y-axis «Ставка, % годовых»): a dashed key-rate path
* (prediction dashed per ui-conventions). Single line (base scenario, or
* the fallback forecast) to keep the chart readable 3 rate lines clutter.
* markLine (vertical) at the target horizon, labelled «целевой».
* markLine (horizontal) at deficit = 0 («баланс») so the sign reads:
* >0 недонасыщенность, <0 затоварка.
*
* Lazy-mount + echarts import are handled by ChartShell we only build a typed
* `option` and hand it over (no echarts import in this file).
*
* Deficit semantics (HARD, see forecast-helpers): >0 недонасыщенность,
* <0 затоварка, 0 баланс.
*/
import { useMemo } from "react";
import { ChartShell } from "@/components/analytics/ChartShell";
import type {
DemandSupplyForecast,
ForecastReport,
ScenarioKey,
} from "@/types/forecast";
import { fmtNum } from "./forecast-helpers";
interface Props {
report: ForecastReport;
/** Horizon (мес) to emphasise — the user's HorizonSelector choice wins. */
selectedHorizon?: number;
}
// Scenario render order + RU labels. viz-1 is always the focused/own series, so
// base (the default expectation) takes viz-1, then aggressive, then conservative.
const SCENARIO_ORDER: ScenarioKey[] = ["base", "aggressive", "conservative"];
const SCENARIO_RU: Record<ScenarioKey, string> = {
base: "База",
aggressive: "Агрессивный",
conservative: "Консервативный",
};
// Series colours — ONLY from --viz-1..3 in order (documented hardcode exception
// for ECharts series colours; values mirror ui-tokens.md).
const VIZ: Record<ScenarioKey, string> = {
base: "#1D4ED8", // --viz-1
aggressive: "#0EA5E9", // --viz-2
conservative: "#14B8A6", // --viz-3
};
// Single-line fallback colour (ui-tokens: одиночная линия — #374151).
const SINGLE_LINE = "#374151";
// Prediction / forecast (dashed) — --prediction-line.
const PREDICTION_LINE = "#0EA5E9";
// Axis chrome.
const FG_SECONDARY = "#5B6066";
const BORDER_STRONG = "#D1D5DB";
// ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ──
interface TooltipParam {
seriesName?: string;
value?: number | null;
axisValueLabel?: string;
marker?: string;
seriesType?: string;
}
function sortedUniqueHorizons(report: ForecastReport): number[] {
const set = new Set<number>();
for (const f of report.future_market.forecasts_by_horizon) {
set.add(f.horizon_months);
}
for (const sc of Object.values(report.scenarios.by_scenario)) {
for (const f of sc?.forecasts ?? []) set.add(f.horizon_months);
}
return Array.from(set).sort((a, b) => a - b);
}
/** deficit_index per horizon for a forecast list, aligned to the horizon axis. */
function deficitByHorizon(
forecasts: DemandSupplyForecast[],
horizons: number[],
): (number | null)[] {
const byH = new Map<number, number>();
for (const f of forecasts) byH.set(f.horizon_months, f.deficit_index);
return horizons.map((h) => byH.get(h) ?? null);
}
/** rate_future per horizon for a forecast list, aligned to the horizon axis. */
function rateByHorizon(
forecasts: DemandSupplyForecast[],
horizons: number[],
): (number | null)[] {
const byH = new Map<number, number>();
for (const f of forecasts) byH.set(f.horizon_months, f.rate_future);
return horizons.map((h) => byH.get(h) ?? null);
}
export function ForecastChart({ report, selectedHorizon }: Props) {
const fm = report.future_market;
const byScenario = report.scenarios.by_scenario;
const presentScenarios = SCENARIO_ORDER.filter(
(k) => (byScenario[k]?.forecasts.length ?? 0) > 0,
);
const hasScenarios = presentScenarios.length > 0;
const hasHorizons = fm.forecasts_by_horizon.length > 0;
// Target horizon: user's selector wins, else future_supply, else 12.
const targetHorizon =
selectedHorizon ?? fm.future_supply?.horizon_months ?? 12;
const option = useMemo<Record<string, unknown>>(() => {
const horizons = sortedUniqueHorizons(report);
const xLabels = horizons.map((h) => `${h} мес`);
// ── Deficit series ──────────────────────────────────────────────────────
// Per-scenario lines when available, else a single fallback line.
const deficitSeries: Record<string, unknown>[] = hasScenarios
? presentScenarios.map((key) => ({
name: SCENARIO_RU[key],
type: "line",
yAxisIndex: 0,
data: deficitByHorizon(byScenario[key]?.forecasts ?? [], horizons),
symbol: "circle",
symbolSize: 6,
smooth: false,
connectNulls: true,
lineStyle: { color: VIZ[key], width: 2 },
itemStyle: { color: VIZ[key] },
emphasis: { focus: "series" },
z: 3,
}))
: [
{
name: "Дефицит",
type: "line",
yAxisIndex: 0,
data: deficitByHorizon(fm.forecasts_by_horizon, horizons),
symbol: "circle",
symbolSize: 6,
smooth: false,
connectNulls: true,
lineStyle: { color: SINGLE_LINE, width: 2 },
itemStyle: { color: SINGLE_LINE },
z: 3,
},
];
// ── Rate path (secondary axis, dashed = prediction) ─────────────────────
// One line only (base scenario preferred, else fallback forecast) — three
// rate lines clutter the deficit story.
const rateSource: DemandSupplyForecast[] =
byScenario.base?.forecasts ??
(hasScenarios
? (byScenario[presentScenarios[0]]?.forecasts ?? [])
: fm.forecasts_by_horizon);
const rateData = rateByHorizon(rateSource, horizons);
const hasRate = rateData.some((v) => v != null);
const rateSeries: Record<string, unknown>[] = hasRate
? [
{
name: "Ставка",
type: "line",
yAxisIndex: 1,
data: rateData,
symbol: "emptyCircle",
symbolSize: 5,
smooth: false,
connectNulls: true,
// Forecast / prediction → dashed (ui-conventions HARD).
lineStyle: { color: PREDICTION_LINE, width: 2, type: "dashed" },
itemStyle: { color: PREDICTION_LINE },
z: 2,
},
]
: [];
// ── markLines: vertical «целевой» + horizontal «баланс» (deficit = 0) ────
// Attached to the first deficit series so they paint once.
const targetLabel = xLabels[horizons.indexOf(targetHorizon)];
const markLineData: Record<string, unknown>[] = [
{
yAxis: 0,
label: {
formatter: "баланс",
position: "insideEndTop",
color: FG_SECONDARY,
fontSize: 11,
},
lineStyle: { color: BORDER_STRONG, type: "solid", width: 1 },
},
];
if (targetLabel != null) {
markLineData.push({
xAxis: targetLabel,
label: {
formatter: "целевой",
color: FG_SECONDARY,
fontSize: 11,
},
lineStyle: { color: BORDER_STRONG, type: "dashed", width: 1 },
});
}
const firstDeficit = deficitSeries[0] as Record<string, unknown>;
firstDeficit.markLine = {
silent: true,
symbol: "none",
data: markLineData,
};
const yDeficit: Record<string, unknown> = {
type: "value",
name: "Индекс дефицита",
position: "left",
min: -1,
max: 1,
interval: 0.5,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "left" },
axisLabel: {
color: FG_SECONDARY,
formatter: (v: number) => fmtNum(v, 1),
},
};
const yAxis: Record<string, unknown>[] = [yDeficit];
if (hasRate) {
yAxis.push({
type: "value",
name: "Ставка, % годовых",
position: "right",
scale: true,
nameTextStyle: { color: FG_SECONDARY, fontSize: 11, align: "right" },
axisLabel: {
color: FG_SECONDARY,
formatter: (v: number) => `${fmtNum(v, 0)} %`,
},
splitLine: { show: false },
});
}
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
// Units MANDATORY: «0.42» for deficit, «14.5 %» for ставка.
formatter: (raw: unknown) => {
const params = Array.isArray(raw) ? (raw as TooltipParam[]) : [];
if (params.length === 0) return "";
const header = params[0]?.axisValueLabel ?? "";
const lines = params
.filter((p) => p.value != null)
.map((p) => {
const v = p.value as number;
const isRate = p.seriesName === "Ставка";
const text = isRate ? `${fmtNum(v, 2)} %` : fmtNum(v, 2);
return `${p.marker ?? ""}${p.seriesName}: <b>${text}</b>`;
});
return [header, ...lines].join("<br/>");
},
textStyle: { fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: FG_SECONDARY, fontSize: 12 },
},
grid: { left: 56, right: hasRate ? 64 : 24, top: 40, bottom: 32 },
xAxis: {
type: "category",
data: xLabels,
boundaryGap: false,
axisLabel: { color: FG_SECONDARY },
axisLine: { lineStyle: { color: BORDER_STRONG } },
},
yAxis,
series: [...deficitSeries, ...rateSeries],
};
}, [
report,
fm,
byScenario,
hasScenarios,
presentScenarios,
targetHorizon,
]);
// Thin reports (no horizons AND no scenarios) → render nothing.
if (!hasHorizons && !hasScenarios) return null;
return <ChartShell option={option} height={300} notMerge />;
}

View file

@ -24,6 +24,7 @@ import { KpiCard } from "@/components/site-finder/KpiCard";
import { useParcelForecastQuery } from "@/lib/site-finder-api";
import type { ForecastReport } from "@/types/forecast";
import { ForecastChart } from "./ForecastChart";
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
@ -291,6 +292,9 @@ function ForecastReady({
</div>
)}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">