fire-planner/frontend/src/components/FanChart.tsx
Viktor Barzin 9cc781a8d6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
income streams, Sankey cashflow, progress overlay, settings sub-pages

Wave 1 (9 features across 4 streams):

Stream A — dashboard skeleton
  1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
        Reports/Estate/Settings) + left Sidebar with Plans switcher.
  1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
        (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
        investment growth). YearScrubber + YearStatsPanel render the
        right-hand sidebar; URL ?year= preserves selection.
  1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
        life_event.kind → emoji) + selectedYear marker line.

Stream B — goals + progress
  1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
        target_real_income probability evaluation. Wired into POST
        /simulate (exact, per-path) and GET /scenarios/{id}/projection
        (approximated from persisted fan via percentile interpolation).
        GoalsSection renders pass/fail badges.
  1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
        the projection fan; ProgressPage shows variance side-panel.

Stream C — income + cashflow
  1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
        aggregates streams into per-year inflows + taxable arrays;
        income tax routes through the jurisdiction tax engine.
        IncomeStreamsSection on Plan tab.
  1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
        an ECharts Sankey (sums conserve). CashflowTab body.

Stream D — settings
  1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
        Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
  1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
  1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
        cards). SimulateRequest gains rates_mode, inflation_pct,
        stocks/bonds growth + dividend, stocks_allocation. New
        build_fixed_paths() in simulator. Real-return arithmetic
        verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
  1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
        scenario.config_json.notes.

Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.

Roadmap for Wave 2-N is documented in the implementation plan.
2026-05-10 12:49:44 +00:00

290 lines
8 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';
import type { Milestone } from '@/lib/milestone';
interface Props {
yearly: ProjectionPoint[];
height?: number;
showWithdrawal?: boolean;
milestones?: Milestone[];
selectedYear?: number | null;
onSelectYear?: (year: number) => void;
}
export function FanChart({
yearly,
height = 360,
showWithdrawal = false,
milestones,
selectedYear,
onSelectYear,
}: Props) {
const option = useMemo<EChartsOption>(
() => buildFan(yearly, showWithdrawal, milestones, selectedYear),
[yearly, showWithdrawal, milestones, selectedYear],
);
if (yearly.length === 0) {
return <p className="text-sm text-slate-500">No projection data.</p>;
}
const handlers = onSelectYear
? {
click: (params: { name?: string }) => {
const year = Number(params.name);
if (!Number.isNaN(year)) onSelectYear(year);
},
}
: undefined;
return (
<ReactECharts
option={option}
style={{ height, width: '100%' }}
notMerge
lazyUpdate
onEvents={handlers}
/>
);
}
function buildFan(
yearly: ProjectionPoint[],
showWithdrawal: boolean,
milestones?: Milestone[],
selectedYear?: number | null,
): 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 (milestones && milestones.length > 0) {
series.push({
name: 'milestones',
type: 'scatter',
data: milestones
.filter((m) => m.year_idx >= 0 && m.year_idx < yearly.length)
.map((m) => ({
name: m.label,
value: [m.year_idx, p50[m.year_idx] ?? 0],
symbol: `text:${m.emoji}`,
symbolSize: 26,
label: { show: true, formatter: m.emoji, fontSize: 18 },
tooltip: {
formatter: () =>
[
`<b>${m.label}</b>`,
`year ${m.year_idx}`,
m.delta_gbp ? `Δ ${m.delta_gbp}` : '',
]
.filter(Boolean)
.join('<br/>'),
},
})),
symbol: 'circle',
symbolSize: 24,
itemStyle: { color: '#f59e0b' },
z: 20,
});
}
if (selectedYear != null && selectedYear >= 0 && selectedYear < yearly.length) {
series.push({
name: 'selected',
type: 'line',
data: [],
markLine: {
symbol: 'none',
silent: true,
lineStyle: { color: 'rgba(15, 23, 42, 0.6)', width: 2, type: 'solid' },
label: { show: false },
data: [{ xAxis: selectedYear }],
},
z: 30,
});
}
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);
}