Spaces:
Sleeping
Sleeping
UI v3: fintech-direction design pass (5 grafts), waterfall historical-mode fix, explain-strip placement + shared hook, palette contrast fix
20abf65 verified | 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 ( | |
| <div class="table-scroll"> | |
| <table class="data-table"> | |
| <thead> | |
| <tr> | |
| <th>Component</th> | |
| <th>Allowance ($m)</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rows.map((r) => ( | |
| <tr key={r.label}> | |
| <td>{r.label}</td> | |
| <td class="num-cell">{r.value}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| } | |
| /** | |
| * 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 ( | |
| <Panel | |
| exhibit={exhibit} | |
| title="Allowance bridge" | |
| subtitle={subtitle} | |
| source={source} | |
| dense={false} | |
| explainLabel="Allowance bridge" | |
| actions={ | |
| <button | |
| type="button" | |
| class="icon-btn icon-btn-square" | |
| aria-pressed={view === 'table'} | |
| aria-label="Show as table" | |
| data-tip="Table view" | |
| onClick={() => setView((v) => (v === 'chart' ? 'table' : 'chart'))} | |
| > | |
| β | |
| </button> | |
| } | |
| buildExplainQuestion={() => | |
| explainPanelQuestion({ | |
| panelId: 'waterfall', | |
| params: { t0, t1 }, | |
| exhibitLabel: exhibit != null ? `Exhibit ${exhibit}` : undefined, | |
| title: 'Allowance bridge', | |
| recap: recapFor(wf), | |
| }) | |
| } | |
| > | |
| {error && ( | |
| <div class="empty-note"> | |
| Engine API offline ({error}). Start the FastAPI service on :7860. | |
| </div> | |
| )} | |
| {view === 'chart' ? ( | |
| <div ref={ref} class="chart chart-tall" /> | |
| ) : ( | |
| wf && <WaterfallTable wf={wf} /> | |
| )} | |
| {built?.flooredAny && ( | |
| <p class="panel-sub chart-caveat"> | |
| Components under ~3% of range shown at a minimum visible height β | |
| labeled values are exact. | |
| </p> | |
| )} | |
| <div class="chart-legend-row"> | |
| <span class="legend-item"> | |
| <span class="legend-dot" style={{ background: live.levelFill }} /> Running total | |
| </span> | |
| <span class="legend-item"> | |
| <span class="legend-dot" style={{ background: live.increase }} /> Adds to allowance | |
| </span> | |
| <span class="legend-item"> | |
| <span class="legend-dot" style={{ background: live.decrease }} /> Reduces allowance | |
| </span> | |
| </div> | |
| <StageGuide /> | |
| </Panel> | |
| ); | |
| } | |