- add <h1> page title to analysis page (иерархия стартовала с h2 — a11y) - emoji → Lucide во всех вердикт/баннер/статус UI (ui-conventions: emoji запрещены): GateVerdictBanner (VerdictColor.icon string→ComponentType, sourceHint→ReactNode, SectionLabels), + найдены ещё 3 файла: OverviewTab (⚠ ЛЭП/трамвай), GeotechRiskBlock (🟡 вечная мерзлота → Snowflake, ⚠ загрязнение), HydrologyBlock (⚠ паводок, 🏞💦🪷🚣💧 subtype-map → Waves/Droplet). Все иконки декоративные → aria-hidden, семантика дублируется текстом. Семантические цвета через var(--success/--warn/--danger) + hex-fallback. - расшифровка аббревиатур при первом упоминании (ui-microcopy): Section2 «строительство в охранной зоне (ОЗ)... зоне с особыми условиями использования территорий (ЗОУИТ), тип 5»; Section4 title-tooltip для ЗОУИТ. - ForecastChart: axis-chrome hex (:65/67/68) задокументирован как canvas-renderer исключение (echarts canvas не резолвит CSS var() — var() сломал бы chrome, как и VIZ-series). Цвета не тронуты, только комментарий. НЕ в scope: ForecastChart CI-band (±p25/p75 — backend не отдаёт, отдельная задача); ★ в WeightProfilePanel <option> (SVG в option невозможен); токенизация legacy-hex в Geotech/Hydrology (отдельный pass). 138 frontend vitest passed, tsc/lint clean. lucide-react уже в deps. Refs #1871
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
"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";
|
||
// Chrome colours below are intentionally raw hex, NOT var(--token), for the same
|
||
// reason as VIZ above: ChartShell renders via echarts-for-react with the default
|
||
// CANVAS renderer (no opts.renderer:"svg"), and the canvas 2D context does not
|
||
// resolve CSS custom properties — `var(--x)` would paint as transparent/black.
|
||
// Values mirror ui-tokens.md exactly: --prediction-line, --fg-secondary,
|
||
// --border-strong. Keep in sync with ui-tokens.md by hand.
|
||
const PREDICTION_LINE = "#0EA5E9"; // --prediction-line
|
||
const FG_SECONDARY = "#5B6066"; // --fg-secondary
|
||
const BORDER_STRONG = "#D1D5DB"; // --border-strong
|
||
|
||
// ── 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,
|
||
);
|
||
// #1871 P1 — when backend collapses 3 scenarios into one (failed §9.6 β
|
||
// rate-sensitivity gate), conservative/base/aggressive deficit_index lines
|
||
// are identical and just stack on top of each other. Render the base line
|
||
// only — the caveat above (Section6Forecast) explains why. Band / rate-line
|
||
// / markLine remain untouched (still derived from the base / fallback row).
|
||
const collapsed = report.scenarios.scenarios_collapsed === true;
|
||
const effectiveScenarios: ScenarioKey[] = collapsed
|
||
? presentScenarios.filter((k) => k === "base")
|
||
: presentScenarios;
|
||
const hasScenarios = effectiveScenarios.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
|
||
? effectiveScenarios.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[effectiveScenarios[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,
|
||
effectiveScenarios,
|
||
// `collapsed` intentionally omitted: it's already encoded in
|
||
// `effectiveScenarios` (collapse filters the array down to ["base"]), and
|
||
// exhaustive-deps flags it as an unnecessary dep when included separately.
|
||
targetHorizon,
|
||
]);
|
||
|
||
// Thin reports (no horizons AND no scenarios) → render nothing.
|
||
if (!hasHorizons && !hasScenarios) return null;
|
||
|
||
return <ChartShell option={option} height={300} notMerge />;
|
||
}
|