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

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,7 @@
{#snippet body(project: Item)} {#snippet body(project: Item)}
<span class="flex min-w-0 items-start gap-3"> <span class="flex min-w-0 items-start gap-3">
{#if tracker} {#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} {:else}
<span <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)]" 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,6 +317,8 @@
// No overflow clipping on the trackers panel: its spotting flyout sticks out. // No overflow clipping on the trackers panel: its spotting flyout sticks out.
item.href === '/trackers' 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-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
: 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' : 'left-0 max-h-[75vh] w-72 overflow-y-auto'
]} ]}
aria-label={menu.label} aria-label={menu.label}
@@ -325,7 +327,7 @@
<a <a
href={resolve(item.href)} 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="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 >all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
> >
<div class="col-span-2 my-1 h-px bg-border"></div> <div class="col-span-2 my-1 h-px bg-border"></div>
@@ -438,12 +440,15 @@
<a <a
href={project.external ? project.href : resolve(project.href)} href={project.external ? project.href : resolve(project.href)}
rel={project.external ? 'external' : undefined} rel={project.external ? 'external' : undefined}
class={item.href === '/trackers' class={item.href !== '/projects'
? 'tile flex items-center gap-2.5 p-2 text-sm' ? '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'} : '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'} {#if item.href !== '/projects'}
<TrackerIcon name={project.name} class="tile-icon size-9" /> <TrackerIcon
name={project.icon ?? project.name}
class="tile-icon size-9"
/>
{:else} {:else}
<span <span
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]" 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> </div>
{/each} {/each}
</div> </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} {:else}
<div class="ml-3 flex flex-col border-l border-border pl-3"> <div class="ml-3 flex flex-col border-l border-border pl-3">
{#each menus[item.href].items as project (project.name)} {#each menus[item.href].items as project (project.name)}

View File

@@ -18,12 +18,6 @@ export const projects = [
href: '/projects/derby', href: '/projects/derby',
external: false 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)', name: 'language learner (warning: AI slop)',
subtitle: 'Dutch, Korean and English, from zero to fluent', subtitle: 'Dutch, Korean and English, from zero to fluent',
@@ -36,48 +30,66 @@ export const projects = [
export const statsPages = [ export const statsPages = [
{ {
name: 'economy', name: 'economy',
icon: 'stats-economy',
subtitle: 'rates, inflation, bond spreads, GDP and debt', subtitle: 'rates, inflation, bond spreads, GDP and debt',
href: '/stats/economy', href: '/stats/economy',
external: false external: false
}, },
{ {
name: 'energy', 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', href: '/stats/energy',
external: false external: false
}, },
{ {
name: 'gouda', name: 'gouda',
icon: 'stats-gouda',
subtitle: 'population, housing and neighbourhoods in Gouda', subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda', href: '/stats/gouda',
external: false external: false
}, },
{ {
name: 'countries', name: 'countries',
icon: 'stats-countries',
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side', subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
href: '/stats/countries', href: '/stats/countries',
external: false external: false
}, },
{ {
name: 'elections', name: 'elections',
icon: 'stats-elections',
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential', subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
href: '/stats/elections', href: '/stats/elections',
external: false external: false
}, },
{ {
name: 'history', name: 'history',
icon: 'stats-history',
subtitle: 'the long run, a sourced timeline, politics and culture of five places', subtitle: 'the long run, a sourced timeline, politics and culture of five places',
href: '/stats/history', href: '/stats/history',
external: false external: false
}, },
{ {
name: 'opinions', 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', href: '/stats/opinions',
external: false external: false
} }
] as const; ] 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 = { type Tracker = {
name: string; name: string;
subtitle: string; subtitle: string;
@@ -381,13 +393,6 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false, external: false,
private: true private: true
}, },
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false,
private: false
},
{ {
name: 'games', name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery', 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': { '/trackers': {
title: 'Trackers', title: 'Trackers',
description: 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': { '/trackers/books': {
title: 'Books', title: 'Books',
@@ -104,11 +104,6 @@ const publicPages: Record<string, { title: string; description: string }> = {
description: description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.' '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': { '/projects/derby': {
title: 'Derby Lens', title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.' description: 'Estimate horse racing odds and compare them with market prices.'
@@ -122,7 +117,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
description: description:
'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.' 'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.'
}, },
'/projects/world-opinions': { '/stats/opinions/world': {
title: 'World Opinions', title: 'World Opinions',
description: description:
'How political words, parties, values and national histories differ across the world.' '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) ───────────────────────────────────────────────────── // ── 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. // src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
export const statsWoCountry = statsSchema.table('wo_country', { export const statsWoCountry = statsSchema.table('wo_country', {

View File

@@ -12,7 +12,7 @@ import type {
WorldOpinionsData WorldOpinionsData
} from '#lib/world-opinions/data.js'; } 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. // the exact shapes the components used, leaving optional keys out when the column is null.
export function loadWorldOpinions(): Promise<WorldOpinionsData> { export function loadWorldOpinions(): Promise<WorldOpinionsData> {
return cached('world-opinions', async () => { return cached('world-opinions', async () => {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js'; import * as Tabs from '#lib/components/ui/tabs/index.js';
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte'; import type { ChartTheme } from '#lib/energy/EnergyChart.svelte';
import { import {
SOURCES, SOURCES,
fillGaps, fillGaps,
@@ -11,10 +11,19 @@
type Bucket type Bucket
} from '#lib/energy/data.js'; } from '#lib/energy/data.js';
import { relativeTime } from '#lib/relative-time.js'; import { relativeTime } from '#lib/relative-time.js';
import type { PageProps } from './$types'; import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import type { EnergyData } from '#lib/server/energy.js';
let { data }: PageProps = $props(); // The live NED.nl grid and AGSI+ gas storage section of /stats/energy (formerly the
let energy = $derived(data.energy); // /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 HOUR = 3_600_000;
const zone = 'Europe/Amsterdam'; const zone = 'Europe/Amsterdam';
@@ -565,88 +574,83 @@
}); });
</script> </script>
<svelte:head> <h2 class="stats-section-title" id="live">Live grid and gas storage</h2>
<title>Energy · Stan Runge</title> <p class="stats-note">
<meta Where Dutch electricity comes from hour by hour, how hard each source is running, and how full the
name="description" gas storages are. Collected hourly from NED.nl; the yearly CBS figures further down remain the
content="Dutch electricity mix, utilization, trends and gas storage from NED.nl data, updated hourly." official totals.
/>
</svelte:head>
<div class="energy-page">
<header class="intro">
<div>
<p class="eyebrow">grid archive / netherlands</p>
<h1>Energy</h1>
<p class="lede">
Where Dutch electricity comes from hour by hour, how hard each source is running, and how
full the gas storages are. Data from NED.nl, collected hourly.
</p> </p>
</div> <div class="kpis">
<div class="stats">
{#if now} {#if now}
<div><strong>{percent.format(now.renewableShare)}</strong><span>renewable now</span></div> <Kpi
label="Renewable now"
value={percent.format(now.renewableShare)}
sub={formatHour.format(now.t)}
color="#10b981"
/>
{#if now.ef !== null} {#if now.ef !== null}
<div><strong>{whole.format(now.ef * 1000)}</strong><span>g CO₂ / kWh</span></div> <Kpi
label="Grid CO₂ intensity"
value={whole.format(now.ef * 1000)}
unit="g/kWh"
sub="latest hour"
color="#64748b"
/>
{/if} {/if}
<Kpi
label="Low-carbon now"
value={percent.format(now.lowCarbonShare)}
sub={`fossil ${percent.format(now.fossilShare)}`}
color="#06b6d4"
/>
{/if} {/if}
{#if latestGas} {#if latestGas}
<div><strong>{one.format(latestGas.fill)}%</strong><span>gas storage full</span></div> <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} {/if}
</div> </div>
</header>
{#if !now} {#if !now}
<section class="panel empty">No electricity data has been collected yet.</section> <p class="empty">No electricity data has been collected yet.</p>
{:else} {:else}
<section class="grid two"> <div class="stats-grid">
<div class="panel"> <StatChart
<div class="panel-heading"> title="Current mix"
<h2>Current mix</h2> subtitle={`${formatHour.format(now.t)} · ${whole.format(now.total)} MWh`}
<span class="muted">{formatHour.format(now.t)} · {whole.format(now.total)} MWh</span>
</div>
<EnergyChart
option={mixOption} option={mixOption}
label="Electricity generation mix for the latest hour" sources={['ned']}
height="320px" attributions={attr}
/> >
<dl class="facts">
<div>
<dt>Low-carbon</dt>
<dd>{percent.format(now.lowCarbonShare)}</dd>
</div>
<div>
<dt>Fossil</dt>
<dd>{percent.format(now.fossilShare)}</dd>
</div>
{#if now.load !== null} {#if now.load !== null}
<div> <p class="facts">
<dt>{now.load > now.total ? 'Net import' : 'Net export'}</dt> {now.load > now.total ? 'Net import' : 'Net export'}
<dd>{whole.format(Math.abs(now.total - now.load))} MWh</dd> <strong>{whole.format(Math.abs(now.total - now.load))} MWh</strong>
</div>
{/if}
</dl>
</div>
<div class="panel">
<div class="panel-heading">
<h2>Utilization</h2>
<span class="muted">share of installed capacity in use</span>
</div>
<EnergyChart
option={utilizationOption}
label="Utilization of each electricity source for the latest hour"
height="320px"
/>
<p class="note">
NED's load factor per source. Nuclear and "other" are reported near 100% almost
constantly, so treat those as estimates.
</p> </p>
</div> {/if}
</section> </StatChart>
<StatChart
<section class="panel"> title="Utilization"
<div class="panel-heading"> subtitle="share of installed capacity in use"
<h2>Generation and consumption</h2> 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.Root bind:value={range}>
<Tabs.List> <Tabs.List>
{#each Object.entries(rangeLabels) as [value, label] (value)} {#each Object.entries(rangeLabels) as [value, label] (value)}
@@ -654,85 +658,54 @@
{/each} {/each}
</Tabs.List> </Tabs.List>
</Tabs.Root> </Tabs.Root>
</div> </StatChart>
<EnergyChart
option={trendOption}
label={`Electricity generation by source, ${rangeLabels[range]}`}
/>
<p class="note">
Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂
intensity. * = incomplete (still running or hours missing).
{#if range === 'month' && daily.length < 28}
Hourly collection started {formatDate.format(energy.coverage.firstHour ?? now.t)}, so this
fills up over the coming weeks.
{/if}
</p>
</section>
{#if forecast.length} {#if forecast.length}
<section class="panel"> <StatChart
<div class="panel-heading"> title="Next days"
<h2>Next days</h2> subtitle="NED forecast for wind, solar, generation and consumption"
<span class="muted">NED forecast for wind, solar, generation and consumption</span>
</div>
<EnergyChart
option={forecastOption} option={forecastOption}
label="Forecast of wind and solar generation and consumption" sources={['ned']}
height="320px" attributions={attr}
wide
/> />
</section>
{/if}
{/if}
<section class="grid two">
<div class="panel">
<div class="panel-heading">
<h2>Gas storage by year</h2>
{#if latestGas}
<span class="muted"
>{one.format(latestGas.twh)} TWh on {formatDate.format(new Date(latestGas.day))}</span
>
{/if} {/if}
</div> </div>
<EnergyChart {/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} option={seasonOption}
label="Dutch gas storage fill level per year" sources={['agsi']}
attributions={attr}
height="340px" height="340px"
/> />
</div> <StatChart
<div class="panel"> title="Gas vs electricity"
<div class="panel-heading"> subtitle="monthly"
<h2>Gas vs electricity</h2>
<span class="muted">monthly</span>
</div>
<EnergyChart
option={gasPowerOption} option={gasPowerOption}
label="Share of electricity from gas compared with gas storage fill" sources={['ned', 'agsi']}
attributions={attr}
height="340px" 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."
/> />
<p class="note">
NED has no gas consumption series here, so this compares how much electricity is made from
gas with how full storage is.
</p>
</div>
</section>
{#if energy.gasSites.length} {#if energy.gasSites.length}
<section class="panel"> <StatChart
<div class="panel-heading"> title="Storage sites"
<h2>Storage sites</h2> subtitle="latest day per site"
<span class="muted">latest day per site</span>
</div>
<EnergyChart
option={sitesOption} option={sitesOption}
label="Fill level per Dutch gas storage site" sources={['agsi']}
attributions={attr}
height="240px" height="240px"
wide
/> />
</section>
{/if} {/if}
</div>
<footer class="coverage"> <p class="stats-note">
<p>
{#if energy.coverage.firstHour && energy.coverage.lastHour} {#if energy.coverage.firstHour && energy.coverage.lastHour}
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format( Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
energy.coverage.lastHour energy.coverage.lastHour
@@ -750,147 +723,24 @@
{#if energy.gas.length} {#if energy.gas.length}
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}. Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
{/if} {/if}
</p>
<p>
Source: <a href="https://ned.nl" rel="external noopener">NED.nl</a>.
{#if energy.collector.lastOk} {#if energy.collector.lastOk}
Last successful collection {relativeTime(energy.collector.lastOk, clock)}{energy.collector Last successful collection {relativeTime(new Date(energy.collector.lastOk), clock)}{energy
.failedWeek .collector.failedWeek
? ` · ${energy.collector.failedWeek} failed runs this week` ? ` · ${energy.collector.failedWeek} failed runs this week`
: ''}. : ''}.
{/if} {/if}
</p> </p>
</footer>
</div>
<style> <style>
.energy-page {
max-width: 1600px;
margin: 0 auto;
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
color: var(--site-text);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 2rem;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.67rem;
font-weight: 700;
}
h1 {
font-size: clamp(2.8rem, 6vw, 5.2rem);
line-height: 1;
letter-spacing: -0.075em;
margin: 0.7rem 0 1rem;
}
h2 {
font-size: 1.25rem;
letter-spacing: -0.04em;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
font-size: 0.95rem;
}
.stats {
display: flex;
gap: 2.5rem;
white-space: nowrap;
}
.stats div {
display: flex;
flex-direction: column;
align-items: end;
}
.stats strong {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.06em;
}
.stats span,
.facts dt {
color: var(--site-muted);
font-size: 0.69rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid > .panel {
margin-bottom: 0;
}
.panel {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1.2rem;
margin-bottom: 1rem;
min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 1rem;
}
.muted,
.note,
.coverage {
color: var(--site-muted);
font-size: 0.75rem;
}
.note {
margin-top: 0.75rem;
}
.empty {
color: var(--site-muted);
}
.facts { .facts {
display: flex; color: var(--site-muted);
gap: 2rem; font-size: 0.8rem;
margin-top: 0.5rem;
} }
.facts div { .facts strong {
display: flex; color: var(--site-text);
flex-direction: column;
}
.facts dd {
font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
.coverage { #live {
margin-top: 2rem; scroll-margin-top: 5rem;
display: grid;
gap: 0.4rem;
}
.coverage a {
text-decoration: underline;
}
@media (max-width: 900px) {
.intro {
flex-direction: column;
align-items: start;
}
.stats div {
align-items: start;
}
.grid.two {
grid-template-columns: minmax(0, 1fr);
}
} }
</style> </style>

View File

@@ -37,12 +37,11 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/animaldex/admin': 'Animaldex admin', '/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens', '/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices', '/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/trackers/games': 'Game Tracker', '/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner', '/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder', '/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress', '/projects/progress': 'Progress',
'/projects/world-opinions': 'World Opinions', '/stats/opinions/world': 'World Opinions',
'/trackers/money': 'Money' '/trackers/money': 'Money'
}; };
const cleaned = path.replace(/\/$/, '') || '/'; 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', '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' '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 // Positions are approximate and meant for comparing countries/parties relative
// to each other. The curated content (countries, party placements, profiles, // to each other. The curated content (countries, party placements, profiles,
// narratives, sources) lives in the `stats.wo_*` tables (seeded by the // 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. // the types, the label dictionaries and the lookups the components share.
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';

View File

@@ -152,7 +152,11 @@
>stats <ArrowRight size={20} aria-hidden="true" /></a >stats <ArrowRight size={20} aria-hidden="true" /></a
> >
</h2> </h2>
<CatalogGrid items={statsPages} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" /> <CatalogGrid
items={statsPages}
variant="tracker"
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>
</section> </section>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20"> <section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">

View File

@@ -1,4 +0,0 @@
import { loadEnergy } from '#lib/server/energy.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({ energy: await loadEnergy() });

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
export const GET: RequestHandler = ({ url }) => {
redirect(301, `/stats/energy${url.search}#live`);
};

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
export const GET: RequestHandler = ({ url }) => {
redirect(301, `/stats/opinions/world${url.search}`);
};

View File

@@ -22,6 +22,7 @@ const staticPaths = [
'/stats/countries/CUW', '/stats/countries/CUW',
'/stats/elections', '/stats/elections',
'/stats/opinions', '/stats/opinions',
'/stats/opinions/world',
'/stats/history', '/stats/history',
'/stats/countries/NLD', '/stats/countries/NLD',
'/stats/countries/USA', '/stats/countries/USA',
@@ -47,10 +48,8 @@ const staticPaths = [
'/projects/game-tracker/osu', '/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations', '/projects/game-tracker/pokemon/locations',
'/projects/animaldex', '/projects/animaldex',
'/projects/energy',
'/projects/derby', '/projects/derby',
'/projects/spotify-beatmap-finder', '/projects/spotify-beatmap-finder',
'/projects/world-opinions',
'/projects/language-learner', '/projects/language-learner',
...COURSES.map((c) => `/projects/language-learner/${c.code}`) ...COURSES.map((c) => `/projects/language-learner/${c.code}`)
]; ];

View File

@@ -1,44 +1,43 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { statsPages } from '#lib/projects.js'; import { statsPages, statsSubPages } from '#lib/projects.js';
import ChartLine from 'lucide-svelte/icons/chart-line'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import Flame from 'lucide-svelte/icons/flame';
import House from 'lucide-svelte/icons/house';
import Landmark from 'lucide-svelte/icons/landmark';
import Globe from 'lucide-svelte/icons/globe';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
import ScrollText from 'lucide-svelte/icons/scroll-text';
let { children } = $props(); let { children } = $props();
const icons = {
overview: ChartLine,
economy: Landmark,
energy: Flame,
gouda: House,
countries: Globe,
elections: Vote,
opinions: MessageCircle,
history: ScrollText
} as const;
const tabs = [ const tabs = [
{ name: 'overview', href: '/stats' }, { name: 'overview', href: '/stats', icon: 'stats-overview' },
...statsPages.map((p) => ({ name: p.name, href: p.href })) ...statsPages.map((p) => ({ name: p.name, href: p.href, icon: p.icon }))
]; ];
const path = $derived(page.url.pathname.replace(/\/$/, '') || '/');
const active = (href: string) =>
href === '/stats' ? path === href : path === href || path.startsWith(`${href}/`);
const section = $derived(tabs.find((t) => t.href !== '/stats' && active(t.href))?.href);
const subTabs = $derived(section ? statsSubPages[section] : undefined);
</script> </script>
<div class="stats-page"> <div class="stats-page">
<nav class="stats-tabs" aria-label="Stats sections"> <nav class="stats-tabs" aria-label="Stats sections">
{#each tabs as tab (tab.href)} {#each tabs as tab (tab.href)}
{@const Icon = icons[tab.name as keyof typeof icons] ?? ChartLine}
<a <a
href={resolve(tab.href as '/stats')} href={resolve(tab.href as '/stats')}
class:active={page.url.pathname === tab.href} class:active={active(tab.href)}
aria-current={page.url.pathname === tab.href ? 'page' : undefined} aria-current={path === tab.href ? 'page' : undefined}
><span class="tab-icon tab-{tab.name}"><Icon size={14} /></span>{tab.name}</a ><TrackerIcon name={tab.icon} class="tab-icon size-6" />{tab.name}</a
> >
{/each} {/each}
</nav> </nav>
{#if subTabs}
<nav class="stats-tabs stats-subtabs" aria-label="Section pages">
{#each subTabs as tab (tab.href)}
<a
href={resolve(tab.href as '/stats')}
class:active={path === tab.href}
aria-current={path === tab.href ? 'page' : undefined}
><TrackerIcon name={tab.icon} class="tab-icon size-5" />{tab.name}</a
>
{/each}
</nav>
{/if}
{@render children()} {@render children()}
</div> </div>
@@ -79,37 +78,13 @@
border-color: var(--site-text); border-color: var(--site-text);
color: var(--site-bg); color: var(--site-bg);
} }
.tab-icon { .stats-subtabs {
display: inline-grid; margin-top: -0.9rem;
place-items: center; border-bottom-style: dashed;
width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
color: #fff;
} }
.tab-overview { .stats-subtabs a {
background: linear-gradient(135deg, #3b82f6, #6366f1); font-size: 0.8rem;
} padding: 0.25rem 0.65rem 0.25rem 0.3rem;
.tab-economy {
background: linear-gradient(135deg, #10b981, #0d9488);
}
.tab-energy {
background: linear-gradient(135deg, #f97316, #e11d48);
}
.tab-gouda {
background: linear-gradient(135deg, #eab308, #f97316);
}
.tab-countries {
background: linear-gradient(135deg, #a855f7, #ec4899);
}
.tab-elections {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
}
.tab-history {
background: linear-gradient(135deg, #b45309, #eab308);
}
.tab-opinions {
background: linear-gradient(135deg, #ec4899, #f97316);
} }
/* Shared page furniture for every /stats page. */ /* Shared page furniture for every /stats page. */
.stats-page :global(.stats-intro) { .stats-page :global(.stats-intro) {

View File

@@ -4,15 +4,8 @@
import StatChart from '#lib/stats/StatChart.svelte'; import StatChart from '#lib/stats/StatChart.svelte';
import { change, latest, lineOption, spread, type StatPoint } from '#lib/stats/chart.js'; import { change, latest, lineOption, spread, type StatPoint } from '#lib/stats/chart.js';
import { ago, compact, num, period } from '#lib/stats/format.js'; import { ago, compact, num, period } from '#lib/stats/format.js';
import Landmark from 'lucide-svelte/icons/landmark';
import Flame from 'lucide-svelte/icons/flame';
import House from 'lucide-svelte/icons/house';
import Globe from 'lucide-svelte/icons/globe';
import Palmtree from 'lucide-svelte/icons/tree-palm';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
let { data } = $props(); let { data } = $props();
const s = $derived(data.series); const s = $derived(data.series);
@@ -50,57 +43,49 @@
{ {
href: '/stats/economy', href: '/stats/economy',
title: 'Economy', title: 'Economy',
icon: Landmark, icon: 'stats-economy',
grad: 'linear-gradient(135deg,#3b82f6,#a855f7)',
text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.' text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.'
}, },
{ {
href: '/stats/energy', href: '/stats/energy',
title: 'Energy', title: 'Energy',
icon: Flame, icon: 'stats-energy',
grad: 'linear-gradient(135deg,#eab308,#f97316)',
text: 'Dutch household tariffs, Europe vs the US, oil and gas, import dependence, renewables, heat pumps, solar and EVs.' text: 'Dutch household tariffs, Europe vs the US, oil and gas, import dependence, renewables, heat pumps, solar and EVs.'
}, },
{ {
href: '/stats/gouda', href: '/stats/gouda',
title: 'Gouda', title: 'Gouda',
icon: House, icon: 'stats-gouda',
grad: 'linear-gradient(135deg,#10b981,#06b6d4)',
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.' text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
}, },
{ {
href: '/stats/countries', href: '/stats/countries',
title: 'Countries', title: 'Countries',
icon: Globe, icon: 'stats-countries',
grad: 'linear-gradient(135deg,#a855f7,#ec4899)',
text: 'The Netherlands, Curaçao, the US, South Korea, China and more side by side: income, growth, inflation, debt and life expectancy.' text: 'The Netherlands, Curaçao, the US, South Korea, China and more side by side: income, growth, inflation, debt and life expectancy.'
}, },
{ {
href: '/stats/countries/CUW', href: '/stats/countries/CUW',
title: 'Curaçao', title: 'Curaçao',
icon: Palmtree, icon: 'stats-curacao',
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.' text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
}, },
{ {
href: '/stats/elections', href: '/stats/elections',
title: 'Elections', title: 'Elections',
icon: Vote, icon: 'stats-elections',
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.' text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.'
}, },
{ {
href: '/stats/history', href: '/stats/history',
title: 'History and culture', title: 'History and culture',
icon: ScrollText, icon: 'stats-history',
grad: 'linear-gradient(135deg,#b45309,#eab308)',
text: 'The Netherlands, Gouda, Curaçao, the US and Korea over the long run, a sourced timeline, office holders, religion, holidays, UNESCO sites and famous people.' text: 'The Netherlands, Gouda, Curaçao, the US and Korea over the long run, a sourced timeline, office holders, religion, holidays, UNESCO sites and famous people.'
}, },
{ {
href: '/stats/opinions', href: '/stats/opinions',
title: 'Opinions', title: 'Opinions',
icon: MessageCircle, icon: 'stats-opinions',
grad: 'linear-gradient(135deg,#ec4899,#f97316)',
text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.' text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.'
} }
] as const; ] as const;
@@ -203,9 +188,8 @@
<div class="sections"> <div class="sections">
{#each SECTIONS as sec (sec.href)} {#each SECTIONS as sec (sec.href)}
{@const Icon = sec.icon}
<a class="section-card" href={resolve(sec.href as '/stats')}> <a class="section-card" href={resolve(sec.href as '/stats')}>
<span class="section-icon" style:background={sec.grad}><Icon size={20} /></span> <TrackerIcon name={sec.icon} class="size-10" />
<span class="section-body"> <span class="section-body">
<strong>{sec.title}</strong> <strong>{sec.title}</strong>
<span>{sec.text}</span> <span>{sec.text}</span>
@@ -337,15 +321,6 @@
.section-card:hover { .section-card:hover {
border-color: var(--site-text); border-color: var(--site-text);
} }
.section-icon {
display: inline-grid;
place-items: center;
flex: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
color: #fff;
}
.section-body { .section-body {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;

View File

@@ -1,4 +1,5 @@
import { cached, loadSeries } from '#lib/server/stats.js'; import { cached, loadSeries } from '#lib/server/stats.js';
import { loadEnergy } from '#lib/server/energy.js';
import { epSeriesIds } from '#lib/stats/energylabels.js'; import { epSeriesIds } from '#lib/stats/energylabels.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
@@ -6,6 +7,8 @@ const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_20
const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro']; const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
export const load: PageServerLoad = async () => ({ export const load: PageServerLoad = async () => ({
// Live NED grid + gas storage (read-only, ned-collector's `energy` schema).
energy: await cached('stats:energy:live', loadEnergy),
series: await cached('stats:energy', () => series: await cached('stats:energy', () =>
loadSeries([ loadSeries([
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map( ...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte'; import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte'; import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js'; import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
@@ -12,8 +11,7 @@
EP_LABELS, EP_LABELS,
labelStackOption labelStackOption
} from '#lib/stats/energylabels.js'; } from '#lib/stats/energylabels.js';
import Zap from 'lucide-svelte/icons/zap'; import EnergyLive from '#lib/stats/EnergyLive.svelte';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data } = $props(); let { data } = $props();
const s = $derived(data.series); const s = $derived(data.series);
@@ -102,7 +100,7 @@
<title>energy · stats · Stan Runge</title> <title>energy · stats · Stan Runge</title>
<meta <meta
name="description" name="description"
content="Dutch household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars." content="The live Dutch grid mix and gas storage, household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
/> />
</svelte:head> </svelte:head>
@@ -115,15 +113,6 @@
</p> </p>
</section> </section>
<a class="live-link" href={resolve('/projects/energy')}>
<span class="live-icon"><Zap size={16} /></span>
<span
><strong>Live grid data</strong> · hourly Dutch production, consumption and the energy mix in the
energy tracker</span
>
<span class="live-arrow"><ArrowRight size={16} /></span>
</a>
<div class="kpis"> <div class="kpis">
<Kpi <Kpi
label="Electricity, variable + tax" label="Electricity, variable + tax"
@@ -181,6 +170,8 @@
/> />
</div> </div>
<EnergyLive energy={data.energy} attributions={a} />
<h2 class="stats-section-title">Household tariffs in the Netherlands</h2> <h2 class="stats-section-title">Household tariffs in the Netherlands</h2>
<div class="stats-grid"> <div class="stats-grid">
<StatChart <StatChart
@@ -640,34 +631,3 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<style>
.live-link {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--site-text);
text-decoration: none;
}
.live-link:hover {
border-color: var(--site-text);
}
.live-arrow {
margin-left: auto;
display: inline-flex;
}
.live-icon {
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.6rem;
background: linear-gradient(135deg, #eab308, #f97316);
color: #fff;
flex: none;
}
</style>

View File

@@ -208,7 +208,8 @@
<p> <p>
What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee
Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand. Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand.
For values by country see <a href={resolve('/projects/world-opinions')}>world opinions</a>. For values, what words mean and a party compass across 57 countries see
<a href={resolve('/stats/opinions/world')}>world opinions</a>.
</p> </p>
</section> </section>
@@ -514,7 +515,7 @@
</div> </div>
{/if} {/if}
<h2 class="stats-section-title">Where Dutch parties stand</h2> <h2 class="stats-section-title" id="parties">Where Dutch parties stand</h2>
<div class="stats-grid"> <div class="stats-grid">
{#if ches.length} {#if ches.length}
<StatChart <StatChart

View File

@@ -7,6 +7,7 @@
import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte'; import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte';
import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js'; import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js';
import { Button } from '#lib/components/ui/button/index.js'; import { Button } from '#lib/components/ui/button/index.js';
import { resolve } from '$app/paths';
let { data } = $props(); let { data } = $props();
// Static per load (stats.wo_*), so it is read once and shared with the components. // Static per load (stats.wo_*), so it is read once and shared with the components.
@@ -32,14 +33,26 @@
} }
</script> </script>
<div class="wo mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <svelte:head>
<header class="mb-8"> <title>world opinions · stats · Stan Runge</title>
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">world opinions</h1> <meta
<p class="text-lg text-[var(--site-muted)]"> name="description"
Same words, different politics. Hover the map, click a country. content="How political words, parties, values and national histories differ across 57 countries: what liberal means, what left and right are about, a shared party compass and a values map."
</p> />
</header> </svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / opinions / world</span>
<h1>World opinions</h1>
<p>
Same words, different politics. Hover the map, click a country. For measured party positions
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/opinions') + '#parties'}
>where Dutch parties stand</a
>; for polls, happiness and Eurobarometer see <a href={resolve('/stats/opinions')}>opinions</a>.
</p>
</section>
<div class="wo">
<section aria-label="World map"> <section aria-label="World map">
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer"> <div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
{#each modes as item (item.id)} {#each modes as item (item.id)}
@@ -70,20 +83,26 @@
<CountryPanel id={countryId} /> <CountryPanel id={countryId} />
</section> </section>
<section class="border-t border-[var(--site-border)] mt-12 py-10"> <section class="mt-12 border-t border-[var(--site-border)] py-10">
<h2 class="mb-1 text-2xl font-semibold">party compass</h2> <h2 class="stats-section-title mb-1">Party compass</h2>
<p class="mb-5 text-[var(--site-muted)]">Every party on one shared scale.</p> <p class="mb-5 text-[var(--site-muted)]">
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
positions for Dutch parties are on <a
class="underline underline-offset-2"
href={resolve('/stats/opinions') + '#parties'}>opinions</a
>.
</p>
<PartyCompass focus={countryId} /> <PartyCompass focus={countryId} />
</section> </section>
<section class="border-t border-[var(--site-border)] py-10"> <section class="border-t border-[var(--site-border)] py-10">
<h2 class="mb-1 text-2xl font-semibold">values map</h2> <h2 class="stats-section-title mb-1">Values map</h2>
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p> <p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
<CultureMap selected={countryId} onselect={select} /> <CultureMap selected={countryId} onselect={select} />
</section> </section>
<section class="border-t border-[var(--site-border)] py-10"> <section class="border-t border-[var(--site-border)] py-10">
<h2 class="mb-4 text-2xl font-semibold">one event, many stories</h2> <h2 class="stats-section-title mb-4">One event, many stories</h2>
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event"> <div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
{#each narratives as item, index (item.event)} {#each narratives as item, index (item.event)}
<Button <Button