stats: world opinions moves to /stats/opinions/world, the energy tracker merges into /stats/energy (live NED section), 301s from /projects/world-opinions and /projects/energy, tracker-style icons for the stats header, nav, sub-tabs and home grid
All checks were successful
Deploy production / deploy (push) Successful in 1m18s

This commit is contained in:
2026-10-26 00:16:08 +00:00
parent be3473caa0
commit d71e39f40a
25 changed files with 405 additions and 2235 deletions

View File

@@ -61,7 +61,7 @@
{#snippet body(project: Item)}
<span class="flex min-w-0 items-start gap-3">
{#if tracker}
<TrackerIcon name={project.name} class="catalog-icon mt-0.5 size-10" />
<TrackerIcon name={project.icon ?? project.name} class="catalog-icon mt-0.5 size-10" />
{:else}
<span
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"

View File

@@ -317,7 +317,9 @@
// No overflow clipping on the trackers panel: its spotting flyout sticks out.
item.href === '/trackers'
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
: 'left-0 max-h-[75vh] w-72 overflow-y-auto'
: item.href === '/stats'
? 'left-1/2 grid w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
: 'left-0 max-h-[75vh] w-72 overflow-y-auto'
]}
aria-label={menu.label}
data-menu-panel
@@ -325,7 +327,7 @@
<a
href={resolve(item.href)}
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
class:col-span-2={item.href === '/trackers'}
class:col-span-2={item.href !== '/projects'}
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
>
<div class="col-span-2 my-1 h-px bg-border"></div>
@@ -438,12 +440,15 @@
<a
href={project.external ? project.href : resolve(project.href)}
rel={project.external ? 'external' : undefined}
class={item.href === '/trackers'
class={item.href !== '/projects'
? 'tile flex items-center gap-2.5 p-2 text-sm'
: 'flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent'}
>
{#if item.href === '/trackers'}
<TrackerIcon name={project.name} class="tile-icon size-9" />
{#if item.href !== '/projects'}
<TrackerIcon
name={project.icon ?? project.name}
class="tile-icon size-9"
/>
{:else}
<span
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
@@ -606,6 +611,20 @@
</div>
{/each}
</div>
{:else if item.href === '/stats'}
<div class="ml-3 grid grid-cols-2 gap-1.5 border-l border-border py-1 pl-3">
{#each menus[item.href].items as project (project.name)}
<a
href={resolve(project.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon
name={project.icon ?? project.name}
class="tile-icon size-7"
/><span class="truncate">{project.name}</span></a
>
{/each}
</div>
{:else}
<div class="ml-3 flex flex-col border-l border-border pl-3">
{#each menus[item.href].items as project (project.name)}

View File

@@ -18,12 +18,6 @@ export const projects = [
href: '/projects/derby',
external: false
},
{
name: 'world opinions (warning: AI slop)',
subtitle: 'ideologies, values and biases across countries',
href: '/projects/world-opinions',
external: false
},
{
name: 'language learner (warning: AI slop)',
subtitle: 'Dutch, Korean and English, from zero to fluent',
@@ -36,48 +30,66 @@ export const projects = [
export const statsPages = [
{
name: 'economy',
icon: 'stats-economy',
subtitle: 'rates, inflation, bond spreads, GDP and debt',
href: '/stats/economy',
external: false
},
{
name: 'energy',
subtitle: 'tariffs, oil and gas, renewables, heat pumps and EVs',
icon: 'stats-energy',
subtitle: 'live grid mix and gas storage, tariffs, oil and gas, renewables, heat pumps and EVs',
href: '/stats/energy',
external: false
},
{
name: 'gouda',
icon: 'stats-gouda',
subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda',
external: false
},
{
name: 'countries',
icon: 'stats-countries',
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
href: '/stats/countries',
external: false
},
{
name: 'elections',
icon: 'stats-elections',
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
href: '/stats/elections',
external: false
},
{
name: 'history',
icon: 'stats-history',
subtitle: 'the long run, a sourced timeline, politics and culture of five places',
href: '/stats/history',
external: false
},
{
name: 'opinions',
subtitle: 'polls, Eurobarometer, happiness, search trends and party positions',
icon: 'stats-opinions',
subtitle: 'polls, Eurobarometer, happiness, party positions and world opinions',
href: '/stats/opinions',
external: false
}
] as const;
/** Sub-tabs of a /stats section (second row under the stats nav), keyed by the section path. */
export const statsSubPages: Record<
string,
readonly { name: string; href: string; icon: string }[]
> = {
'/stats/opinions': [
{ name: 'polls & surveys', href: '/stats/opinions', icon: 'stats-polls' },
{ name: 'world', href: '/stats/opinions/world', icon: 'stats-world' }
]
};
type Tracker = {
name: string;
subtitle: string;
@@ -381,13 +393,6 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false,
private: true
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false,
private: false
},
{
name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery',

View File

@@ -38,7 +38,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/trackers': {
title: 'Trackers',
description:
'Stan Runge’s trackers: games, money, houses, books, Counter-Strike prices, Dutch energy, Animaldex and lists.'
'Stan Runge’s trackers: games, money, houses, books, Counter-Strike prices, Animaldex and lists.'
},
'/trackers/books': {
title: 'Books',
@@ -104,11 +104,6 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
},
'/projects/energy': {
title: 'Energy',
description:
'Dutch electricity generation mix, source utilization, consumption trends, CO₂ intensity and gas storage, from NED.nl data.'
},
'/projects/derby': {
title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.'
@@ -122,7 +117,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.'
},
'/projects/world-opinions': {
'/stats/opinions/world': {
title: 'World Opinions',
description:
'How political words, parties, values and national histories differ across the world.'

View File

@@ -523,7 +523,7 @@ export const statsElecTurnout = statsSchema.table(
);
// ── Step 3: world opinions (§4, A15) ─────────────────────────────────────────────────────
// The /projects/world-opinions guide reads these. Curated rows (seeded from the former
// The /stats/opinions/world guide reads these. Curated rows (seeded from the former
// src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
export const statsWoCountry = statsSchema.table('wo_country', {

View File

@@ -12,7 +12,7 @@ import type {
WorldOpinionsData
} from '#lib/world-opinions/data.js';
// /projects/world-opinions content from stats.wo_* (seeded from the former data.ts). Rebuilds
// /stats/opinions/world content from stats.wo_* (seeded from the former data.ts). Rebuilds
// the exact shapes the components used, leaving optional keys out when the column is null.
export function loadWorldOpinions(): Promise<WorldOpinionsData> {
return cached('world-opinions', async () => {

View File

@@ -0,0 +1,746 @@
<script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js';
import type { ChartTheme } from '#lib/energy/EnergyChart.svelte';
import {
SOURCES,
fillGaps,
gaps,
latestSnapshot,
seasonalTime,
toBuckets,
type Bucket
} from '#lib/energy/data.js';
import { relativeTime } from '#lib/relative-time.js';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import type { EnergyData } from '#lib/server/energy.js';
// The live NED.nl grid and AGSI+ gas storage section of /stats/energy (formerly the
// /projects/energy tracker). Reads the ned-collector's `energy` schema, never writes.
let { energy, attributions }: { energy: EnergyData; attributions: Record<string, string> } =
$props();
const attr = $derived({
...attributions,
ned: 'NED.nl (Nationaal Energie Dashboard), hourly',
agsi: 'GIE AGSI+ gas storage'
});
const HOUR = 3_600_000;
const zone = 'Europe/Amsterdam';
const formatHour = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatTick = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatDay = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short'
});
const formatDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: zone });
const formatMonth = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
month: 'short',
year: 'numeric'
});
const formatYear = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' });
const formatSeason = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
day: 'numeric',
month: 'short'
});
const whole = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 0 });
const one = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 });
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
);
let hourly = $derived(toBuckets(energy.hourly));
let daily = $derived(toBuckets(energy.daily));
let monthly = $derived(toBuckets(energy.monthly));
let yearly = $derived(toBuckets(energy.yearly));
let forecast = $derived(toBuckets(energy.forecast));
let now = $derived(latestSnapshot(hourly));
let incompleteHours = $derived(hourly.filter((bucket) => bucket.incomplete));
let hourGaps = $derived(gaps(hourly.map((bucket) => bucket.t)));
let latestGas = $derived(energy.gas.at(-1));
let latestPeriod = $derived(energy.coverage.lastHour ?? Date.now());
type Range = 'day' | 'week' | 'month' | 'year' | 'years';
let range = $state<Range>('week');
const rangeLabels: Record<Range, string> = {
day: 'day',
week: 'week',
month: 'month',
year: '12 months',
years: 'all years'
};
function axis(theme: ChartTheme) {
return {
axisLine: { lineStyle: { color: theme.border } },
axisLabel: { color: theme.muted },
splitLine: { lineStyle: { color: theme.border } }
};
}
function tooltip(theme: ChartTheme, extra: Record<string, unknown> = {}) {
return {
backgroundColor: theme.surface,
borderColor: theme.border,
confine: true,
textStyle: { color: theme.text },
...extra
};
}
function legend(theme: ChartTheme) {
return { top: 0, type: 'scroll', textStyle: { color: theme.muted }, itemWidth: 14 };
}
// YYYY-MM-DD of an instant in Amsterdam, used to compare calendar periods.
const localDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone });
const monthKey = (t: number) => localDay.format(t).slice(0, 7);
const yearKey = (t: number) => localDay.format(t).slice(0, 4);
/** Is the bucket's calendar period still running (current month/year)? */
const isPartial = (bucket: Partial<Bucket>, key: (t: number) => string) =>
key(bucket.t!) === key(latestPeriod);
let trend = $derived.by(() => {
if (range === 'day' || range === 'week') {
const from = latestPeriod - (range === 'day' ? 24 : 24 * 7) * HOUR;
const points = fillGaps(
hourly.filter((bucket) => bucket.t > from),
HOUR
).map((bucket) => ('missing' in bucket || bucket.incomplete ? { t: bucket.t } : bucket));
return { kind: 'hour' as const, unit: 'MWh', scale: 1, points: points as Partial<Bucket>[] };
}
if (range === 'month')
return { kind: 'day' as const, unit: 'GWh', scale: 1000, points: daily as Partial<Bucket>[] };
if (range === 'year')
return {
kind: 'month' as const,
unit: 'GWh',
scale: 1,
points: monthly as Partial<Bucket>[]
};
return {
kind: 'year' as const,
unit: 'TWh',
scale: 1000,
// Before 2021 NED only publishes wind and solar per year, so those totals would mislead.
points: yearly.filter((bucket) => bucket.mix !== null) as Partial<Bucket>[]
};
});
function trendLabel(t: number) {
const kind = trend.kind;
if (kind === 'day') return formatDay.format(t);
if (kind === 'month') return formatMonth.format(t);
return formatYear.format(t);
}
function partialNote(bucket: Partial<Bucket>) {
if (trend.kind === 'day' && bucket.hours !== undefined && bucket.expected !== undefined)
if (bucket.hours < bucket.expected) return `${bucket.hours}/${bucket.expected} h`;
if (trend.kind === 'month' && isPartial(bucket, monthKey)) return 'so far';
if (trend.kind === 'year' && isPartial(bucket, yearKey)) return 'so far';
return '';
}
let trendOption = $derived((theme: ChartTheme) => {
const { kind, unit, scale, points } = trend;
const hourlyAxis = kind === 'hour';
const value = (raw: number | null | undefined) =>
raw === null || raw === undefined ? null : raw / scale;
const x = (bucket: Partial<Bucket>) => (hourlyAxis ? bucket.t! : trendLabel(bucket.t!));
const categories = hourlyAxis
? undefined
: points.map((bucket) => {
const note = partialNote(bucket);
return note ? `${trendLabel(bucket.t!)}*` : trendLabel(bucket.t!);
});
const series: Record<string, unknown>[] = SOURCES.map((source) => ({
name: source.label,
type: hourlyAxis ? 'line' : 'bar',
stack: 'mix',
areaStyle: hourlyAxis ? { opacity: 0.85 } : undefined,
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
emphasis: { focus: 'series' },
data: points.map((bucket, index) =>
hourlyAxis
? [x(bucket), value(bucket.sources?.[source.type]?.value)]
: [categories![index], value(bucket.sources?.[source.type]?.value)]
)
}));
series.push({
name: 'Consumption',
type: 'line',
showSymbol: !hourlyAxis,
symbolSize: 5,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
value(bucket.load)
])
});
const showCo2 = kind !== 'year';
if (showCo2)
series.push({
name: 'CO₂ intensity',
type: 'line',
yAxisIndex: 1,
showSymbol: false,
lineStyle: { width: 1.5, color: '#ef4444' },
itemStyle: { color: '#ef4444' },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
bucket.ef === null || bucket.ef === undefined ? null : Math.round(bucket.ef * 1000)
])
});
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: showCo2 ? 64 : 20, top: 48, bottom: hourlyAxis ? 64 : 36 },
xAxis: hourlyAxis
? {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatTick.format(t)
}
}
: { type: 'category', data: categories, ...axis(theme), splitLine: { show: false } },
yAxis: [
{
type: 'value',
name: unit,
nameTextStyle: { color: theme.muted },
...axis(theme),
axisLabel: { color: theme.muted, formatter: (v: number) => whole.format(v) }
},
...(showCo2
? [
{
type: 'value',
name: 'g CO₂/kWh',
nameTextStyle: { color: theme.muted },
...axis(theme),
splitLine: { show: false },
min: 0
}
]
: [])
],
dataZoom: hourlyAxis
? [
{ type: 'inside' },
{
type: 'slider',
bottom: 10,
height: 18,
borderColor: theme.border,
textStyle: { color: theme.muted },
fillerColor: 'rgba(127,127,127,.15)'
}
]
: [],
tooltip: tooltip(theme, {
trigger: 'axis',
formatter: (
params: { seriesName: string; marker: string; value: [number | string, number | null] }[]
) => {
if (!params.length) return '';
const head = params[0].value[0];
const index = hourlyAxis ? -1 : categories!.indexOf(String(head));
const bucket = hourlyAxis ? points.find((point) => point.t === head) : points[index];
const title = hourlyAxis ? formatHour.format(Number(head)) : trendLabel(bucket!.t!);
const note = bucket ? partialNote(bucket) : '';
const lines = params
.filter((param) => param.value[1] !== null && param.value[1] !== undefined)
.map((param) => {
const suffix = param.seriesName === 'CO₂ intensity' ? ' g/kWh' : ` ${unit}`;
return `${param.marker}${escapeHtml(param.seriesName)}: <b>${one.format(param.value[1]!)}${suffix}</b>`;
});
if (!lines.length) return `${title}<br>no data for this hour`;
const total = bucket?.sum
? `<br>Generation: <b>${one.format(bucket.sum / scale)} ${unit}</b>`
: '';
return `${title}${note ? ` (${note})` : ''}${total}<br>${lines.join('<br>')}`;
}
}),
series
};
});
let mixOption = $derived((theme: ChartTheme) => ({
animation: false,
backgroundColor: 'transparent',
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number; percent: number }) =>
`${escapeHtml(param.name)}<br><b>${whole.format(param.value)} MWh</b> · ${one.format(param.percent)}%`
}),
series: [
{
type: 'pie',
radius: ['48%', '78%'],
itemStyle: { borderColor: theme.surface, borderWidth: 2 },
label: { color: theme.muted, formatter: '{b}\n{d}%' },
data: (now?.sources ?? [])
.filter((source) => source.value > 0)
.map((source) => ({
name: source.label,
value: Math.round(source.value),
itemStyle: { color: source.color }
}))
}
]
}));
let utilizationOption = $derived((theme: ChartTheme) => {
const sources = [...(now?.sources ?? [])].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 48, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sources.map((source) => source.label),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number }) =>
`${escapeHtml(param.name)}<br>running at <b>${one.format(param.value)}%</b> of capacity`
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { value: number }) => `${whole.format(param.value)}%`
},
data: sources.map((source) => ({
value: source.utilization === null ? 0 : Math.round(source.utilization * 1000) / 10,
itemStyle: { color: source.color }
}))
}
]
};
});
let forecastOption = $derived((theme: ChartTheme) => {
const weather = SOURCES.filter((source) =>
['WindOffshoreC', 'Wind', 'Solar'].includes(source.type)
);
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatDay.format(t)
}
},
yAxis: { type: 'value', name: 'MWh', nameTextStyle: { color: theme.muted }, ...axis(theme) },
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${whole.format(v)} MWh`)
}),
series: [
...weather.map((source) => ({
name: source.label,
type: 'line',
stack: 'weather',
areaStyle: { opacity: 0.85 },
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
data: forecast.map((bucket) => [bucket.t, bucket.sources[source.type]?.value ?? null])
})),
{
name: 'Total generation',
type: 'line',
showSymbol: false,
lineStyle: { width: 1.5, color: theme.muted },
itemStyle: { color: theme.muted },
data: forecast.map((bucket) => [bucket.t, bucket.mix])
},
{
name: 'Consumption',
type: 'line',
showSymbol: false,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: forecast.map((bucket) => [bucket.t, bucket.load])
}
]
};
});
let seasonOption = $derived((theme: ChartTheme) => {
const years: Record<string, [number, number][]> = {};
for (const row of energy.gas)
(years[row.day.slice(0, 4)] ??= []).push([seasonalTime(row.day), row.fill]);
const list = Object.entries(years).filter(([, points]) => points.length > 30);
const current = list.at(-1)?.[0];
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
min: Date.UTC(2000, 0, 1),
max: Date.UTC(2000, 11, 31),
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
formatter: (t: number) => formatSeason.format(t).split(' ')[1]
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
axisPointer: {
label: { formatter: ({ value }: { value: number }) => formatSeason.format(value) }
},
valueFormatter: (v: number) => `${one.format(v)}%`
}),
series: list.map(([year, points], index) => ({
name: year,
type: 'line',
showSymbol: false,
data: points,
lineStyle: { width: year === current ? 3 : 1.3, opacity: year === current ? 1 : 0.7 },
itemStyle: {
color:
year === current
? '#f97316'
: `hsl(${200 + index * 22} 55% ${theme.dark ? '68%' : '42%'})`
},
z: year === current ? 3 : 2
}))
};
});
let gasPowerOption = $derived((theme: ChartTheme) => {
const months = monthly.filter((bucket) => bucket.sum > 0);
// Fill level on the last reported day of each month.
const fillAt = (t: number) =>
energy.gas.findLast((row) => row.day.startsWith(monthKey(t)))?.fill ?? null;
const labels = months.map((bucket) =>
isPartial(bucket, monthKey)
? `${formatMonth.format(bucket.t)}*`
: formatMonth.format(bucket.t)
);
const share = (bucket: Bucket, type: 'FossilGasPower' | 'WKK Total') =>
Math.round(((bucket.sources[type]?.value ?? 0) / bucket.sum) * 1000) / 10;
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: { type: 'category', data: labels, ...axis(theme), splitLine: { show: false } },
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${one.format(v)}%`)
}),
series: [
{
name: 'Fossil gas power (share of generation)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#f97316' },
data: months.map((bucket) => share(bucket, 'FossilGasPower'))
},
{
name: 'CHP (WKK, mostly gas)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#fdba74' },
data: months.map((bucket) => share(bucket, 'WKK Total'))
},
{
name: 'Gas storage fill (month end)',
type: 'line',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#0ea5e9' },
itemStyle: { color: '#0ea5e9' },
data: months.map((bucket) => fillAt(bucket.t))
}
]
};
});
let sitesOption = $derived((theme: ChartTheme) => {
const sites = [...energy.gasSites].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 96, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sites.map((site) => site.site),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { dataIndex: number }) => {
const site = sites[param.dataIndex];
return `${escapeHtml(site.site)} · ${site.day}<br><b>${one.format(site.fill)}%</b> full · ${one.format(site.twh)} TWh`;
}
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
itemStyle: { color: '#0ea5e9' },
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { dataIndex: number }) =>
`${whole.format(sites[param.dataIndex].fill)}% · ${one.format(sites[param.dataIndex].twh)} TWh`
},
data: sites.map((site) => site.fill)
}
]
};
});
let missingHours = $derived(hourGaps.reduce((sum, gap) => sum + gap.hours, 0));
let clock = $state(Date.now());
$effect(() => {
const timer = setInterval(() => (clock = Date.now()), 60_000);
return () => clearInterval(timer);
});
</script>
<h2 class="stats-section-title" id="live">Live grid and gas storage</h2>
<p class="stats-note">
Where Dutch electricity comes from hour by hour, how hard each source is running, and how full the
gas storages are. Collected hourly from NED.nl; the yearly CBS figures further down remain the
official totals.
</p>
<div class="kpis">
{#if now}
<Kpi
label="Renewable now"
value={percent.format(now.renewableShare)}
sub={formatHour.format(now.t)}
color="#10b981"
/>
{#if now.ef !== null}
<Kpi
label="Grid CO₂ intensity"
value={whole.format(now.ef * 1000)}
unit="g/kWh"
sub="latest hour"
color="#64748b"
/>
{/if}
<Kpi
label="Low-carbon now"
value={percent.format(now.lowCarbonShare)}
sub={`fossil ${percent.format(now.fossilShare)}`}
color="#06b6d4"
/>
{/if}
{#if latestGas}
<Kpi
label="Gas storage"
value={one.format(latestGas.fill)}
unit="% full"
sub={`${one.format(latestGas.twh)} TWh · ${formatDate.format(new Date(latestGas.day))}`}
color="#3b82f6"
/>
{/if}
</div>
{#if !now}
<p class="empty">No electricity data has been collected yet.</p>
{:else}
<div class="stats-grid">
<StatChart
title="Current mix"
subtitle={`${formatHour.format(now.t)} · ${whole.format(now.total)} MWh`}
option={mixOption}
sources={['ned']}
attributions={attr}
>
{#if now.load !== null}
<p class="facts">
{now.load > now.total ? 'Net import' : 'Net export'}
<strong>{whole.format(Math.abs(now.total - now.load))} MWh</strong>
</p>
{/if}
</StatChart>
<StatChart
title="Utilization"
subtitle="share of installed capacity in use"
option={utilizationOption}
sources={['ned']}
attributions={attr}
note="NED's load factor per source. Nuclear and &quot;other&quot; are reported near 100% almost constantly, so treat those as estimates."
/>
<StatChart
title="Generation and consumption"
subtitle={rangeLabels[range]}
option={trendOption}
sources={['ned']}
attributions={attr}
height="380px"
wide
note={`Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂ intensity. * = incomplete (still running or hours missing).${range === 'month' && daily.length < 28 ? ` Hourly collection started ${formatDate.format(energy.coverage.firstHour ?? now.t)}, so this fills up over the coming weeks.` : ''}`}
>
<Tabs.Root bind:value={range}>
<Tabs.List>
{#each Object.entries(rangeLabels) as [value, label] (value)}
<Tabs.Trigger {value}>{label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</StatChart>
{#if forecast.length}
<StatChart
title="Next days"
subtitle="NED forecast for wind, solar, generation and consumption"
option={forecastOption}
sources={['ned']}
attributions={attr}
wide
/>
{/if}
</div>
{/if}
<div class="stats-grid">
<StatChart
title="Gas storage by year"
subtitle={latestGas
? `${one.format(latestGas.twh)} TWh on ${formatDate.format(new Date(latestGas.day))}`
: ''}
option={seasonOption}
sources={['agsi']}
attributions={attr}
height="340px"
/>
<StatChart
title="Gas vs electricity"
subtitle="monthly"
option={gasPowerOption}
sources={['ned', 'agsi']}
attributions={attr}
height="340px"
note="NED has no gas consumption series here, so this compares how much electricity is made from gas with how full storage is."
/>
{#if energy.gasSites.length}
<StatChart
title="Storage sites"
subtitle="latest day per site"
option={sitesOption}
sources={['agsi']}
attributions={attr}
height="240px"
wide
/>
{/if}
</div>
<p class="stats-note">
{#if energy.coverage.firstHour && energy.coverage.lastHour}
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
energy.coverage.lastHour
)} ({whole.format(energy.coverage.hours)} hours{missingHours
? `, ${missingHours} missing in the last week`
: ''}{incompleteHours.length
? `, ${incompleteHours.length} still incomplete and left out`
: ''}).
{/if}
{#if monthly.length}
Monthly from {formatMonth.format(monthly[0].t)}, yearly from {formatYear.format(
yearly[0]?.t ?? monthly[0].t
)}.
{/if}
{#if energy.gas.length}
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
{/if}
{#if energy.collector.lastOk}
Last successful collection {relativeTime(new Date(energy.collector.lastOk), clock)}{energy
.collector.failedWeek
? ` · ${energy.collector.failedWeek} failed runs this week`
: ''}.
{/if}
</p>
<style>
.facts {
color: var(--site-muted);
font-size: 0.8rem;
}
.facts strong {
color: var(--site-text);
font-weight: 600;
}
#live {
scroll-margin-top: 5rem;
}
</style>

View File

@@ -37,12 +37,11 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress',
'/projects/world-opinions': 'World Opinions',
'/stats/opinions/world': 'World Opinions',
'/trackers/money': 'Money'
};
const cleaned = path.replace(/\/$/, '') || '/';

View File

@@ -370,6 +370,89 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M7 22a4 4 0 0 1-4-4 1 1 0 0 1 1-1h16a1 1 0 0 1 1 1 4 4 0 0 1-4 4z',
'M9.159 2.46a1 1 0 0 1 1.521-.193l9.977 8.98A1 1 0 0 1 20 13H4a1 1 0 0 1-.824-1.567z'
]
},
// /stats sections (header stats menu, home page, stats nav). Keys are `stats-<section>`.
'stats-overview': {
from: '#3b82f6',
to: '#6366f1',
ink: WHITE,
paths: ['M3 3v16a2 2 0 0 0 2 2h16', 'm19 9-5 5-4-4-3 3']
},
'stats-economy': {
from: '#10b981',
to: '#0d9488',
ink: WHITE,
paths: ['M16 7h6v6', 'm22 7-8.5 8.5-5-5L2 17']
},
'stats-energy': {
from: '#f97316',
to: '#e11d48',
ink: WHITE,
paths: ['M13 2 3 14h9l-1 8 10-12h-9l1-8z']
},
'stats-gouda': {
from: '#eab308',
to: '#f97316',
ink: WHITE,
paths: [
'M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0',
c(12, 10, 3)
]
},
'stats-countries': {
from: '#a855f7',
to: '#ec4899',
ink: WHITE,
paths: [c(12, 12, 10), 'M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20', 'M2 12h20']
},
'stats-curacao': {
from: '#22d3ee',
to: '#0e7490',
ink: WHITE,
paths: [
'M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4',
'M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3',
'M5.89 9.71c-2.15 2.15-2.3 5.47-.35 7.43l4.24-4.25.7-.7.71-.71 2.12-2.12c-1.95-1.96-5.27-1.8-7.42.35',
'M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14'
]
},
'stats-elections': {
from: '#06b6d4',
to: '#3b82f6',
ink: WHITE,
paths: ['m9 12 2 2 4-4', 'M5 7c0-1.1.9-2 2-2h10a2 2 0 0 1 2 2v12H5V7Z', 'M22 19H2']
},
'stats-history': {
from: '#b45309',
to: '#eab308',
ink: WHITE,
paths: [
'M15 12h-5',
'M15 8h-5',
'M19 17V5a2 2 0 0 0-2-2H4',
'M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3'
]
},
'stats-opinions': {
from: '#ec4899',
to: '#f97316',
ink: WHITE,
paths: ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z']
},
'stats-polls': {
from: '#f472b6',
to: '#db2777',
ink: WHITE,
paths: ['M3 3v16a2 2 0 0 0 2 2h16', 'M18 17V9', 'M13 17V5', 'M8 17v-3']
},
'stats-world': {
from: '#38bdf8',
to: '#0369a1',
ink: WHITE,
paths: [
c(12, 12, 10),
'm16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z'
]
}
};

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest';
import world from 'world-atlas/countries-110m.json';
import seed from '../../../scripts/stats/world-opinions-seed.json';
import { buildWorldOpinions, type WorldOpinionsData } from './data';
// The guide's content lives in stats.wo_*; this checks the snapshot the stats_wo_seed migration
// was generated from (scripts/stats/world-opinions-seed.json).
const { countries, countryById, narratives, parties, partyCountries, profiles } =
buildWorldOpinions(seed as WorldOpinionsData);
describe('world opinions seed', () => {
it('has unique country ids and ISO codes', () => {
expect(new Set(countries.map((country) => country.id)).size).toBe(countries.length);
expect(new Set(countries.map((country) => country.iso)).size).toBe(countries.length);
});
it('matches every country to a shape on the map', () => {
const shapes = new Set(
world.objects.countries.geometries.map((shape: { id?: string }) => String(shape.id))
);
for (const country of countries) expect(shapes.has(country.iso), country.name).toBe(true);
});
it('only references known countries', () => {
const referenced = [
...profiles.map((profile) => profile.id),
...partyCountries,
...parties.map((party) => party.country),
...narratives.flatMap((narrative) => narrative.views.map((view) => view.country))
];
for (const id of referenced) expect(countryById.has(id), id).toBe(true);
});
it('keeps party positions on the 0–10 scale with unique keys', () => {
const keys = new Set(parties.map((party) => `${party.country}-${party.short}`));
expect(keys.size).toBe(parties.length);
for (const party of parties) {
for (const value of [party.econ, party.social]) {
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(10);
}
}
for (const id of partyCountries) {
expect(
parties.some((party) => party.country === id),
id
).toBe(true);
}
});
});

View File

@@ -2,7 +2,7 @@
// Positions are approximate and meant for comparing countries/parties relative
// to each other. The curated content (countries, party placements, profiles,
// narratives, sources) lives in the `stats.wo_*` tables (seeded by the
// stats_wo_seed migration) and is loaded by +page.server.ts; this file keeps
// stats_wo_seed migration) and is loaded by /stats/opinions/world; this file keeps
// the types, the label dictionaries and the lookups the components share.
import { getContext, setContext } from 'svelte';