Files
website/src/lib/money-tracker/MoneyChart.svelte
Stan Runge 78b69c7d8f
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
Money forecast: calm optimistic start, rising plateau, thin solid projected lines
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00

447 lines
13 KiB
Svelte
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.

<script lang="ts">
import {
decimateSeries,
timeGrid,
yearLabels,
type ChartPoint,
type ChartSeries,
type GridMode
} from './charts';
let {
series: input,
title,
unit = '€',
marker,
grid,
zeroLine
}: {
series: ChartSeries[];
title: string;
unit?: string;
marker?: { date: string; label: string };
/** Vertical gridlines: every month (1 January drawn stronger), every year, or every decade. */
grid?: GridMode;
/** Thick baseline at 0 (default on for money charts) so dips toward zero stand out. */
zeroLine?: boolean;
} = $props();
let showZero = $derived(zeroLine ?? unit === '€');
let clientWidth = $state(800);
let width = $derived(Math.max(280, clientWidth));
let compact = $derived(width < 520);
let height = $derived(compact ? 240 : 280);
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
// Daily projections over decades: keep each pixel column's low and high so spikes stay.
let series = $derived(decimateSeries(input, width - plot.left - plot.right));
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
// Money charts keep a little room under €0 so the baseline sits clear of the bottom edge and
// small dips below zero stay visible.
let min = $derived(showZero && scale.min === 0 && scale.max > 0 ? -scale.max * 0.06 : scale.min);
let max = $derived(scale.max);
let span = $derived(max - min || 1);
let from = $derived(Math.min(...times));
let until = $derived(Math.max(...times));
let dateSpan = $derived(until - from || 1);
let gridLines = $derived(
grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : []
);
let axisTicks = $derived(
grid === 'years' || grid === 'decades'
? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44)))
: ticks()
);
let snapDates = $derived(
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
);
let hoverDate = $state<string | null>(null);
let hoverRows = $derived(
hoverDate === null
? []
: series.flatMap((item) => {
const point = pointAt(item.points, hoverDate!);
return point ? [{ item, point }] : [];
})
);
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 });
const euros = new Intl.NumberFormat('en-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
const exact = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
/** Rounds the value range out to 1/2/5 × 10ⁿ steps so gridlines land on round amounts. */
function niceScale(low: number, high: number) {
const raw = (high - low || 1) / 3;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const step = ([1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= raw) ?? 10) * magnitude;
// A dip of a few euros below zero should not add a whole empty gridline band.
const min = low > -step * 0.05 ? 0 : Math.floor(low / step) * step;
const max = Math.ceil(high / step) * step;
const ticks: number[] = [];
for (let value = max; value >= min - step / 2; value -= step) ticks.push(value);
return { min, max, ticks };
}
function isoDate(ms: number) {
return new Date(ms).toISOString().slice(0, 10);
}
function time(date: string) {
return Date.parse(`${date}T12:00:00Z`);
}
function x(date: string) {
return plot.left + ((time(date) - from) / dateSpan) * (width - plot.left - plot.right);
}
function y(value: number) {
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
}
function path(points: ChartPoint[]) {
return points
.map(
(point, index) =>
`${index ? 'L' : 'M'}${x(point.date).toFixed(2)},${y(point.value).toFixed(2)}`
)
.join(' ');
}
/** Actual part through `projectedFrom` and projected part from the last actual point onward. */
function segments(item: ChartSeries) {
if (!item.projectedFrom) return { actual: item.points, projected: [] };
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
return split < 0
? { actual: [], projected: item.points }
: { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
}
/** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
const lineWidth = (item: ChartSeries, projected: boolean) =>
projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
function pointAt(points: ChartPoint[], date: string) {
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
return points.findLast((point) => point.date <= date) ?? null;
}
function ticks() {
const months = (until - from) / (30.44 * 86_400_000);
const step = [1, 2, 3, 6, 12, 24].find((size) => months / size <= (compact ? 3 : 6)) ?? 48;
const start = new Date(from);
const result: string[] = [];
let year = start.getUTCFullYear();
let month = start.getUTCMonth() + 1;
month = Math.ceil(month / step) * step;
for (let guard = 0; guard < 60; guard++) {
while (month >= 12) {
month -= 12;
year++;
}
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
if (time(date) > until) break;
if (time(date) >= from) result.push(date);
month += step;
}
return result;
}
function tickLabel(date: string) {
return grid === 'years' || grid === 'decades' ? date.slice(0, 4) : dateLabel(date);
}
function dateLabel(date: string, withDay = false) {
return new Date(time(date)).toLocaleDateString('en-NL', {
day: withDay ? 'numeric' : undefined,
month: 'short',
year: withDay ? 'numeric' : '2-digit',
timeZone: 'UTC'
});
}
function format(value: number, precise = false) {
if (unit !== '€') return `${money.format(value)} ${unit}`;
return (precise ? exact : euros).format(value / 100);
}
function hover(event: PointerEvent) {
const svg = event.currentTarget as SVGSVGElement;
const box = svg.getBoundingClientRect();
const position = ((event.clientX - box.left) / box.width) * width;
const target = from + ((position - plot.left) / (width - plot.left - plot.right)) * dateSpan;
let best: string | null = null;
for (const date of snapDates)
if (best === null || Math.abs(time(date) - target) < Math.abs(time(best) - target))
best = date;
hoverDate = best;
}
</script>
<figure class="chart">
{#if values.length}
<div class="chart-area" bind:clientWidth>
<svg
viewBox={`0 0 ${width} ${height}`}
width={clientWidth ? undefined : width}
role="img"
aria-label={title}
onpointermove={hover}
onpointerdown={hover}
onpointerleave={() => (hoverDate = null)}
>
{#each gridLines as line (line.date)}
<line
class="grid-x"
class:year={line.year && grid === 'months'}
x1={x(line.date)}
x2={x(line.date)}
y1={plot.top}
y2={height - plot.bottom}
/>
{/each}
{#each scale.ticks as value (value)}
<line
x1={plot.left}
x2={width - plot.right}
y1={y(value)}
y2={y(value)}
stroke="var(--site-border)"
stroke-width="1"
/>
<text
x={plot.left - 8}
y={y(value) + 4}
text-anchor="end"
fill="var(--site-muted)"
font-size="11">{format(value)}</text
>
{/each}
{#if showZero && min <= 0 && max >= 0}
<line class="zero-line" x1={plot.left} x2={width - plot.right} y1={y(0)} y2={y(0)} />
{/if}
{#each axisTicks as tick (tick)}
<text
x={x(tick)}
y={height - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{tickLabel(tick)}</text
>
{/each}
{#if marker && time(marker.date) >= from && time(marker.date) <= until}
<line
x1={x(marker.date)}
x2={x(marker.date)}
y1={plot.top - 6}
y2={height - plot.bottom}
stroke="var(--site-muted)"
stroke-width="1"
stroke-dasharray="2 3"
/>
<text
x={x(marker.date)}
y={plot.top - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{marker.label}</text
>
{/if}
{#each series as item (item.label)}
{@const parts = segments(item)}
{#each [parts.actual, parts.projected] as points, index (index)}
{#if points.length > 1}
<path
d={path(points)}
fill="none"
stroke={item.color}
stroke-width={lineWidth(item, index === 1)}
stroke-opacity={index ? 0.8 : 1}
stroke-linecap="round"
stroke-linejoin="round"
data-line={index ? 'projected' : 'actual'}
/>
{/if}
{/each}
{#if item.points.length === 1}
<circle
cx={x(item.points[0].date)}
cy={y(item.points[0].value)}
r="4"
fill={item.color}
/>
{/if}
{/each}
{#if hoverDate}
<line
x1={x(hoverDate)}
x2={x(hoverDate)}
y1={plot.top}
y2={height - plot.bottom}
stroke="var(--site-muted)"
stroke-width="1"
/>
{#each hoverRows as row (row.item.label)}
<circle
cx={x(hoverDate)}
cy={y(row.point.value)}
r="4"
fill={row.item.color}
stroke="var(--site-bg, #000)"
stroke-width="2"
/>
{/each}
{/if}
<rect
x={plot.left}
y={plot.top}
width={width - plot.left - plot.right}
height={height - plot.top - plot.bottom}
fill="transparent"
/>
</svg>
{#if hoverDate && hoverRows.length}
<div
class="tooltip"
class:flip={x(hoverDate) > width / 2}
style={`left:${x(hoverDate)}px`}
role="status"
>
<p class="tooltip-date">
{dateLabel(hoverDate, true)}{#if marker && hoverDate > marker.date}&nbsp;· projected{/if}
</p>
{#each hoverRows as row (row.item.label)}
<p class:emphasis={row.item.emphasis}>
<span class="swatch" style={`background:${row.item.color}`}></span>
<span class="tooltip-label">{row.item.label}</span>
<span class="tooltip-value">{format(row.point.value, true)}</span>
</p>
{/each}
</div>
{/if}
</div>
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
<figcaption class="legend">
{#each series as item (item.label)}
<span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
>
{/each}
{#if series.some((item) => item.projectedFrom)}
<span class="line-key first"><span class="key actual"></span>Actual</span>
<span class="line-key"><span class="key projected"></span>Projected</span>
{/if}
</figcaption>
{/if}
{:else}
<p class="empty">Add entries to see a chart.</p>
{/if}
</figure>
<style>
.chart {
min-width: 0;
}
.chart-area {
position: relative;
touch-action: pan-y;
}
svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
user-select: none;
}
/* Vertical time gridlines: quieter than the value gridlines so the data stays in front. */
.grid-x {
stroke: var(--site-border);
stroke-width: 1;
stroke-opacity: 0.55;
shape-rendering: crispEdges;
}
/* The €0 baseline: thicker and higher contrast than the gridlines, under the data lines. */
.zero-line {
stroke: var(--site-text, currentColor);
stroke-width: 2;
stroke-opacity: 0.6;
shape-rendering: crispEdges;
}
.grid-x.year {
stroke: var(--site-muted);
stroke-width: 1.5;
stroke-opacity: 0.4;
}
.tooltip {
position: absolute;
top: 4px;
z-index: 2;
min-width: 170px;
max-width: 240px;
margin-left: 12px;
padding: 8px 10px;
border: 1px solid var(--site-border);
border-radius: 7px;
background: var(--site-bg, #000);
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
font-size: 12px;
pointer-events: none;
}
.tooltip.flip {
transform: translateX(calc(-100% - 24px));
}
.tooltip p {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--site-muted);
line-height: 1.6;
}
.tooltip p.emphasis {
color: var(--site-text);
font-weight: 600;
}
.tooltip .tooltip-date {
color: var(--site-text);
}
.tooltip-value {
margin-left: auto;
color: var(--site-text);
font-variant-numeric: tabular-nums;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
padding-top: 12px;
color: var(--site-muted);
font-size: 12px;
}
.legend > span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.legend > span.emphasis {
color: var(--site-text);
font-weight: 600;
}
.legend > .line-key.first {
margin-left: auto;
}
.key {
display: inline-block;
width: 18px;
border-top: 3px solid var(--site-muted);
border-radius: 2px;
}
.key.projected {
border-top-width: 1.5px;
opacity: 0.8;
}
.swatch {
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--site-border);
}
.empty {
padding: 70px 0;
text-align: center;
color: var(--site-muted);
}
</style>