frontend: What-If page with fan chart driven by /simulate
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
New /what-if route. Sticky form on the left (jurisdiction, strategy, glide, NW seed, spending, savings, horizon, optional floor for vpw_floor, MC paths) submits to POST /simulate; results panel renders summary stats + the new FanChart. FanChart component layers seven series: - p10 invisible baseline (line, transparent) - p10→p25 stacked area (low opacity) - p25→p75 stacked area (IQR, mid opacity) - p75→p90 stacked area (low opacity) - p50 solid median line (drawn last, prominent) - p10 + p90 dashed lines on top of the bands Stacking deltas keeps the band fills clean — plotting raw quantiles each as their own area would overlap badly. Reusable by scenario detail in the next chunk (same ProjectionPoint[] shape). 5 tests pass (was 2). 470 KB gzipped (ECharts). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5d2b9e931a
commit
bb74bc0add
4 changed files with 550 additions and 4 deletions
202
frontend/src/components/FanChart.tsx
Normal file
202
frontend/src/components/FanChart.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* 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: '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: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 },
|
||||
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: {
|
||||
bottom: 0,
|
||||
data: [
|
||||
'median',
|
||||
'p10',
|
||||
'p90',
|
||||
'p25–p75 (IQR)',
|
||||
...(showWithdrawal ? ['median withdrawal'] : []),
|
||||
],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years,
|
||||
name: 'years from now',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 24,
|
||||
},
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue