gendesign/frontend/src/components/site-finder/analysis/ForecastChart.tsx
Light1YT a66a554462
All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m51s
Deploy / deploy (push) Successful in 1m19s
feat(site-finder): forecast deficit-by-horizon chart in Section 6 (#998)
ECharts (via ChartShell, SSR-safe) chart of deficit_index across horizons per
scenario (база/агр/конс, --viz-1..3) + dashed rate-path on a secondary axis +
баланс/целевой markLines, tooltip with units. Renders above the 6.1 table;
returns null on thin reports. Makes the §22 forecast signal glanceable for demos.
2026-06-07 14:19:17 +05:00

303 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 />;
}