"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 = { 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 = { 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(); 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(); 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(); 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>(() => { 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[] = 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[] = 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[] = [ { 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; firstDeficit.markLine = { silent: true, symbol: "none", data: markLineData, }; const yDeficit: Record = { 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[] = [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}: ${text}`; }); return [header, ...lines].join("
"); }, 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 ; }