All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- 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.
447 lines
13 KiB
Svelte
447 lines
13 KiB
Svelte
<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} · 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>
|