gendesign/frontend/src/components/site-finder/analysis/ForecastChart.tsx
Light1YT f7773c71d4
All checks were successful
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 48s
fix(site-finder): P3 a11y/UI batch from audit #1871
- 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
2026-06-23 12:52:32 +05:00

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