From 2fd13d3cd389b04c5cf4ac63ca90fcb8d9c4c6a6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 07:18:52 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecast-ui):=20confidence=20band=20=D0=BD?= =?UTF-8?q?=D0=B0=20ForecastChart=20=E2=80=94=20=D0=BA=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9,=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8E=20=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(#1926?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В §22-payload нет численных квантилей (p25/p75) — только качественный confidence per-горизонт. Band строится полушириной из уровня уверенности (high +-0.10 / medium +-0.20 / low +-0.32) вокруг фокусной линии дефицита (base-сценарий, иначе single fallback), ECharts stack + stackStrategy 'all' (иначе отрицательная нижняя граница не стакается), кламп к [-1,1]. Легенда и тултип явно подписывают интервал как качественный, не p25/p75 — без выдуманных квантилей. Линии прогноза переведены на пунктир (ui-conventions: prediction = dashed + band). Пункты 2 (velocity proxy-disclosure) и 3 (h1) из #1926 уже были в main. --- .../site-finder/analysis/ForecastChart.tsx | 164 +++++++++++++++++- .../forecast-helpers-confidenceBand.test.ts | 45 +++++ .../site-finder/analysis/forecast-helpers.ts | 21 +++ 3 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-confidenceBand.test.ts diff --git a/frontend/src/components/site-finder/analysis/ForecastChart.tsx b/frontend/src/components/site-finder/analysis/ForecastChart.tsx index cca92c05..3cdff175 100644 --- a/frontend/src/components/site-finder/analysis/ForecastChart.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastChart.tsx @@ -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(); + 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(); + 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[] = 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[] = 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}: ${text}`; }); - return [header, ...lines].join("
"); + // 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( + "
", + ); }, 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, diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-confidenceBand.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-confidenceBand.test.ts new file mode 100644 index 00000000..b58d1a0f --- /dev/null +++ b/frontend/src/components/site-finder/analysis/__tests__/forecast-helpers-confidenceBand.test.ts @@ -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); + } + }); +}); diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts index 00ab8091..4cc39879 100644 --- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts +++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts @@ -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 = { + 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) ─────────────── /** -- 2.45.3