stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps, solar, cars, population (monthly, since 1942, components), house prices, housing stock, municipal levies for Gouda and its neighbours - shared StatChart/Kpi/chart helpers with per-chart source attribution - drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
This commit is contained in:
50
src/lib/stats/Kpi.svelte
Normal file
50
src/lib/stats/Kpi.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
// One overview tile: coloured dot, label, big value, and a sub line (period / change).
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
unit = '',
|
||||
sub = '',
|
||||
color = '#3b82f6',
|
||||
delta = null,
|
||||
deltaUnit = '',
|
||||
invert = false,
|
||||
href = ''
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
sub?: string;
|
||||
color?: string;
|
||||
delta?: number | null;
|
||||
deltaUnit?: string;
|
||||
/** Treat a rise as bad (red), e.g. prices. */
|
||||
invert?: boolean;
|
||||
href?: string;
|
||||
} = $props();
|
||||
const good = $derived(delta == null ? null : invert ? delta < 0 : delta > 0);
|
||||
</script>
|
||||
|
||||
<svelte:element this={href ? 'a' : 'div'} class="kpi" href={href || undefined}>
|
||||
<span class="kpi-label"><span class="dot" style:background={color}></span>{label}</span>
|
||||
<span class="kpi-value">{value}<small>{unit}</small></span>
|
||||
<span class="kpi-sub"
|
||||
>{#if delta != null && Number.isFinite(delta)}<span class={good ? 'up' : 'down'}
|
||||
>{delta > 0 ? '▲' : delta < 0 ? '▼' : '•'}
|
||||
{Math.abs(delta).toLocaleString('en-GB', { maximumFractionDigits: 2 })}{deltaUnit}</span
|
||||
>
|
||||
{/if}{sub}</span
|
||||
>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
small {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--site-muted);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
a.kpi:hover {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
</style>
|
||||
88
src/lib/stats/StatChart.svelte
Normal file
88
src/lib/stats/StatChart.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
// One /stats chart panel: title, optional subtitle, the chart, and an attribution footer
|
||||
// built from stats.source.attribution for every source the chart uses.
|
||||
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle = '',
|
||||
option,
|
||||
sources,
|
||||
attributions,
|
||||
height = '320px',
|
||||
note = '',
|
||||
wide = false,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
option: (theme: ChartTheme) => EChartsCoreOption;
|
||||
/** Source ids (cbs, eurostat, fred, bis, …). */
|
||||
sources: string[];
|
||||
/** source id → attribution line (layout data). */
|
||||
attributions: Record<string, string>;
|
||||
height?: string;
|
||||
note?: string;
|
||||
wide?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
const footer = $derived(
|
||||
[...new Set(sources)]
|
||||
.map((s) => attributions[s])
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<figure class="stat-panel" class:wide>
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
{#if subtitle}<span>{subtitle}</span>{/if}
|
||||
</header>
|
||||
{@render children?.()}
|
||||
<EnergyChart {option} label={title} {height} />
|
||||
{#if note}<p class="note">{note}</p>{/if}
|
||||
<figcaption>{footer}</figcaption>
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.stat-panel {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
header span,
|
||||
.note {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
figcaption {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.7rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
230
src/lib/stats/chart.ts
Normal file
230
src/lib/stats/chart.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// ECharts option builders for /stats: black-and-white chrome from the site theme, colourful
|
||||
// series. Used through StatChart.svelte (which renders with the shared lazy ECharts wrapper).
|
||||
// Same shape as ChartTheme in #lib/energy/EnergyChart.svelte (svelte module types can't be
|
||||
// imported from plain .ts under tsgo).
|
||||
export type ChartTheme = {
|
||||
text: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
surface: string;
|
||||
dark: boolean;
|
||||
};
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
export type StatPoint = [string, number];
|
||||
export type Line = {
|
||||
name: string;
|
||||
points: StatPoint[];
|
||||
color?: string;
|
||||
/** 'line' (default), 'bar', 'area' or 'step'. */
|
||||
kind?: 'line' | 'bar' | 'area' | 'step';
|
||||
dashed?: boolean;
|
||||
/** Plot on the right-hand axis. */
|
||||
right?: boolean;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
export const PALETTE = [
|
||||
'#3b82f6', // blue
|
||||
'#f97316', // orange
|
||||
'#10b981', // emerald
|
||||
'#e11d48', // rose
|
||||
'#a855f7', // purple
|
||||
'#eab308', // yellow
|
||||
'#06b6d4', // cyan
|
||||
'#84cc16', // lime
|
||||
'#ec4899', // pink
|
||||
'#64748b' // slate
|
||||
];
|
||||
|
||||
export type AxisOptions = {
|
||||
unit?: string;
|
||||
rightUnit?: string;
|
||||
decimals?: number;
|
||||
/** Category x axis (e.g. years as labels) instead of a time axis. */
|
||||
category?: boolean;
|
||||
zoom?: boolean;
|
||||
legend?: boolean;
|
||||
min?: number | 'dataMin';
|
||||
};
|
||||
|
||||
const fmt = (decimals: number) =>
|
||||
new Intl.NumberFormat('en-GB', { maximumFractionDigits: decimals, minimumFractionDigits: 0 });
|
||||
|
||||
export function lineOption(
|
||||
theme: ChartTheme,
|
||||
lines: Line[],
|
||||
o: AxisOptions = {}
|
||||
): EChartsCoreOption {
|
||||
const number = fmt(o.decimals ?? 2);
|
||||
const axisLabel = { color: theme.muted, fontSize: 11 };
|
||||
const splitLine = { lineStyle: { color: theme.border, opacity: 0.6 } };
|
||||
const yAxis = (unit: string | undefined, right = false) => ({
|
||||
type: 'value',
|
||||
position: right ? 'right' : 'left',
|
||||
scale: o.min === 'dataMin',
|
||||
min: typeof o.min === 'number' ? o.min : undefined,
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => fmt(2).format(v) },
|
||||
splitLine: right ? { show: false } : splitLine,
|
||||
name: unit,
|
||||
nameTextStyle: { color: theme.muted, fontSize: 11, align: right ? 'right' : 'left' }
|
||||
});
|
||||
const hasRight = lines.some((l) => l.right);
|
||||
return {
|
||||
animation: false,
|
||||
grid: {
|
||||
left: 8,
|
||||
right: hasRight ? 8 : 16,
|
||||
top: o.legend === false ? 28 : 56,
|
||||
bottom: o.zoom ? 56 : 24,
|
||||
containLabel: true
|
||||
},
|
||||
legend:
|
||||
o.legend === false || lines.length < 2
|
||||
? undefined
|
||||
: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
type: 'scroll',
|
||||
textStyle: { color: theme.muted, fontSize: 11 },
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.border,
|
||||
textStyle: { color: theme.text, fontSize: 12 },
|
||||
valueFormatter: (v: number) => (v == null ? '–' : number.format(v))
|
||||
},
|
||||
xAxis: o.category
|
||||
? { type: 'category', axisLabel, axisLine: { lineStyle: { color: theme.border } } }
|
||||
: {
|
||||
type: 'time',
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: theme.border } },
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: hasRight ? [yAxis(o.unit), yAxis(o.rightUnit, true)] : yAxis(o.unit),
|
||||
dataZoom: o.zoom
|
||||
? [
|
||||
{ type: 'inside', throttle: 50 },
|
||||
{
|
||||
type: 'slider',
|
||||
height: 18,
|
||||
bottom: 8,
|
||||
borderColor: theme.border,
|
||||
textStyle: { color: theme.muted },
|
||||
fillerColor: 'rgba(127,127,127,0.15)'
|
||||
}
|
||||
]
|
||||
: undefined,
|
||||
series: lines.map((l, i) => {
|
||||
const color = l.color ?? PALETTE[i % PALETTE.length];
|
||||
const base = {
|
||||
name: l.name,
|
||||
data: l.points,
|
||||
yAxisIndex: l.right ? 1 : 0,
|
||||
itemStyle: { color },
|
||||
emphasis: { focus: 'series' },
|
||||
stack: l.stack
|
||||
};
|
||||
if (l.kind === 'bar') return { ...base, type: 'bar', barMaxWidth: 18 };
|
||||
return {
|
||||
...base,
|
||||
type: 'line',
|
||||
step: l.kind === 'step' ? 'end' : undefined,
|
||||
showSymbol: l.points.length < 40,
|
||||
symbolSize: 5,
|
||||
sampling: 'lttb',
|
||||
lineStyle: { width: 2, color, type: l.dashed ? 'dashed' : 'solid' },
|
||||
areaStyle: l.kind === 'area' ? { opacity: 0.18, color } : undefined
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** Horizontal bar ranking (e.g. countries or neighbourhoods). */
|
||||
export function barRankOption(
|
||||
theme: ChartTheme,
|
||||
rows: { label: string; value: number; highlight?: boolean }[],
|
||||
o: { decimals?: number; unit?: string; color?: string } = {}
|
||||
): EChartsCoreOption {
|
||||
const number = fmt(o.decimals ?? 1);
|
||||
const sorted = [...rows].sort((a, b) => a.value - b.value);
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 8, right: 56, top: 8, bottom: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.border,
|
||||
textStyle: { color: theme.text, fontSize: 12 },
|
||||
valueFormatter: (v: number) => `${number.format(v)}${o.unit ? ` ${o.unit}` : ''}`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: theme.muted, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: theme.border, opacity: 0.6 } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sorted.map((r) => r.label),
|
||||
axisLabel: { color: theme.text, fontSize: 11 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 16,
|
||||
data: sorted.map((r) => ({
|
||||
value: r.value,
|
||||
itemStyle: {
|
||||
color: r.highlight ? '#f97316' : (o.color ?? '#3b82f6'),
|
||||
borderRadius: [0, 3, 3, 0]
|
||||
}
|
||||
})),
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: theme.muted,
|
||||
fontSize: 11,
|
||||
formatter: (p: { value: number }) => number.format(p.value)
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/** Last point of a series, or null. */
|
||||
export const latest = (points: StatPoint[] | undefined) =>
|
||||
points?.length ? points[points.length - 1] : null;
|
||||
|
||||
/** Value a given number of periods back (by index), or null. */
|
||||
export function change(points: StatPoint[] | undefined, back: number) {
|
||||
if (!points || points.length <= back) return null;
|
||||
const now = points[points.length - 1][1];
|
||||
const then = points[points.length - 1 - back][1];
|
||||
return { abs: now - then, pct: then ? ((now - then) / Math.abs(then)) * 100 : null };
|
||||
}
|
||||
|
||||
/** Difference of two series on shared dates (a − b). */
|
||||
export function spread(a: StatPoint[] = [], b: StatPoint[] = []): StatPoint[] {
|
||||
const map = new Map(b);
|
||||
return a
|
||||
.filter(([d]) => map.has(d))
|
||||
.map(([d, v]) => [d, Math.round((v - map.get(d)!) * 1000) / 1000]);
|
||||
}
|
||||
|
||||
/** Year-over-year % change for monthly (12) or quarterly (4) points. */
|
||||
export function yoy(points: StatPoint[] = [], lag = 12): StatPoint[] {
|
||||
const out: StatPoint[] = [];
|
||||
for (let i = lag; i < points.length; i++) {
|
||||
const prev = points[i - lag][1];
|
||||
if (prev) out.push([points[i][0], Math.round(((points[i][1] - prev) / prev) * 10000) / 100]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
38
src/lib/stats/format.ts
Normal file
38
src/lib/stats/format.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Number and period formatting for /stats tiles and tables.
|
||||
const cache = new Map<number, Intl.NumberFormat>();
|
||||
export function num(v: number | null | undefined, decimals = 1) {
|
||||
if (v == null || !Number.isFinite(v)) return '–';
|
||||
let f = cache.get(decimals);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat('en-GB', {
|
||||
maximumFractionDigits: decimals,
|
||||
minimumFractionDigits: decimals
|
||||
});
|
||||
cache.set(decimals, f);
|
||||
}
|
||||
return f.format(v);
|
||||
}
|
||||
export function compact(v: number | null | undefined) {
|
||||
if (v == null || !Number.isFinite(v)) return '–';
|
||||
return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(
|
||||
v
|
||||
);
|
||||
}
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
/** Period label for a series frequency: 2026 / Q3 2026 / Sep 2026 / 24 Oct 2026. */
|
||||
export function period(p: string | null | undefined, freq = 'M') {
|
||||
if (!p) return '–';
|
||||
const [y, m, d] = p.split('-').map(Number);
|
||||
if (freq === 'A') return String(y);
|
||||
if (freq === 'S') return `H${m <= 6 ? 1 : 2} ${y}`;
|
||||
if (freq === 'Q') return `Q${Math.floor((m - 1) / 3) + 1} ${y}`;
|
||||
if (freq === 'M') return `${MONTHS[m - 1]} ${y}`;
|
||||
return `${d} ${MONTHS[m - 1]} ${y}`;
|
||||
}
|
||||
export function ago(iso: string | null | undefined) {
|
||||
if (!iso) return 'never';
|
||||
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (s < 3600) return `${Math.max(1, Math.round(s / 60))} min ago`;
|
||||
if (s < 86400 * 2) return `${Math.round(s / 3600)} h ago`;
|
||||
return `${Math.round(s / 86400)} days ago`;
|
||||
}
|
||||
35
src/lib/stats/regions.ts
Normal file
35
src/lib/stats/regions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Display names for the region codes used by Eurostat (geo), BIS (ref_area) and ISO3.
|
||||
export const COUNTRY: Record<string, string> = {
|
||||
NL: 'Netherlands',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
BE: 'Belgium',
|
||||
IT: 'Italy',
|
||||
ES: 'Spain',
|
||||
PL: 'Poland',
|
||||
SE: 'Sweden',
|
||||
DK: 'Denmark',
|
||||
AT: 'Austria',
|
||||
EU27_2020: 'EU 27',
|
||||
EA20: 'Euro area',
|
||||
EA: 'Euro area',
|
||||
US: 'United States',
|
||||
XM: 'Euro area',
|
||||
KR: 'South Korea',
|
||||
CN: 'China',
|
||||
GB: 'United Kingdom',
|
||||
JP: 'Japan',
|
||||
NLD: 'Netherlands',
|
||||
USA: 'United States',
|
||||
KOR: 'South Korea',
|
||||
CHN: 'China',
|
||||
CUW: 'Curaçao',
|
||||
EUU: 'European Union',
|
||||
WLD: 'World',
|
||||
DEU: 'Germany',
|
||||
FRA: 'France',
|
||||
BEL: 'Belgium',
|
||||
GBR: 'United Kingdom',
|
||||
JPN: 'Japan',
|
||||
IND: 'India'
|
||||
};
|
||||
Reference in New Issue
Block a user