From a66a55446254b43c5aecc34f1cfb2cf0da30c18b Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 14:19:17 +0500 Subject: [PATCH] feat(site-finder): forecast deficit-by-horizon chart in Section 6 (#998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../site-finder/analysis/ForecastChart.tsx | 303 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 4 + 2 files changed, 307 insertions(+) create mode 100644 frontend/src/components/site-finder/analysis/ForecastChart.tsx diff --git a/frontend/src/components/site-finder/analysis/ForecastChart.tsx b/frontend/src/components/site-finder/analysis/ForecastChart.tsx new file mode 100644 index 00000000..4cdd7efc --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastChart.tsx @@ -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 = { + 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 ; +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 29a583e7..063c61d6 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -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({ )} + {/* Траектория индекса дефицита (chart-then-table, выше 6.1) */} + + {/* 6.1 Прогноз по горизонтам */} {hasHorizons && (