/** * 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(() => buildFan(yearly, showWithdrawal), [ yearly, showWithdrawal, ]); if (yearly.length === 0) { return

No projection data.

; } return ; } 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: 'p10–p25', 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: 'p25–p75 (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: 'p75–p90', 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 [ `Year ${year}`, `p10: ${gbpCompact(p10[idx]!)}`, `p25: ${gbpCompact(p25[idx]!)}`, `p50: ${gbpCompact(p50[idx]!)}`, `p75: ${gbpCompact(p75[idx]!)}`, `p90: ${gbpCompact(p90[idx]!)}`, showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '', ] .filter(Boolean) .join('
'); }, }, 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', 'p25–p75 (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); }