tootip and graph style fixes
Some checks failed
Lint / Lint and Typecheck (push) Failing after 45s

This commit is contained in:
2025-12-06 22:58:10 +01:00
parent 4aa961fc1c
commit 35bc31fb3d

View File

@@ -11,7 +11,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { import {
Area, Area,
AreaChart, AreaChart,
@@ -24,7 +29,7 @@ import {
} from 'recharts'; } from 'recharts';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; import type { NameType, Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { Calculator, Info, Share2, Check } from 'lucide-react'; import { Calculator, Info, Share2, Check } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import BlurThing from './blur-thing'; import BlurThing from './blur-thing';
@@ -87,20 +92,57 @@ const formatNumber = (value: number | null) => {
}).format(value); }).format(value);
}; };
// Helper function to render tooltip for chart const formatNumberShort = (value: number) => {
const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>) => { if (value >= 1000000) {
if (active && payload?.[0]?.payload) { return `${(value / 1000000).toPrecision(3)}M`;
const data = payload[0].payload as YearlyData; } else if (value >= 1000) {
return ( return `${(value / 1000).toPrecision(3)}K`;
<div className="bg-background border p-2 shadow-sm"> } else if (value <= -1000000) {
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p> return `${(value / 1000000).toPrecision(3)}M`;
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50 ?? data.balance)}`}</p> } else if (value <= -1000) {
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p> return `${(value / 1000).toPrecision(3)}K`;
<p>{`Phase: ${data.phase === 'accumulation' ? 'Accumulation' : 'Retirement'}`}</p>
</div>
);
} }
return null; return value.toString();
};
// Chart tooltip with the same styling as ChartTooltipContent, but with our custom label info
const tooltipRenderer = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
const allowedKeys = new Set(['balance', 'monthlyAllowance']);
const filteredPayload: Payload<ValueType, NameType>[] = (payload ?? [])
.filter(
(item): item is Payload<ValueType, NameType> =>
typeof item.dataKey === 'string' && allowedKeys.has(item.dataKey),
)
.map((item) => ({
...item,
value: formatNumberShort(item.value as number),
}));
const safeLabel = typeof label === 'string' || typeof label === 'number' ? label : undefined;
return (
<ChartTooltipContent
active={active}
payload={filteredPayload}
label={safeLabel}
indicator="line"
className="min-w-48"
labelFormatter={(_, items: Payload<ValueType, NameType>[]) => {
const point = items.length > 0 ? (items[0]?.payload as YearlyData | undefined) : undefined;
if (!point) {
return null;
}
const phaseLabel = point.phase === 'retirement' ? 'Retirement phase' : 'Accumulation phase';
return (
<div className="flex flex-col gap-0.5">
<span>{`Year ${String(point.year)} (Age ${String(point.age)})`}</span>
<span className="text-muted-foreground">{phaseLabel}</span>
</div>
);
}}
/>
);
}; };
export default function FireCalculatorForm({ export default function FireCalculatorForm({
@@ -402,6 +444,28 @@ export default function FireCalculatorForm({
mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0), mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0),
})) ?? []; })) ?? [];
const projectionChartConfig: ChartConfig = {
year: {
label: 'Year',
},
balance: {
label: 'Balance',
color: 'var(--color-orange-500)',
},
balanceP10: {
label: 'P10 balance',
color: 'var(--color-orange-500)',
},
balanceP90: {
label: 'P90 balance',
color: 'var(--color-orange-500)',
},
monthlyAllowance: {
label: 'Monthly allowance',
color: 'var(--color-secondary)',
},
};
return ( return (
<> <>
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur"> <Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
@@ -857,7 +921,7 @@ export default function FireCalculatorForm({
Shaded band shows 40th-60th percentile outcomes across 2000 simulations. Shaded band shows 40th-60th percentile outcomes across 2000 simulations.
</p> </p>
)} )}
<ChartContainer className="aspect-auto h-80 w-full" config={{}}> <ChartContainer className="aspect-auto h-80 w-full" config={projectionChartConfig}>
<AreaChart data={chartData} margin={{ top: 10, right: 20, left: 20, bottom: 10 }}> <AreaChart data={chartData} margin={{ top: 10, right: 20, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
@@ -872,18 +936,7 @@ export default function FireCalculatorForm({
<YAxis <YAxis
yAxisId={'right'} yAxisId={'right'}
orientation="right" orientation="right"
tickFormatter={(value: number) => { tickFormatter={formatNumberShort}
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={30} width={30}
stroke="var(--color-orange-500)" stroke="var(--color-orange-500)"
tick={{}} tick={{}}
@@ -892,37 +945,19 @@ export default function FireCalculatorForm({
<YAxis <YAxis
yAxisId="left" yAxisId="left"
orientation="left" orientation="left"
tickFormatter={(value: number) => { tickFormatter={formatNumberShort}
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={30} width={30}
stroke="var(--color-red-600)" stroke="var(--color-primary)"
/>
<ChartTooltip
content={
<ChartTooltipContent
label={'Year'}
labelFormatter={(value) => {
return value;
}}
labelKey="year"
indicator="line"
/>
}
/> />
<ChartTooltip content={tooltipRenderer} />
<defs> <defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.8} /> <stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} /> <stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} />
</linearGradient> </linearGradient>
<linearGradient id="fillMonteCarloBand" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="fillMonteCarloBand" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} /> <stop offset="0%" stopColor="var(--color-primary)" stopOpacity={0.1} />
<stop offset="95%" stopColor="var(--color-secondary)" stopOpacity={0.3} /> <stop offset="100%" stopColor="var(--color-secondary)" stopOpacity={0.3} />
</linearGradient> </linearGradient>
</defs> </defs>
<Area <Area
@@ -954,8 +989,9 @@ export default function FireCalculatorForm({
stackId="mc-range" stackId="mc-range"
stroke="none" stroke="none"
fill="url(#fillMonteCarloBand)" fill="url(#fillMonteCarloBand)"
fillOpacity={0.3} fillOpacity={0.5}
yAxisId={'right'} yAxisId={'right'}
activeDot={false}
connectNulls connectNulls
isAnimationActive={false} isAnimationActive={false}
className="mc-bound-band" className="mc-bound-band"
@@ -966,7 +1002,7 @@ export default function FireCalculatorForm({
dataKey="balanceP10" dataKey="balanceP10"
stroke="var(--color-orange-500)" stroke="var(--color-orange-500)"
strokeDasharray="6 6" strokeDasharray="6 6"
strokeWidth={1.25} strokeWidth={0}
dot={false} dot={false}
activeDot={false} activeDot={false}
yAxisId={'right'} yAxisId={'right'}
@@ -978,7 +1014,7 @@ export default function FireCalculatorForm({
dataKey="balanceP90" dataKey="balanceP90"
stroke="var(--color-orange-500)" stroke="var(--color-orange-500)"
strokeDasharray="6 6" strokeDasharray="6 6"
strokeWidth={1.25} strokeWidth={0}
dot={false} dot={false}
activeDot={false} activeDot={false}
yAxisId={'right'} yAxisId={'right'}
@@ -989,7 +1025,7 @@ export default function FireCalculatorForm({
type="step" type="step"
dataKey="monthlyAllowance" dataKey="monthlyAllowance"
name="allowance" name="allowance"
stroke="var(--color-red-600)" stroke="var(--primary)"
fill="none" fill="none"
activeDot={{ r: 6 }} activeDot={{ r: 6 }}
yAxisId="left" yAxisId="left"
@@ -997,8 +1033,8 @@ export default function FireCalculatorForm({
{result.fireNumber && ( {result.fireNumber && (
<ReferenceLine <ReferenceLine
y={result.fireNumber} y={result.fireNumber}
stroke="var(--primary)" stroke="var(--secondary)"
strokeWidth={2} strokeWidth={1}
strokeDasharray="2 1" strokeDasharray="2 1"
label={{ label={{
value: 'FIRE Number', value: 'FIRE Number',
@@ -1013,8 +1049,8 @@ export default function FireCalculatorForm({
(Number(form.getValues('retirementAge')) - (Number(form.getValues('retirementAge')) -
Number(form.getValues('currentAge'))) Number(form.getValues('currentAge')))
} }
stroke="var(--primary)" stroke="var(--secondary)"
strokeWidth={2} strokeWidth={1}
label={{ label={{
value: 'Retirement', value: 'Retirement',
position: 'insideTopRight', position: 'insideTopRight',