feat(forecast-ui): confidence band на ForecastChart — качественный интервал уверенности (#1926) #2368
3 changed files with 223 additions and 7 deletions
|
|
@ -33,7 +33,11 @@ import type {
|
|||
ScenarioKey,
|
||||
} from "@/types/forecast";
|
||||
|
||||
import { fmtNum } from "./forecast-helpers";
|
||||
import {
|
||||
CONFIDENCE_RU,
|
||||
confidenceBandHalfWidth,
|
||||
fmtNum,
|
||||
} from "./forecast-helpers";
|
||||
|
||||
interface Props {
|
||||
report: ForecastReport;
|
||||
|
|
@ -68,9 +72,18 @@ const SINGLE_LINE = "#374151";
|
|||
// 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 PREDICTION_BAND = "rgba(14, 165, 233, 0.18)"; // --prediction-band
|
||||
const FG_SECONDARY = "#5B6066"; // --fg-secondary
|
||||
const BORDER_STRONG = "#D1D5DB"; // --border-strong
|
||||
|
||||
// #1926 — deficit axis bounds; band lower/upper are clamped to these so a wide
|
||||
// low-confidence interval never paints past the scale.
|
||||
const DEFICIT_MIN = -1;
|
||||
const DEFICIT_MAX = 1;
|
||||
|
||||
const clampDeficit = (v: number): number =>
|
||||
Math.max(DEFICIT_MIN, Math.min(DEFICIT_MAX, v));
|
||||
|
||||
// ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ──
|
||||
interface TooltipParam {
|
||||
seriesName?: string;
|
||||
|
|
@ -78,6 +91,7 @@ interface TooltipParam {
|
|||
axisValueLabel?: string;
|
||||
marker?: string;
|
||||
seriesType?: string;
|
||||
dataIndex?: number;
|
||||
}
|
||||
|
||||
function sortedUniqueHorizons(report: ForecastReport): number[] {
|
||||
|
|
@ -111,6 +125,60 @@ function rateByHorizon(
|
|||
return horizons.map((h) => byH.get(h) ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1926 — qualitative confidence band around a deficit line, aligned to the
|
||||
* horizon axis. The §22 payload carries NO numeric quantiles (p25/p75) — only a
|
||||
* per-horizon `confidence` level — so the band width is derived from that level
|
||||
* (lower confidence → wider), NOT from fabricated quantiles. Rendered as two
|
||||
* stacked series (lower bound + width) so ECharts fills between them; the caller
|
||||
* uses `stackStrategy: "all"` so a negative lower bound + positive width stack
|
||||
* correctly on the signed [−1, 1] axis.
|
||||
*
|
||||
* Returns `{ lower, width }` aligned to `horizons` (null where the deficit or its
|
||||
* confidence is missing — the band is simply absent at that horizon, never faked).
|
||||
*/
|
||||
interface DeficitBand {
|
||||
lower: (number | null)[];
|
||||
width: (number | null)[];
|
||||
}
|
||||
|
||||
function bandByHorizon(
|
||||
forecasts: DemandSupplyForecast[],
|
||||
horizons: number[],
|
||||
): DeficitBand {
|
||||
const byH = new Map<number, DemandSupplyForecast>();
|
||||
for (const f of forecasts) byH.set(f.horizon_months, f);
|
||||
const lower: (number | null)[] = [];
|
||||
const width: (number | null)[] = [];
|
||||
for (const h of horizons) {
|
||||
const f = byH.get(h);
|
||||
if (f == null || f.deficit_index == null) {
|
||||
lower.push(null);
|
||||
width.push(null);
|
||||
continue;
|
||||
}
|
||||
const half = confidenceBandHalfWidth(f.confidence);
|
||||
const lo = clampDeficit(f.deficit_index - half);
|
||||
const hi = clampDeficit(f.deficit_index + half);
|
||||
lower.push(lo);
|
||||
width.push(hi - lo);
|
||||
}
|
||||
return { lower, width };
|
||||
}
|
||||
|
||||
/** Per-horizon confidence level for a forecast list (for the band tooltip). */
|
||||
function confidenceByHorizon(
|
||||
forecasts: DemandSupplyForecast[],
|
||||
horizons: number[],
|
||||
): (string | null)[] {
|
||||
const byH = new Map<number, DemandSupplyForecast>();
|
||||
for (const f of forecasts) byH.set(f.horizon_months, f);
|
||||
return horizons.map((h) => {
|
||||
const f = byH.get(h);
|
||||
return f == null ? null : CONFIDENCE_RU[f.confidence];
|
||||
});
|
||||
}
|
||||
|
||||
// #1958 — близость к ±1 трактуем как clamp-floor: backend подрезает deficit_index
|
||||
// к [−1,1], и вырожденный (недифференцированный) прогноз садится на границу.
|
||||
const DEGENERATE_CLAMP_EPS = 0.02;
|
||||
|
|
@ -166,7 +234,8 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
const xLabels = horizons.map((h) => `${h} мес`);
|
||||
|
||||
// ── Deficit series ──────────────────────────────────────────────────────
|
||||
// Per-scenario lines when available, else a single fallback line.
|
||||
// Per-scenario lines when available, else a single fallback line. Forecast →
|
||||
// dashed line per ui-conventions (prediction never a solid point-line).
|
||||
const deficitSeries: Record<string, unknown>[] = hasScenarios
|
||||
? effectiveScenarios.map((key) => ({
|
||||
name: SCENARIO_RU[key],
|
||||
|
|
@ -177,7 +246,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
symbolSize: 6,
|
||||
smooth: false,
|
||||
connectNulls: true,
|
||||
lineStyle: { color: VIZ[key], width: 2 },
|
||||
lineStyle: { color: VIZ[key], width: 2, type: "dashed" },
|
||||
itemStyle: { color: VIZ[key] },
|
||||
emphasis: { focus: "series" },
|
||||
z: 3,
|
||||
|
|
@ -192,12 +261,68 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
symbolSize: 6,
|
||||
smooth: false,
|
||||
connectNulls: true,
|
||||
lineStyle: { color: SINGLE_LINE, width: 2 },
|
||||
lineStyle: { color: SINGLE_LINE, width: 2, type: "dashed" },
|
||||
itemStyle: { color: SINGLE_LINE },
|
||||
z: 3,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Confidence band (#1926) ──────────────────────────────────────────────
|
||||
// Qualitative interval around the FOCUSED deficit line (base scenario, else
|
||||
// the single fallback line) — banding all 3 scenario lines clutters. Width
|
||||
// reflects the per-horizon confidence level (low → wider), NOT fabricated
|
||||
// p25/p75; labelled «качественный» in legend + tooltip. Two stacked series
|
||||
// (transparent lower bound + filled width) with `stackStrategy: "all"` so the
|
||||
// fill sits between lower and upper on the signed [−1, 1] axis.
|
||||
const bandKey: ScenarioKey | null = hasScenarios
|
||||
? (byScenario.base?.forecasts.length ?? 0) > 0
|
||||
? "base"
|
||||
: effectiveScenarios[0]
|
||||
: null;
|
||||
const bandSource: DemandSupplyForecast[] =
|
||||
bandKey != null
|
||||
? (byScenario[bandKey]?.forecasts ?? [])
|
||||
: fm.forecasts_by_horizon;
|
||||
const band = bandByHorizon(bandSource, horizons);
|
||||
const bandConf = confidenceByHorizon(bandSource, horizons);
|
||||
const hasBand = band.width.some((v) => v != null && v > 0);
|
||||
const BAND_STACK = "deficit-band";
|
||||
|
||||
const bandSeries: Record<string, unknown>[] = hasBand
|
||||
? [
|
||||
{
|
||||
// Lower bound — invisible line, just the stack base for the fill.
|
||||
name: "band-lower",
|
||||
type: "line",
|
||||
yAxisIndex: 0,
|
||||
data: band.lower,
|
||||
stack: BAND_STACK,
|
||||
stackStrategy: "all",
|
||||
symbol: "none",
|
||||
lineStyle: { opacity: 0 },
|
||||
areaStyle: { opacity: 0 },
|
||||
silent: true,
|
||||
// Excluded from legend/tooltip (chrome-only helper series).
|
||||
tooltip: { show: false },
|
||||
z: 1,
|
||||
},
|
||||
{
|
||||
// Width (upper − lower) — the visible filled band.
|
||||
name: "Интервал уверенности (качественный)",
|
||||
type: "line",
|
||||
yAxisIndex: 0,
|
||||
data: band.width,
|
||||
stack: BAND_STACK,
|
||||
stackStrategy: "all",
|
||||
symbol: "none",
|
||||
lineStyle: { opacity: 0 },
|
||||
areaStyle: { color: PREDICTION_BAND },
|
||||
silent: true,
|
||||
z: 1,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// ── Rate path (secondary axis, dashed = prediction) ─────────────────────
|
||||
// One line only (base scenario preferred, else fallback forecast) — three
|
||||
// rate lines clutter the deficit story.
|
||||
|
|
@ -303,6 +428,11 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
// Helper band series are excluded from the tooltip body; instead the band's
|
||||
// per-horizon confidence level is appended as a qualitative note (#1926).
|
||||
const BAND_WIDTH_NAME = "Интервал уверенности (качественный)";
|
||||
const BAND_SERIES_NAMES = new Set(["band-lower", BAND_WIDTH_NAME]);
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
|
|
@ -313,19 +443,38 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
if (params.length === 0) return "";
|
||||
const header = params[0]?.axisValueLabel ?? "";
|
||||
const lines = params
|
||||
.filter((p) => p.value != null)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.value != null && !BAND_SERIES_NAMES.has(p.seriesName ?? ""),
|
||||
)
|
||||
.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/>");
|
||||
// Qualitative confidence note for the band (no fabricated quantiles):
|
||||
// interval width reflects the horizon's confidence level.
|
||||
const idx = params[0]?.dataIndex;
|
||||
const conf =
|
||||
hasBand && typeof idx === "number" ? bandConf[idx] : null;
|
||||
const note =
|
||||
conf != null
|
||||
? `Интервал: уверенность ${conf} (качественный, не p25/p75)`
|
||||
: null;
|
||||
return [header, ...lines, ...(note != null ? [note] : [])].join(
|
||||
"<br/>",
|
||||
);
|
||||
},
|
||||
textStyle: { fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
// The invisible lower-bound helper series is hidden from the legend; the
|
||||
// filled «Интервал уверенности (качественный)» stays as the band key.
|
||||
data: [...deficitSeries, ...rateSeries, ...bandSeries]
|
||||
.map((s) => (s as { name?: string }).name)
|
||||
.filter((n): n is string => n != null && n !== "band-lower"),
|
||||
textStyle: { color: FG_SECONDARY, fontSize: 12 },
|
||||
},
|
||||
grid: { left: 56, right: hasRate ? 64 : 24, top: 40, bottom: 32 },
|
||||
|
|
@ -337,7 +486,8 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
axisLine: { lineStyle: { color: BORDER_STRONG } },
|
||||
},
|
||||
yAxis,
|
||||
series: [...deficitSeries, ...rateSeries],
|
||||
// Band first (paints under), then deficit lines, then the rate path.
|
||||
series: [...bandSeries, ...deficitSeries, ...rateSeries],
|
||||
};
|
||||
}, [
|
||||
report,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Tests for `confidenceBandHalfWidth` — issue #1926 (qualitative confidence band).
|
||||
*
|
||||
* The §22 forecast payload carries NO numeric quantiles (p25/p75) — only a
|
||||
* per-horizon qualitative confidence level. ui-conventions forbids showing a
|
||||
* prediction without an interval, so ForecastChart draws a band whose width is
|
||||
* derived from the confidence level: LOWER confidence → WIDER band. This pins
|
||||
* both the ordering (low > medium > high) and that the half-width stays within
|
||||
* the deficit axis span so a low-confidence band can still be clamped to [−1, 1].
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CONFIDENCE_BAND_HALFWIDTH,
|
||||
confidenceBandHalfWidth,
|
||||
} from "../forecast-helpers";
|
||||
|
||||
describe("confidenceBandHalfWidth — #1926 qualitative band width", () => {
|
||||
it("returns the mapped half-width per level", () => {
|
||||
expect(confidenceBandHalfWidth("high")).toBe(
|
||||
CONFIDENCE_BAND_HALFWIDTH.high,
|
||||
);
|
||||
expect(confidenceBandHalfWidth("medium")).toBe(
|
||||
CONFIDENCE_BAND_HALFWIDTH.medium,
|
||||
);
|
||||
expect(confidenceBandHalfWidth("low")).toBe(CONFIDENCE_BAND_HALFWIDTH.low);
|
||||
});
|
||||
|
||||
it("lower confidence → wider band (low > medium > high)", () => {
|
||||
expect(confidenceBandHalfWidth("low")).toBeGreaterThan(
|
||||
confidenceBandHalfWidth("medium"),
|
||||
);
|
||||
expect(confidenceBandHalfWidth("medium")).toBeGreaterThan(
|
||||
confidenceBandHalfWidth("high"),
|
||||
);
|
||||
});
|
||||
|
||||
it("every half-width is positive and fits within the deficit span (< 1)", () => {
|
||||
for (const level of ["high", "medium", "low"] as const) {
|
||||
const half = confidenceBandHalfWidth(level);
|
||||
expect(half).toBeGreaterThan(0);
|
||||
expect(half).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -190,6 +190,27 @@ export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
|
|||
return "danger";
|
||||
}
|
||||
|
||||
/**
|
||||
* #1926 — качественная полуширина confidence-band для deficit-графика (ось [−1, 1]).
|
||||
*
|
||||
* В §22-payload НЕТ численных квантилей (p25/p75) — только качественный уровень
|
||||
* уверенности per-горизонт (`DemandSupplyForecast.confidence` ∈ high|medium|low).
|
||||
* ui-conventions требует показывать прогноз с интервалом (точка без диапазона
|
||||
* «слишком уверенно»). Здесь строим band, ширина которого ОТРАЖАЕТ уверенность,
|
||||
* а НЕ выдуманные квантили: ниже уверенность → шире band. Значения — доли шкалы
|
||||
* дефицита (span 2): high ±0.10, medium ±0.20, low ±0.32. Band подписан в
|
||||
* легенде/тултипе как качественный (по уровню уверенности), не как p25/p75.
|
||||
*/
|
||||
export const CONFIDENCE_BAND_HALFWIDTH: Record<ConfidenceLevel, number> = {
|
||||
high: 0.1,
|
||||
medium: 0.2,
|
||||
low: 0.32,
|
||||
};
|
||||
|
||||
export function confidenceBandHalfWidth(level: ConfidenceLevel): number {
|
||||
return CONFIDENCE_BAND_HALFWIDTH[level];
|
||||
}
|
||||
|
||||
// ── Scoring (§13.6 — product scores #985 + special indices #986) ───────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue