import { useEffect, useMemo, useState } from 'preact/hooks';
import { adaptWaterfallRows, explainPanelQuestion, getWaterfall } from '../api.js';
import { useECharts, useThemeVersion } from '../charts/useECharts.js';
import { buildWaterfallOption } from '../charts/waterfallOption.js';
import { colors } from '../palette.js';
import { fmtMillions, fmtSignedM, runDate } from '../format.js';
import Panel from './Panel.jsx';
import StageGuide from './StageGuide.jsx';
const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function recapFor(wf) {
if (!wf) return 'no data rendered yet';
const steps = wf.steps.map((s) => `${s.label} ${fmtSignedM(s.delta_m)}`).join(', ');
return `opening ${fmtMillions(wf.start.value_m)}, ${steps}, closing ${fmtMillions(wf.end.value_m)}.`;
}
function WaterfallTable({ wf }) {
const rows = [
{ label: wf.start.label, value: fmtMillions(wf.start.value_m) },
...wf.steps.map((s) => ({ label: s.label, value: fmtSignedM(s.delta_m) })),
{ label: wf.end.label, value: fmtMillions(wf.end.value_m) },
];
return (
);
}
/**
* ECharts waterfall. Three modes, resolved from `action = {tool, label, result}`:
* - shock_macro: result.waterfall_vs_baseline (baseline vs shocked allowance);
* - decompose_waterfall: result.components (its own custom t0/t1 window);
* - anything else (reweight_scenarios, rerun_ecl) or no action yet:
* the fixed historical movement between two panel snapshots (t0/t1 props).
*
* Historical-mode bug fix (FINAL_SPEC §0.2): the raw GET /api/ecl/waterfall
* payload is `{components: [...], period_t0, period_t1, ...}`, NOT the
* `{start, steps, end}` shape buildWaterfallOption expects — it must go
* through the same adaptWaterfallRows() adapter as the tool-result modes,
* or the default (historical) view renders empty.
*/
export default function WaterfallChart({ t0 = 59, t1 = 60, action, exhibit }) {
const [histRaw, setHistRaw] = useState(null);
const [error, setError] = useState(null);
const [view, setView] = useState('chart');
useEffect(() => {
let alive = true;
getWaterfall(t0, t1)
.then((d) => alive && (setHistRaw(d), setError(null)))
.catch((e) => alive && setError(e.message));
return () => {
alive = false;
};
}, [t0, t1]);
const r = action?.result;
const shockRows = action?.tool === 'shock_macro' ? r?.waterfall_vs_baseline : null;
const decomposeRows = action?.tool === 'decompose_waterfall' ? r?.components : null;
// Historical mode wraps the SAME raw payload shape through the adapter —
// this is the fix: `hist` used to be the raw {components,...} object.
const hist = useMemo(
() => (histRaw ? adaptWaterfallRows(histRaw.components, 'Opening allowance', 'Closing allowance') : null),
[histRaw],
);
const wf = useMemo(() => {
if (shockRows) return adaptWaterfallRows(shockRows, 'Baseline allowance', 'Shocked allowance');
if (decomposeRows) return adaptWaterfallRows(decomposeRows, 'Opening allowance', 'Closing allowance');
return hist;
}, [shockRows, decomposeRows, hist]);
const themeV = useThemeVersion();
const built = useMemo(
() => (wf ? buildWaterfallOption(wf, { reducedMotion: prefersReducedMotion() }) : null),
[wf, themeV],
);
const ref = useECharts(built?.option ?? null);
const live = colors();
const subtitle = shockRows
? `Effect of ${action.label} vs the baseline scenario — every bar is an engine number.`
: decomposeRows
? `Movement decomposition ${r.period_t0} → ${r.period_t1} — every bar is an engine number.`
: `Historical movement between panel snapshots ${histRaw?.period_t0 ?? '2014Q4'} → ${
histRaw?.period_t1 ?? '2015Q1'
} (t=${t0} → t=${t1}; fixed exhibit — scenario controls act on the reported allowance above, not on this history).`;
const source = shockRows
? { endpoint: 'POST /api/tools/shock_macro', runDate: runDate() }
: decomposeRows
? { endpoint: 'POST /api/tools/decompose_waterfall', runDate: runDate() }
: { endpoint: `GET /api/ecl/waterfall?t0=${t0}&t1=${t1}`, runDate: runDate() };
return (
setView((v) => (v === 'chart' ? 'table' : 'chart'))}
>
⊞
}
buildExplainQuestion={() =>
explainPanelQuestion({
panelId: 'waterfall',
params: { t0, t1 },
exhibitLabel: exhibit != null ? `Exhibit ${exhibit}` : undefined,
title: 'Allowance bridge',
recap: recapFor(wf),
})
}
>
{error && (
Engine API offline ({error}). Start the FastAPI service on :7860.
)}
{view === 'chart' ? (
) : (
wf &&
)}
{built?.flooredAny && (
Components under ~3% of range shown at a minimum visible height —
labeled values are exact.
)}
Running total
Adds to allowance
Reduces allowance
);
}