feat(site-finder): forecast deficit-by-horizon chart in Section 6 (#998)
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
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
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.
This commit is contained in:
parent
40b1f3ad69
commit
a66a554462
2 changed files with 307 additions and 0 deletions
303
frontend/src/components/site-finder/analysis/ForecastChart.tsx
Normal file
303
frontend/src/components/site-finder/analysis/ForecastChart.tsx
Normal file
|
|
@ -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<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 />;
|
||||
}
|
||||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
|
||||
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
||||
|
||||
{/* 6.1 Прогноз по горизонтам */}
|
||||
{hasHorizons && (
|
||||
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue