fire-planner/frontend/src/components/FanChart.tsx
Viktor Barzin e12e8f9290
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
whatif: live data refresh, inflation-adjusted spending, legend fix
Three follow-ups to the actualbudget integration:

**Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean
gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until
the user types into either field, every refetch (mount, window
focus) updates the form value. Once they edit, we leave it alone. A
small ↻ button next to each anchor input flips the edited flag back
off so the user can re-snap to live data on demand. React Query
configured with staleTime=0 + refetchOnMount='always' +
refetchOnWindowFocus=true so the cache never serves stale numbers.
NW provenance shows the snapshot date.

**Inflation-adjusted spending.** Backend now revalues each trailing
month's nominal pence forward to today's £ using monthly compounding
of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline
`total_gbp` is the real-£ figure — matches the simulator's
real-GBP convention. Response also includes `nominal_total_gbp` and
`inflation_pct` for transparency. New /spending/annual?inflation_pct=
override param. 10/10 actualbudget tests pass.

**FanChart legend.** The bottom-anchored legend was overlapping the
x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for
narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28
so nothing collides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:27:22 +00:00

214 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Monte Carlo fan chart — confidence bands (p10/p25/p75/p90) + median line.
*
* Renders five layered series in ECharts:
* 1. p10 invisible baseline (line, transparent, no fill)
* 2. (p25 - p10) band stacks on p10 with a low-opacity fill
* 3. (p75 - p25) band stacks on top with mid-opacity (the inter-quartile)
* 4. (p90 - p75) band stacks on top with low-opacity (upper tail)
* 5. p50 median standalone solid line, drawn last so it sits above
*
* Stacking the deltas instead of plotting raw quantiles keeps the band
* fills clean — ECharts area renderers fill to the *baseline* by default,
* which would overlap badly if we just plotted p10/p25/p50/p75/p90 lines
* each with their own area.
*/
import { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import type { ProjectionPoint } from '@/api/client';
import { gbpCompact } from '@/lib/format';
interface Props {
yearly: ProjectionPoint[];
height?: number;
showWithdrawal?: boolean;
}
export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) {
const option = useMemo<EChartsOption>(() => buildFan(yearly, showWithdrawal), [
yearly,
showWithdrawal,
]);
if (yearly.length === 0) {
return <p className="text-sm text-slate-500">No projection data.</p>;
}
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
}
function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption {
const years = yearly.map((p) => p.year_idx);
const p10 = yearly.map((p) => num(p.p10_portfolio_gbp));
const p25 = yearly.map((p) => num(p.p25_portfolio_gbp));
const p50 = yearly.map((p) => num(p.p50_portfolio_gbp));
const p75 = yearly.map((p) => num(p.p75_portfolio_gbp));
const p90 = yearly.map((p) => num(p.p90_portfolio_gbp));
const wd = yearly.map((p) => num(p.p50_withdrawal_gbp));
// Deltas — what each band stacks on top of the previous.
const d_25_10 = p25.map((v, i) => Math.max(0, v - p10[i]!));
const d_75_25 = p75.map((v, i) => Math.max(0, v - p25[i]!));
const d_90_75 = p90.map((v, i) => Math.max(0, v - p75[i]!));
const series: EChartsOption['series'] = [
{
name: 'p10',
type: 'line',
stack: 'fan',
data: p10,
lineStyle: { opacity: 0 },
itemStyle: { opacity: 0 },
symbol: 'none',
tooltip: { show: false },
},
{
name: 'p10p25',
type: 'line',
stack: 'fan',
data: d_25_10,
lineStyle: { opacity: 0 },
symbol: 'none',
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
tooltip: { show: false },
},
{
name: 'p25p75 (IQR)',
type: 'line',
stack: 'fan',
data: d_75_25,
lineStyle: { opacity: 0 },
symbol: 'none',
areaStyle: { color: 'rgba(80, 110, 230, 0.28)' },
tooltip: { show: false },
},
{
name: 'p75p90',
type: 'line',
stack: 'fan',
data: d_90_75,
lineStyle: { opacity: 0 },
symbol: 'none',
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
tooltip: { show: false },
},
{
name: 'median',
type: 'line',
data: p50,
lineStyle: { width: 2, color: 'rgb(40, 70, 200)' },
itemStyle: { color: 'rgb(40, 70, 200)' },
symbol: 'none',
smooth: true,
z: 10,
},
{
name: 'p10',
type: 'line',
data: p10,
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
symbol: 'none',
smooth: true,
z: 5,
},
{
name: 'p90',
type: 'line',
data: p90,
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
symbol: 'none',
smooth: true,
z: 5,
},
];
if (showWithdrawal) {
series.push({
name: 'median withdrawal',
type: 'line',
yAxisIndex: 1,
data: wd,
lineStyle: { width: 1, color: 'rgb(190, 90, 60)' },
symbol: 'none',
smooth: true,
});
}
return {
// Grid: leave room above for the legend (top:48) and below for the
// x-axis name label so neither collides with the chart area.
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 48, bottom: 56 },
tooltip: {
trigger: 'axis',
formatter: (params) => {
const arr = Array.isArray(params) ? params : [params];
const year = arr[0]?.name ?? '';
const idx = years.indexOf(Number(year));
if (idx < 0) return '';
return [
`<b>Year ${year}</b>`,
`p10: ${gbpCompact(p10[idx]!)}`,
`p25: ${gbpCompact(p25[idx]!)}`,
`<b>p50: ${gbpCompact(p50[idx]!)}</b>`,
`p75: ${gbpCompact(p75[idx]!)}`,
`p90: ${gbpCompact(p90[idx]!)}`,
showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '',
]
.filter(Boolean)
.join('<br/>');
},
},
legend: {
// Above the chart, centred — out of the way of the x-axis label.
// `type: 'scroll'` lets ECharts paginate items on narrow viewports
// instead of wrapping them into the chart area.
top: 8,
left: 'center',
type: 'scroll',
itemGap: 18,
itemWidth: 14,
itemHeight: 10,
textStyle: { fontSize: 11, color: '#475569' },
data: [
'median',
'p10',
'p90',
'p25p75 (IQR)',
...(showWithdrawal ? ['median withdrawal'] : []),
],
},
xAxis: {
type: 'category',
data: years,
name: 'years from now',
nameLocation: 'middle',
nameGap: 28,
nameTextStyle: { color: '#64748b' },
},
yAxis: [
{
type: 'value',
name: 'portfolio (real GBP)',
nameLocation: 'middle',
nameGap: 50,
axisLabel: { formatter: (v: number) => gbpCompact(v) },
},
...(showWithdrawal
? [
{
type: 'value' as const,
name: 'withdrawal',
nameLocation: 'middle' as const,
nameGap: 50,
axisLabel: { formatter: (v: number) => gbpCompact(v) },
},
]
: []),
],
series,
};
}
function num(s: string): number {
return Number(s);
}