Files
personal-website/src/lib/money-tracker/MoneyChart.svelte
Stan Runge c757d0882a
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
add icons to lists and solve money tracker bugs
2026-09-24 19:24:09 +00:00

377 lines
10 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 type { ChartPoint, ChartSeries } from './charts';
let {
series,
title,
unit = '€',
marker
}: {
series: ChartSeries[];
title: string;
unit?: string;
marker?: { date: string; label: string };
} = $props();
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 });
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)));
let min = $derived(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 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 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(' ');
}
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
function segments(item: ChartSeries) {
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
return split < 0
? { solid: [], dashed: item.points }
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
}
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 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 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}
{#each ticks() as tick (tick)}
<text
x={x(tick)}
y={height - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{dateLabel(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.solid, parts.dashed] as points, index (index)}
{#if points.length > 1}
<path
d={path(points)}
fill="none"
stroke={item.color}
stroke-width={item.emphasis ? 3 : 1.75}
stroke-dasharray={index ? '6 5' : undefined}
stroke-linecap={index ? 'butt' : 'round'}
stroke-linejoin="round"
/>
{/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.dashedFrom)}
<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.dashedFrom)}
<span class="line-key first"><span class="key solid"></span>Actual</span>
<span class="line-key"><span class="key dashed"></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;
}
.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: 2px solid var(--site-muted);
}
.key.dashed {
border-top-style: dashed;
}
.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>