fire-planner/frontend/src/components/FanChart.tsx

215 lines
6.2 KiB
TypeScript
Raw Normal View History

/**
* 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);
}