diff --git a/scripts/stats/cbs-series.ts b/scripts/stats/cbs-series.ts index ef5b113..38fe6b8 100644 --- a/scripts/stats/cbs-series.ts +++ b/scripts/stats/cbs-series.ts @@ -23,4 +23,570 @@ export const RING = [ ...GOUDA_RING.map((code) => ({ code, region: code })) ]; -export const CBS_SERIES: CbsSeriesDef[] = []; +const ALL_REGIONS = [ + { code: 'NL01', region: 'NLD' }, + { code: 'GM0513', region: 'GM0513' } +]; +const VAT = { Btw: 'A048944' }; +const tariff = ( + table: string, + measure: string, + id: string, + title: string, + unit: string +): CbsSeriesDef => ({ + id, + table, + measure, + dims: VAT, + freq: 'M', + title, + unit, + theme: 'energy' +}); + +export const CBS_SERIES: CbsSeriesDef[] = [ + // Consumer prices + { + id: 'cbs:cpi:yoy', + table: '86141NED', + measure: 'M000238', + dims: { Bestedingscategorieen: 'T001112' }, + freq: 'M', + title: 'CPI inflation, all items', + unit: '% year on year', + theme: 'economy' + }, + { + id: 'cbs:cpi:energy:yoy', + table: '86141NED', + measure: 'M000238', + dims: { Bestedingscategorieen: 'CPI045000' }, + freq: 'M', + title: 'CPI inflation, electricity, gas and other fuels', + unit: '% year on year', + theme: 'energy' + }, + { + id: 'cbs:cpi:food:yoy', + table: '86141NED', + measure: 'M000238', + dims: { Bestedingscategorieen: 'CPI011000' }, + freq: 'M', + title: 'CPI inflation, food', + unit: '% year on year', + theme: 'economy' + }, + { + id: 'cbs:cpi:1969', + table: '83136NED', + measure: 'M000215_1', + freq: 'M', + title: 'Consumer price index (1969 = 100)', + unit: 'Index 1969=100', + theme: 'economy' + }, + { + id: 'cbs:rent:increase', + table: '70675ned', + measure: 'D001503', + freq: 'A', + title: 'Average rent increase', + unit: '%', + theme: 'economy' + }, + // Energy tariffs (incl. VAT); _1 = gas, _2 = electricity + tariff( + '85592NED', + 'M007924_2', + 'cbs:tariff:elec:variable', + 'Electricity: variable supply tariff (contract)', + 'EUR per kWh' + ), + tariff('85592NED', 'A045057_2', 'cbs:tariff:elec:tax', 'Electricity: energy tax', 'EUR per kWh'), + tariff( + '85592NED', + 'M001013_2', + 'cbs:tariff:elec:transport', + 'Electricity: transport tariff', + 'EUR per year' + ), + tariff( + '85592NED', + 'M000175_2', + 'cbs:tariff:elec:fixed', + 'Electricity: fixed supply tariff', + 'EUR per year' + ), + tariff( + '85592NED', + 'M001284', + 'cbs:tariff:elec:taxcredit', + 'Electricity: energy tax reduction', + 'EUR per year' + ), + tariff( + '85592NED', + 'M008381', + 'cbs:tariff:elec:dynamic', + 'Electricity: variable supply tariff (dynamic)', + 'EUR per kWh' + ), + tariff( + '85592NED', + 'M007924_1', + 'cbs:tariff:gas:variable', + 'Gas: variable supply tariff (contract)', + 'EUR per m3' + ), + tariff('85592NED', 'A045057_1', 'cbs:tariff:gas:tax', 'Gas: energy tax', 'EUR per m3'), + tariff( + '85592NED', + 'M001013_1', + 'cbs:tariff:gas:transport', + 'Gas: transport tariff', + 'EUR per year' + ), + tariff( + '85592NED', + 'M000175_1', + 'cbs:tariff:gas:fixed', + 'Gas: fixed supply tariff', + 'EUR per year' + ), + tariff( + '84672NED', + 'M000176_2', + 'cbs:tariff1823:elec:variable', + 'Electricity: variable supply tariff (2018-2023 series)', + 'EUR per kWh' + ), + tariff( + '84672NED', + 'A045057_2', + 'cbs:tariff1823:elec:tax', + 'Electricity: energy tax (2018-2023 series)', + 'EUR per kWh' + ), + tariff( + '84672NED', + 'M001285_2', + 'cbs:tariff1823:elec:ode', + 'Electricity: renewable energy surcharge ODE (2018-2023 series)', + 'EUR per kWh' + ), + tariff( + '84672NED', + 'M000176_1', + 'cbs:tariff1823:gas:variable', + 'Gas: variable supply tariff (2018-2023 series)', + 'EUR per m3' + ), + tariff( + '84672NED', + 'A045057_1', + 'cbs:tariff1823:gas:tax', + 'Gas: energy tax (2018-2023 series)', + 'EUR per m3' + ), + tariff( + '84672NED', + 'M001285_1', + 'cbs:tariff1823:gas:ode', + 'Gas: renewable energy surcharge ODE (2018-2023 series)', + 'EUR per m3' + ), + // Average prices for small users 2007-2023 (quarterly), incl. VAT and taxes + { + id: 'cbs:price0723:elec', + table: '81309NED', + measure: 'D001522', + dims: { Belastingen: 'A047659', Prijscomponenten: 'A047656' }, + freq: 'Q', + title: 'Electricity price, households 2.5-5 MWh (incl. VAT and taxes)', + unit: 'EUR per kWh', + theme: 'energy' + }, + { + id: 'cbs:price0723:gas', + table: '81309NED', + measure: 'D001514', + dims: { Belastingen: 'A047659', Prijscomponenten: 'A047656' }, + freq: 'Q', + title: 'Gas price, households 20-200 GJ (incl. VAT and taxes)', + unit: 'EUR per GJ', + theme: 'energy' + }, + // Energy balance (PJ) + { + id: 'cbs:energy:tpes', + table: '83140NED', + measure: 'D002419', + dims: { Energiedragers: 'T001027' }, + freq: 'A', + title: 'Total primary energy supply', + unit: 'PJ', + theme: 'energy' + }, + { + id: 'cbs:energy:netimport', + table: '83140NED', + measure: 'M002205', + dims: { Energiedragers: 'T001027' }, + freq: 'A', + title: 'Net energy imports', + unit: 'PJ', + theme: 'energy' + }, + { + id: 'cbs:energy:production', + table: '83140NED', + measure: 'M002420', + dims: { Energiedragers: 'T001027' }, + freq: 'A', + title: 'Domestic energy production', + unit: 'PJ', + theme: 'energy' + }, + { + id: 'cbs:energy:gas:production', + table: '83140NED', + measure: 'M002420', + dims: { Energiedragers: 'E006560' }, + freq: 'A', + title: 'Natural gas production', + unit: 'PJ', + theme: 'energy' + }, + { + id: 'cbs:energy:gas:netimport', + table: '83140NED', + measure: 'M002205', + dims: { Energiedragers: 'E006560' }, + freq: 'A', + title: 'Natural gas net imports', + unit: 'PJ', + theme: 'energy' + }, + { + id: 'cbs:energy:gas:tpes', + table: '83140NED', + measure: 'D002419', + dims: { Energiedragers: 'E006560' }, + freq: 'A', + title: 'Natural gas supply', + unit: 'PJ', + theme: 'energy' + }, + // Renewable electricity + ...( + [ + ['T001028', 'total', 'all renewables'], + ['E006637', 'wind-onshore', 'onshore wind'], + ['E006638', 'wind-offshore', 'offshore wind'], + ['E006590', 'solar', 'solar'], + ['E006566', 'biomass', 'biomass'], + ['E006587', 'hydro', 'hydro'] + ] as const + ).flatMap(([code, key, name]): CbsSeriesDef[] => [ + { + id: `cbs:renew:${key}:gwh`, + table: '82610NED', + measure: 'M002264_1', + dims: { BronTechniek: code }, + freq: 'A', + title: `Renewable electricity production: ${name}`, + unit: 'GWh', + theme: 'energy' + }, + { + id: `cbs:renew:${key}:share`, + table: '82610NED', + measure: 'M002416_2', + dims: { BronTechniek: code }, + freq: 'A', + title: `Renewable electricity, ${name} (normalised)`, + unit: '% of consumption', + theme: 'energy' + }, + { + id: `cbs:renew:${key}:mw`, + table: '82610NED', + measure: 'M002163', + dims: { BronTechniek: code }, + freq: 'A', + title: `Installed capacity: ${name}`, + unit: 'MW', + theme: 'energy' + } + ]), + // Heat pumps (homes and all buildings) + { + id: 'cbs:heatpumps:homes', + table: '85523NED', + measure: 'D002272', + dims: { Sector: 'E007037', Warmtepompen: 'T001364' }, + freq: 'A', + title: 'Heat pumps installed in homes (end of year)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:heatpumps:homes:air', + table: '85523NED', + measure: 'D002272', + dims: { Sector: 'E007037', Warmtepompen: 'A043948' }, + freq: 'A', + title: 'Air-source heat pumps in homes (end of year)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:heatpumps:homes:ground', + table: '85523NED', + measure: 'D002272', + dims: { Sector: 'E007037', Warmtepompen: 'A043945' }, + freq: 'A', + title: 'Ground/water-source heat pumps in homes (end of year)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:heatpumps:homes:hybrid', + table: '85523NED', + measure: 'D002272', + dims: { Sector: 'E007037', Warmtepompen: 'A052046' }, + freq: 'A', + title: 'Hybrid air-water heat pumps in homes (end of year)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:heatpumps:new', + table: '85523NED', + measure: 'D002270', + dims: { Sector: 'E007041', Warmtepompen: 'T001364' }, + freq: 'A', + title: 'Heat pumps put into use per year (all buildings)', + unit: 'count', + theme: 'energy' + }, + // Solar (NL and Gouda) + { + id: 'cbs:solar:kwp', + table: '85005NED', + measure: 'M002461', + dims: { SectorEnVermogensklasse: 'E007161' }, + freq: 'A', + title: 'Installed solar panel capacity (all)', + unit: 'kWp', + theme: 'energy', + regionDim: 'RegioS', + regions: ALL_REGIONS + }, + { + id: 'cbs:solar:homes:kwp', + table: '85005NED', + measure: 'M002461', + dims: { SectorEnVermogensklasse: 'E007037' }, + freq: 'A', + title: 'Installed solar panel capacity on homes', + unit: 'kWp', + theme: 'energy', + regionDim: 'RegioS', + regions: ALL_REGIONS + }, + { + id: 'cbs:solar:homes:n', + table: '85005NED', + measure: 'M002460', + dims: { SectorEnVermogensklasse: 'E007037' }, + freq: 'A', + title: 'Solar installations on homes', + unit: 'count', + theme: 'energy', + regionDim: 'RegioS', + regions: ALL_REGIONS + }, + // Passenger cars (1 January) + { + id: 'cbs:cars:total', + table: '85237NED', + measure: 'T001051', + dims: { Bouwjaar: 'T001378' }, + freq: 'A', + title: 'Passenger cars (1 January)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:cars:electric', + table: '85237NED', + measure: 'A027842', + dims: { Bouwjaar: 'T001378' }, + freq: 'A', + title: 'Fully electric passenger cars (1 January)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:cars:petrol', + table: '85237NED', + measure: 'A019276', + dims: { Bouwjaar: 'T001378' }, + freq: 'A', + title: 'Petrol passenger cars (1 January)', + unit: 'count', + theme: 'energy' + }, + { + id: 'cbs:cars:diesel', + table: '85237NED', + measure: 'A019275', + dims: { Bouwjaar: 'T001378' }, + freq: 'A', + title: 'Diesel passenger cars (1 January)', + unit: 'count', + theme: 'energy' + }, + // Population (Gouda, the ring and NL) + { + id: 'cbs:pop:monthly', + table: '37230ned', + measure: 'M000099', + freq: 'M', + title: 'Population at the end of the month', + unit: 'people', + theme: 'gouda', + regionDim: 'RegioS', + regions: RING + }, + { + id: 'cbs:pop:jan1', + table: '37259ned', + measure: 'M000352', + dims: { Geslacht: 'T001038' }, + freq: 'A', + title: 'Population on 1 January', + unit: 'people', + theme: 'gouda', + regionDim: 'RegioS', + regions: RING + }, + ...( + [ + ['D000128_2', 'births', 'Live births'], + ['M000134_2', 'deaths', 'Deaths'], + ['M000167', 'immigration', 'Immigration'], + ['M000168', 'emigration', 'Emigration (incl. administrative corrections)'], + ['M003485_1', 'in', 'Moved in from another municipality'], + ['M003485_2', 'out', 'Moved out to another municipality'], + ['M000101_2', 'growth', 'Total population growth'] + ] as const + ).map(([measure, key, title]): CbsSeriesDef => ({ + id: `cbs:pop:${key}`, + table: '37259ned', + measure, + dims: { Geslacht: 'T001038' }, + freq: 'A', + title, + unit: 'people', + theme: 'gouda', + regionDim: 'RegioS', + regions: ALL_REGIONS + })), + // Housing + { + id: 'cbs:house:avgprice', + table: '83625NED', + measure: 'M001534', + freq: 'A', + title: 'Average sale price of existing homes', + unit: 'EUR', + theme: 'gouda', + regionDim: 'RegioS', + regions: RING + }, + { + id: 'cbs:house:hpi:cr28', + table: '85819NED', + measure: 'M001505_2', + dims: { RegioS: 'CR28' }, + freq: 'Q', + title: 'House price index, Oost-Zuid-Holland (COROP, incl. Gouda)', + unit: 'Index 2020=100', + theme: 'gouda', + region: 'CR28' + }, + { + id: 'cbs:house:hpi:nl', + table: '85773NED', + measure: 'M001505_2', + freq: 'M', + title: 'House price index, Netherlands', + unit: 'Index 2020=100', + theme: 'economy' + }, + { + id: 'cbs:house:avgprice:nl:m', + table: '85773NED', + measure: 'M001534', + freq: 'M', + title: 'Average sale price of existing homes, Netherlands', + unit: 'EUR', + theme: 'economy' + }, + { + id: 'cbs:house:sold:nl:m', + table: '85773NED', + measure: 'M001532_2', + freq: 'M', + title: 'Existing homes sold, Netherlands', + unit: 'homes', + theme: 'economy' + }, + ...( + [ + ['T001258', 'total', 'Housing stock'], + ['A028868', 'owner', 'Owner-occupied homes'], + ['A047038', 'social', 'Housing association rentals'], + ['A047039', 'private', 'Other (private) rentals'] + ] as const + ).map(([code, key, title]): CbsSeriesDef => ({ + id: `cbs:stock:${key}`, + table: '86079NED', + measure: 'M000297', + dims: { + EigendomWoning: code, + Bouwjaar: 'T001018', + Woningtype: 'T001100', + BewoningWoonruimte: 'T001697' + }, + freq: 'A', + title, + unit: 'homes', + theme: 'gouda', + regionDim: 'RegioS', + regions: RING + })), + // Municipal levies per inhabitant (Gouda + ring) + ...( + [ + ['T001473', 'total', 'Municipal levies, total'], + ['A047000', 'ozb', 'Property tax (OZB) on homes'], + ['A047006', 'sewer', 'Sewerage charge'], + ['A047007', 'waste', 'Waste collection charge'] + ] as const + ).map(([code, key, title]): CbsSeriesDef => ({ + id: `cbs:levy:${key}`, + table: '83642NED', + measure: 'M001178_2', + dims: { GemeentelijkeHeffingenVanaf2017: code }, + freq: 'A', + title: `${title} per inhabitant`, + unit: 'EUR per inhabitant', + theme: 'gouda', + regionDim: 'RegioS', + regions: RING.filter((r) => r.code !== 'NL01') + })) +]; diff --git a/scripts/stats/eurostat.ts b/scripts/stats/eurostat.ts index 5936fa4..bfdecf8 100644 --- a/scripts/stats/eurostat.ts +++ b/scripts/stats/eurostat.ts @@ -64,8 +64,6 @@ export const EUROSTAT_DATASETS: Dataset[] = [ theme: 'energy', params: { nrg_cons: 'GJ20-199', currency: 'EUR', unit: 'KWH' } }, - { code: 'nrg_pc_204_h', theme: 'energy', params: { consom: '4161902', currency: 'EUR' } }, - { code: 'nrg_pc_202_h', theme: 'energy', params: { consom: '4141902', currency: 'EUR' } }, { code: 'nrg_ind_id', theme: 'energy', params: { siec: 'TOTAL' } }, { code: 'irt_lt_mcby_m', diff --git a/src/lib/components/ProjectIcon.svelte b/src/lib/components/ProjectIcon.svelte index 60b49ce..f719a1a 100644 --- a/src/lib/components/ProjectIcon.svelte +++ b/src/lib/components/ProjectIcon.svelte @@ -9,6 +9,9 @@ + + + {label} + {value}{unit} + {#if delta != null && Number.isFinite(delta)}{delta > 0 ? '▲' : delta < 0 ? '▼' : '•'} + {Math.abs(delta).toLocaleString('en-GB', { maximumFractionDigits: 2 })}{deltaUnit} + {/if}{sub} + + + diff --git a/src/lib/stats/StatChart.svelte b/src/lib/stats/StatChart.svelte new file mode 100644 index 0000000..6e1b5c7 --- /dev/null +++ b/src/lib/stats/StatChart.svelte @@ -0,0 +1,88 @@ + + +
+
+

{title}

+ {#if subtitle}{subtitle}{/if} +
+ {@render children?.()} + + {#if note}

{note}

{/if} +
{footer}
+
+ + diff --git a/src/lib/stats/chart.ts b/src/lib/stats/chart.ts new file mode 100644 index 0000000..0d1a099 --- /dev/null +++ b/src/lib/stats/chart.ts @@ -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; +} diff --git a/src/lib/stats/format.ts b/src/lib/stats/format.ts new file mode 100644 index 0000000..7cf87d6 --- /dev/null +++ b/src/lib/stats/format.ts @@ -0,0 +1,38 @@ +// Number and period formatting for /stats tiles and tables. +const cache = new Map(); +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`; +} diff --git a/src/lib/stats/regions.ts b/src/lib/stats/regions.ts new file mode 100644 index 0000000..da43d03 --- /dev/null +++ b/src/lib/stats/regions.ts @@ -0,0 +1,35 @@ +// Display names for the region codes used by Eurostat (geo), BIS (ref_area) and ISO3. +export const COUNTRY: Record = { + 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' +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index edd9838..52fed43 100755 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,7 +4,7 @@ import ProgressBar from '#lib/components/ProgressBar.svelte'; import ListeningCard from '#lib/music/ListeningCard.svelte'; import CatalogGrid from '#lib/components/CatalogGrid.svelte'; - import { projects, visibleTrackers } from '#lib/projects.js'; + import { projects, statsPages, visibleTrackers } from '#lib/projects.js'; import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ExternalLink from 'lucide-svelte/icons/external-link'; import Mail from 'lucide-svelte/icons/mail'; @@ -144,6 +144,17 @@ /> +
+

+ stats +

+ +
+

[t, ...(t.children ?? [])]) .map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })), - ...extraHits.map((t) => ({ ...t, kind: 'tracker' })) + ...extraHits.map((t) => ({ ...t, kind: 'tracker' })), + ...statsHits.map((t) => ({ ...t, kind: 'stats' })) ]); const hits = $derived.by(() => { const q = query.trim().toLowerCase(); @@ -559,6 +566,7 @@ >{servicesDown.length ? `${servicesDown.length} down` : 'all up'} {/if} + stats {#if data.weight}{data.weight.kg} kg{/if} diff --git a/src/routes/stats/+layout.server.ts b/src/routes/stats/+layout.server.ts new file mode 100644 index 0000000..3f2228e --- /dev/null +++ b/src/routes/stats/+layout.server.ts @@ -0,0 +1,11 @@ +import { cached, loadSources } from '#lib/server/stats.js'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ setHeaders }) => { + setHeaders({ 'cache-control': 'public, max-age=300' }); + const sources = await cached('stats:sources', loadSources); + return { + sources, + attributions: Object.fromEntries(Object.values(sources).map((s) => [s.id, s.attribution])) + }; +}; diff --git a/src/routes/stats/+layout.svelte b/src/routes/stats/+layout.svelte new file mode 100644 index 0000000..44e431a --- /dev/null +++ b/src/routes/stats/+layout.svelte @@ -0,0 +1,217 @@ + + +
+ + {@render children()} +
+ + diff --git a/src/routes/stats/+page.server.ts b/src/routes/stats/+page.server.ts new file mode 100644 index 0000000..e2503d8 --- /dev/null +++ b/src/routes/stats/+page.server.ts @@ -0,0 +1,29 @@ +import { cached, loadFreshness, loadSeries } from '#lib/server/stats.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const [series, freshness] = await Promise.all([ + cached('stats:overview', () => + loadSeries([ + { id: 'fred:DCOILBRENTEU', from: '2015-01-01', resample: 'W' }, + { id: 'fred:PNGASEUUSDM', from: '2015-01-01' }, + { id: 'bis:D:US', from: '2015-01-01', resample: 'W' }, + { id: 'bis:D:XM', from: '2015-01-01', resample: 'W' }, + { id: 'bis:D:KR', from: '2015-01-01', resample: 'W' }, + { id: 'eurostat:prc_hicp_minr:RCH_A.TOTAL.NL', from: '2015-01-01' }, + { id: 'eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20', from: '2015-01-01' }, + { id: 'cbs:cpi:yoy', from: '2015-01-01' }, + { id: 'eurostat:irt_lt_mcby_m:MCBY.FR', from: '2015-01-01' }, + { id: 'eurostat:irt_lt_mcby_m:MCBY.DE', from: '2015-01-01' }, + { id: 'cbs:tariff:elec:variable', from: '2015-01-01' }, + { id: 'cbs:tariff:elec:tax', from: '2015-01-01' }, + 'cbs:renew:total:share', + { id: 'cbs:pop:monthly:GM0513', from: '2015-01-01' }, + 'cbs:house:avgprice:GM0513', + 'cbs:house:avgprice:NL01' + ]) + ), + cached('stats:freshness', loadFreshness) + ]); + return { series, freshness }; +}; diff --git a/src/routes/stats/+page.svelte b/src/routes/stats/+page.svelte new file mode 100644 index 0000000..4072529 --- /dev/null +++ b/src/routes/stats/+page.svelte @@ -0,0 +1,323 @@ + + + + stats · Stan Runge + + + +
+ stats +

Statistics

+

+ Numbers I like to keep an eye on, pulled straight from official open data (CBS, Eurostat, FRED, + the BIS) and refreshed on a schedule. Every chart names its source. +

+
+ +
+ + + + + + + + + +
+ +
+ {#each SECTIONS as sec (sec.href)} + {@const Icon = sec.icon} + + + + {sec.title} + {sec.text} + + + + {/each} +
+ +
+ + lineOption( + t, + [ + { name: 'Fed', points: pts('bis:D:US'), color: '#3b82f6', kind: 'step' }, + { name: 'ECB', points: pts('bis:D:XM'), color: '#eab308', kind: 'step' }, + { name: 'Bank of Korea', points: pts('bis:D:KR'), color: '#e11d48', kind: 'step' } + ], + { unit: '%', decimals: 2 } + )} + /> + + lineOption( + t, + [ + { + name: 'Netherlands', + points: pts('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'), + color: '#f97316' + }, + { + name: 'Euro area', + points: pts('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'), + color: '#3b82f6' + } + ], + { unit: '%' } + )} + /> + + lineOption( + t, + [ + { name: 'Brent', points: pts('fred:DCOILBRENTEU'), color: '#64748b' }, + { name: 'EU gas (TTF)', points: pts('fred:PNGASEUUSDM'), color: '#3b82f6', right: true } + ], + { unit: '$/bbl', rightUnit: '$/MMBtu' } + )} + /> + + lineOption( + t, + [{ name: 'Gouda', points: pts('cbs:pop:monthly:GM0513'), color: '#f97316', kind: 'area' }], + { decimals: 0, legend: false, min: 'dataMin' } + )} + /> +
+ +

Sources and freshness

+
+ + + + + + {#each data.freshness.filter((f) => f.series > 0) as f (f.sourceId)} + + + + + + + + {/each} + +
SourceSeriesObservationsLatest dataLast refresh
{f.name}{num(f.series, 0)}{num(f.observations, 0)}{period(f.latestPeriod, 'D')} + {ago(f.lastOk)}
+
+

+ Data is fetched by scheduled jobs with a polite rate limit and stored as-is; charts are rendered + from the stored copy. Licences: CBS and Eurostat CC BY 4.0, FRED and BIS per their terms of use. +

+ + diff --git a/src/routes/stats/economy/+page.server.ts b/src/routes/stats/economy/+page.server.ts new file mode 100644 index 0000000..c229122 --- /dev/null +++ b/src/routes/stats/economy/+page.server.ts @@ -0,0 +1,40 @@ +import { cached, loadSeries } from '#lib/server/stats.js'; +import type { PageServerLoad } from './$types'; + +const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20']; +const YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA']; + +export const load: PageServerLoad = async () => ({ + series: await cached('stats:economy', () => + loadSeries([ + ...['US', 'XM', 'KR', 'CN', 'GB', 'JP'].map((a) => ({ + id: `bis:M:${a}`, + from: '1990-01-01' + })), + ...EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`), + { id: 'fred:CPIAUCSL', from: '1989-01-01' }, + ...YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`), + { id: 'fred:DGS10', from: '1990-01-01', resample: 'M' }, + { id: 'fred:DGS2', from: '1990-01-01', resample: 'M' }, + 'fred:IRLTLT01KRM156N', + ...['NL', 'KR', 'US', 'CN'].map((c) => ({ + id: `fred:SPASTT01${c}M661N`, + from: '1990-01-01' + })), + { id: 'fred:SP500', resample: 'W' }, + { id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' }, + ...EU.map((g) => `eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.${g}`), + ...EU.map((g) => `eurostat:nama_10_gdp:CP_MEUR.B1GQ.${g}`), + 'fred:GDPC1', + 'fred:UNRATE', + ...EU.flatMap((g) => [ + `eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.${g}`, + `eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.${g}`, + `eurostat:gov_10a_main:PC_GDP.S13.TE.${g}`, + `eurostat:gov_10a_main:PC_GDP.S13.TR.${g}`, + `eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`, + `eurostat:demo_pjan:NR.TOTAL.T.${g}` + ]) + ]) + ) +}); diff --git a/src/routes/stats/economy/+page.svelte b/src/routes/stats/economy/+page.svelte new file mode 100644 index 0000000..522ef05 --- /dev/null +++ b/src/routes/stats/economy/+page.svelte @@ -0,0 +1,406 @@ + + + + economy · stats · Stan Runge + + + +
+ stats / economy +

Economy

+

+ Central bank rates, inflation, 10-year bond yields and spreads, stock markets, growth, public + finances and migration. The Netherlands next to Europe, the US and South Korea, refreshed daily + from open sources. +

+
+ +
+ + + + + + + + +
+ +

Rates and inflation

+
+ + lineOption( + t, + RATES.map(([c, name, color]) => ({ name, color, kind: 'step', points: pts(`bis:M:${c}`) })), + { unit: '%', zoom: true } + )} + /> + + lineOption( + t, + [ + { name: 'Netherlands', points: hicp('NL'), color: '#f97316' }, + { name: 'Euro area', points: hicp('EA20'), color: '#10b981' }, + { name: 'Germany', points: hicp('DE'), color: '#64748b' }, + { name: 'United States', points: usCpi, color: '#3b82f6' } + ], + { unit: '%', zoom: true } + )} + /> +
+ +

Bond markets

+
+ + lineOption( + t, + [ + { name: 'Netherlands', points: y('NL'), color: '#f97316' }, + { name: 'Germany', points: y('DE'), color: '#64748b' }, + { name: 'France', points: y('FR'), color: '#3b82f6' }, + { name: 'Italy', points: y('IT'), color: '#10b981' }, + { name: 'Spain', points: y('ES'), color: '#eab308' }, + { name: 'United States', points: us10, color: '#a855f7' }, + { name: 'South Korea', points: pts('fred:IRLTLT01KRM156N'), color: '#e11d48' } + ], + { unit: '%', zoom: true } + )} + /> + + lineOption( + t, + [ + { name: 'France − Germany', points: frde, color: '#3b82f6', kind: 'area' }, + { name: 'Italy − Germany', points: itde, color: '#10b981' }, + { name: 'Netherlands − Germany', points: nlde, color: '#f97316' } + ], + { unit: 'pp', zoom: true } + )} + /> + + lineOption(t, [{ name: '10y − 2y', points: curve, color: '#a855f7', kind: 'area' }], { + unit: 'pp', + zoom: true, + legend: false + })} + /> + + lineOption( + t, + [ + { name: 'Netherlands', points: pts('fred:SPASTT01NLM661N'), color: '#f97316' }, + { name: 'United States', points: pts('fred:SPASTT01USM661N'), color: '#3b82f6' }, + { name: 'South Korea', points: pts('fred:SPASTT01KRM661N'), color: '#e11d48' }, + { name: 'China', points: pts('fred:SPASTT01CNM661N'), color: '#eab308' } + ], + { zoom: true } + )} + /> + + lineOption( + t, + [ + { name: 'S&P 500', points: pts('fred:SP500'), color: '#3b82f6' }, + { name: 'NASDAQ Composite', points: pts('fred:NASDAQCOM'), color: '#10b981', right: true } + ], + { zoom: true, unit: 'S&P', rightUnit: 'NASDAQ' } + )} + /> +
+ +

Growth and jobs

+
+ + lineOption( + t, + [ + { + name: 'Netherlands', + points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.NL'), + color: '#f97316', + kind: 'bar' + }, + { + name: 'EU 27', + points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.EU27_2020'), + color: '#3b82f6' + }, + { + name: 'Germany', + points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.DE'), + color: '#64748b' + } + ], + { unit: '%' } + )} + /> + + lineOption( + t, + [ + { name: 'Real GDP', points: pts('fred:GDPC1'), color: '#3b82f6', kind: 'area' }, + { name: 'Unemployment rate', points: pts('fred:UNRATE'), color: '#e11d48', right: true } + ], + { unit: 'USD bn', rightUnit: '%', zoom: true } + )} + /> +
+ +

Public finances and migration

+
+ barRankOption(t, debt, { unit: '% of GDP', color: '#a855f7' })} + /> + barRankOption(t, deficit, { unit: '% of GDP', color: '#10b981' })} + /> + + lineOption( + t, + [ + { + name: 'Spending', + points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.NL'), + color: '#e11d48' + }, + { + name: 'Revenue', + points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.NL'), + color: '#10b981' + }, + { + name: 'EU 27 spending', + points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.EU27_2020'), + color: '#e11d48', + dashed: true + }, + { + name: 'EU 27 revenue', + points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.EU27_2020'), + color: '#10b981', + dashed: true + } + ], + { unit: '% of GDP', min: 'dataMin' } + )} + /> + + lineOption( + t, + [ + { + name: 'Netherlands', + points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.NL'), + color: '#f97316', + kind: 'area' + }, + { + name: 'Belgium', + points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.BE'), + color: '#eab308' + }, + { + name: 'Sweden', + points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.SE'), + color: '#3b82f6' + }, + { + name: 'Austria', + points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.AT'), + color: '#e11d48' + } + ], + { decimals: 0 } + )} + /> + barRankOption(t, migrationPerK, { unit: 'per 1,000', color: '#06b6d4' })} + /> +
+

+ Policy rates: BIS central bank policy rates (month end). Yields for the euro countries are + Eurostat's EMU convergence-criterion series. +

diff --git a/src/routes/stats/energy/+page.server.ts b/src/routes/stats/energy/+page.server.ts new file mode 100644 index 0000000..4588582 --- /dev/null +++ b/src/routes/stats/energy/+page.server.ts @@ -0,0 +1,58 @@ +import { cached, loadSeries } from '#lib/server/stats.js'; +import type { PageServerLoad } from './$types'; + +const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020']; +const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro']; + +export const load: PageServerLoad = async () => ({ + series: await cached('stats:energy', () => + loadSeries([ + ...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map( + (k) => `cbs:tariff:elec:${k}` + ), + ...['variable', 'tax', 'transport', 'fixed'].map((k) => `cbs:tariff:gas:${k}`), + ...['variable', 'tax', 'ode'].flatMap((k) => [ + `cbs:tariff1823:elec:${k}`, + `cbs:tariff1823:gas:${k}` + ]), + 'cbs:price0723:elec', + 'cbs:price0723:gas', + 'cbs:cpi:energy:yoy', + ...EU.flatMap((g) => [ + `eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`, + `eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`, + `eurostat:nrg_ind_id:TOTAL.PC.${g}` + ]), + ...['X_TAX', 'X_VAT'].flatMap((t) => [ + `eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.${t}.EUR.NL`, + `eurostat:nrg_pc_202:G3000.GJ20-199.KWH.${t}.EUR.NL` + ]), + { id: 'fred:APU000072610', from: '2007-01-01' }, + { id: 'fred:DCOILBRENTEU', resample: 'W' }, + 'fred:PNGASEUUSDM', + 'cbs:energy:tpes', + 'cbs:energy:netimport', + 'cbs:energy:production', + 'cbs:energy:gas:production', + 'cbs:energy:gas:netimport', + 'cbs:energy:gas:tpes', + ...RENEW.flatMap((k) => [`cbs:renew:${k}:share`, `cbs:renew:${k}:mw`]), + 'cbs:heatpumps:homes', + 'cbs:heatpumps:homes:air', + 'cbs:heatpumps:homes:ground', + 'cbs:heatpumps:new', + ...['NL01', 'GM0513'].flatMap((r) => [ + `cbs:solar:homes:kwp:${r}`, + `cbs:solar:homes:n:${r}`, + `cbs:solar:kwp:${r}` + ]), + 'cbs:pop:jan1:GM0513', + 'cbs:cars:total', + 'cbs:cars:electric', + 'cbs:cars:petrol', + 'cbs:cars:diesel', + 'eurostat:road_eqr_carpda:NR.ELC.NL', + 'eurostat:road_eqr_carpda:NR.TOTAL.NL' + ]) + ) +}); diff --git a/src/routes/stats/energy/+page.svelte b/src/routes/stats/energy/+page.svelte new file mode 100644 index 0000000..14fc428 --- /dev/null +++ b/src/routes/stats/energy/+page.svelte @@ -0,0 +1,605 @@ + + + + energy · stats · Stan Runge + + + +
+ stats / energy +

Energy

+

+ What Dutch households pay for electricity and gas, how that compares with Europe and the US, and + how fast the Netherlands is switching: renewables, heat pumps, solar panels and electric cars. +

+
+ + + + Live grid data · hourly Dutch production, consumption and the energy mix in the + energy tracker + + + +
+ + + + + + + + +
+ +

Household tariffs in the Netherlands

+
+ + lineOption( + t, + [ + { + name: 'Average price 2007–2022 (2.5–5 MWh)', + points: pts('cbs:price0723:elec'), + color: '#64748b' + }, + { name: 'Variable + tax 2018–2022', points: elec1823, color: '#eab308', dashed: true }, + { name: 'Variable + tax (new contracts)', points: elecAllIn, color: '#eab308' }, + { name: 'Dynamic contract', points: pts('cbs:tariff:elec:dynamic'), color: '#10b981' } + ], + { unit: '€/kWh', decimals: 3, zoom: true } + )} + /> + + lineOption( + t, + [ + { name: 'Variable + tax 2018–2022', points: gas1823, color: '#f97316', dashed: true }, + { name: 'Variable + tax (new contracts)', points: gasAllIn, color: '#f97316' }, + { + name: 'of which energy tax', + points: pts('cbs:tariff:gas:tax'), + color: '#e11d48', + kind: 'area' + } + ], + { unit: '€/m³', decimals: 3, zoom: true } + )} + /> + + lineOption( + t, + [ + { + name: 'Supply (variable)', + points: [ + ...pts('cbs:tariff1823:elec:variable').filter(([d]) => d < '2021-01-01'), + ...pts('cbs:tariff:elec:variable') + ], + color: '#eab308', + kind: 'area', + stack: 'kwh' + }, + { + name: 'Energy tax', + points: [ + ...pts('cbs:tariff1823:elec:tax').filter(([d]) => d < '2021-01-01'), + ...pts('cbs:tariff:elec:tax') + ], + color: '#e11d48', + kind: 'area', + stack: 'kwh' + }, + { + name: 'ODE surcharge', + points: pts('cbs:tariff1823:elec:ode'), + color: '#a855f7', + kind: 'area', + stack: 'kwh' + } + ], + { unit: '€/kWh', decimals: 3 } + )} + /> + + lineOption( + t, + [ + { + name: 'Network (transport) tariff', + points: pts('cbs:tariff:elec:transport'), + color: '#3b82f6', + kind: 'step' + }, + { + name: 'Fixed supply tariff', + points: pts('cbs:tariff:elec:fixed'), + color: '#eab308', + kind: 'step' + }, + { + name: 'Energy tax reduction', + points: pts('cbs:tariff:elec:taxcredit'), + color: '#10b981', + kind: 'step' + }, + { + name: 'Gas network tariff', + points: pts('cbs:tariff:gas:transport'), + color: '#f97316', + kind: 'step' + } + ], + { unit: '€/year', decimals: 0 } + )} + /> +
+ +

Compared with Europe and the US

+
+ + barRankOption(t, elecRank.rows, { decimals: 3, unit: '€/kWh', color: '#eab308' })} + /> + + lineOption( + t, + [ + { name: 'Netherlands (€)', points: elecPrice('NL'), color: '#f97316', kind: 'step' }, + { name: 'Germany (€)', points: elecPrice('DE'), color: '#64748b', kind: 'step' }, + { name: 'EU 27 (€)', points: elecPrice('EU27_2020'), color: '#3b82f6', kind: 'step' }, + { name: 'United States ($)', points: usElec, color: '#e11d48' } + ], + { unit: 'per kWh', decimals: 3 } + )} + /> + barRankOption(t, gasRank.rows, { decimals: 3, unit: '€/kWh', color: '#f97316' })} + /> + + lineOption( + t, + [ + { + name: 'Electricity excl. taxes', + points: pts('eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.X_TAX.EUR.NL'), + color: '#eab308', + kind: 'step', + dashed: true + }, + { + name: 'Electricity incl. taxes', + points: elecPrice('NL'), + color: '#eab308', + kind: 'step' + }, + { + name: 'Gas excl. taxes', + points: pts('eurostat:nrg_pc_202:G3000.GJ20-199.KWH.X_TAX.EUR.NL'), + color: '#f97316', + kind: 'step', + dashed: true + }, + { name: 'Gas incl. taxes', points: gasPrice('NL'), color: '#f97316', kind: 'step' } + ], + { unit: '€/kWh', decimals: 3 } + )} + /> +
+ +

Oil, gas and imports

+
+ + lineOption( + t, + [ + { name: 'Brent', points: pts('fred:DCOILBRENTEU'), color: '#64748b' }, + { name: 'EU gas (TTF)', points: pts('fred:PNGASEUUSDM'), color: '#3b82f6', right: true } + ], + { unit: '$/bbl', rightUnit: '$/MMBtu', zoom: true } + )} + /> + + lineOption(t, [{ name: 'Netherlands', points: importDep, color: '#e11d48', kind: 'area' }], { + unit: '%', + legend: false + })} + /> + + lineOption( + t, + [ + { + name: 'Production', + points: pts('cbs:energy:gas:production'), + color: '#10b981', + kind: 'area' + }, + { name: 'Net imports', points: pts('cbs:energy:gas:netimport'), color: '#e11d48' }, + { name: 'Supply', points: pts('cbs:energy:gas:tpes'), color: '#64748b', dashed: true } + ], + { unit: 'PJ', decimals: 0 } + )} + /> + barRankOption(t, depRank.rows, { unit: '%', color: '#e11d48' })} + /> +
+ +

The switch

+
+ + lineOption( + t, + [ + { + name: 'Onshore wind', + points: pts('cbs:renew:wind-onshore:share'), + color: '#06b6d4', + kind: 'area', + stack: 'r' + }, + { + name: 'Offshore wind', + points: pts('cbs:renew:wind-offshore:share'), + color: '#3b82f6', + kind: 'area', + stack: 'r' + }, + { + name: 'Solar', + points: pts('cbs:renew:solar:share'), + color: '#eab308', + kind: 'area', + stack: 'r' + }, + { + name: 'Biomass', + points: pts('cbs:renew:biomass:share'), + color: '#84cc16', + kind: 'area', + stack: 'r' + }, + { + name: 'Hydro', + points: pts('cbs:renew:hydro:share'), + color: '#a855f7', + kind: 'area', + stack: 'r' + } + ], + { unit: '%' } + )} + /> + + lineOption( + t, + [ + { + name: 'Solar', + points: giga(pts('cbs:renew:solar:mw'), 1000), + color: '#eab308', + kind: 'bar' + }, + { + name: 'Onshore wind', + points: giga(pts('cbs:renew:wind-onshore:mw'), 1000), + color: '#06b6d4', + kind: 'bar' + }, + { + name: 'Offshore wind', + points: giga(pts('cbs:renew:wind-offshore:mw'), 1000), + color: '#3b82f6', + kind: 'bar' + } + ], + { unit: 'GW' } + )} + /> + + lineOption( + t, + [ + { + name: 'Air source', + points: pts('cbs:heatpumps:homes:air'), + color: '#06b6d4', + kind: 'area', + stack: 'hp' + }, + { + name: 'Ground / water source', + points: pts('cbs:heatpumps:homes:ground'), + color: '#10b981', + kind: 'area', + stack: 'hp' + }, + { + name: 'New per year (all buildings)', + points: pts('cbs:heatpumps:new'), + color: '#e11d48', + kind: 'bar', + right: true + } + ], + { decimals: 0, rightUnit: 'new' } + )} + /> + + lineOption( + t, + [ + { + name: 'Netherlands', + points: giga(pts('cbs:solar:homes:kwp:NL01'), 1000), + color: '#eab308', + kind: 'bar' + }, + { + name: 'Gouda', + points: giga(pts('cbs:solar:homes:kwp:GM0513'), 1000), + color: '#f97316', + right: true + } + ], + { unit: 'NL MWp', rightUnit: 'Gouda MWp', decimals: 1 } + )} + /> + + lineOption( + t, + [ + { name: 'Share of all cars on the road', points: evShare, color: '#a855f7', kind: 'bar' }, + { name: 'Share of new cars registered', points: evNewShare, color: '#10b981' } + ], + { unit: '%' } + )} + /> +
+ + diff --git a/src/routes/stats/gouda/+page.server.ts b/src/routes/stats/gouda/+page.server.ts new file mode 100644 index 0000000..698783c --- /dev/null +++ b/src/routes/stats/gouda/+page.server.ts @@ -0,0 +1,44 @@ +import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js'; +import type { PageServerLoad } from './$types'; + +// Gouda (GM0513) and its neighbours; NL01 is the national total. +const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632']; +const KWB_TABLE = '85984NED'; // Kerncijfers wijken en buurten 2024 +const KWB_MEASURES = [ + 'T001036', + 'M000100', + 'M001642', + '1014850_2', + 'A047047', + 'M000224', + 'M008349', + 'M000221_2', + 'M000219_2', + 'M008295', + 'M008297' +]; + +export const load: PageServerLoad = async () => { + const [series, wijken] = await Promise.all([ + cached('stats:gouda', () => + loadSeries([ + ...['NL01', ...RING].flatMap((r) => [ + `cbs:pop:monthly:${r}`, + `cbs:pop:jan1:${r}`, + `cbs:house:avgprice:${r}`, + ...['total', 'owner', 'social', 'private'].map((k) => `cbs:stock:${k}:${r}`) + ]), + ...RING.flatMap((r) => ['total', 'ozb', 'sewer', 'waste'].map((k) => `cbs:levy:${k}:${r}`)), + ...['births', 'deaths', 'immigration', 'emigration', 'in', 'out', 'growth'].map( + (k) => `cbs:pop:${k}:GM0513` + ), + 'cbs:house:hpi:cr28', + 'cbs:house:hpi:nl' + ]) + ), + cached('stats:gouda:kwb', () => + loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00']) + ) + ]); + return { series, wijken, kwbYear: 2024 }; +}; diff --git a/src/routes/stats/gouda/+page.svelte b/src/routes/stats/gouda/+page.svelte new file mode 100644 index 0000000..4d62731 --- /dev/null +++ b/src/routes/stats/gouda/+page.svelte @@ -0,0 +1,396 @@ + + + + Gouda · stats · Stan Runge + + + +
+ stats / gouda +

Gouda

+

+ My home town in numbers, next to its neighbours: Waddinxveen, Bodegraven-Reeuwijk, Zuidplas, + Krimpenerwaard, Alphen aan den Rijn and Woerden. +

+
+ +
+ + + + + +
+ +

People

+
+ + lineOption( + t, + [{ name: 'Gouda', points: pts('cbs:pop:jan1:GM0513'), color: '#f97316', kind: 'area' }], + { decimals: 0, legend: false } + )} + /> + + lineOption( + t, + ['NL01', ...RING].map((r) => ({ + name: NAMES[r], + points: indexed(pts(`cbs:pop:monthly:${r}`)), + color: COLORS[r], + dashed: r === 'NL01' + })), + { decimals: 1, zoom: true } + )} + /> + + lineOption( + t, + [ + { name: 'Births', points: pts('cbs:pop:births:GM0513'), color: '#10b981' }, + { name: 'Deaths', points: pts('cbs:pop:deaths:GM0513'), color: '#64748b' }, + { name: 'Immigration', points: pts('cbs:pop:immigration:GM0513'), color: '#3b82f6' }, + { name: 'Emigration', points: pts('cbs:pop:emigration:GM0513'), color: '#a855f7' }, + { name: 'Moved in', points: pts('cbs:pop:in:GM0513'), color: '#eab308', dashed: true }, + { name: 'Moved out', points: pts('cbs:pop:out:GM0513'), color: '#e11d48', dashed: true }, + { + name: 'Net growth', + points: pts('cbs:pop:growth:GM0513'), + color: '#f97316', + kind: 'bar' + } + ], + { decimals: 0, zoom: true } + )} + /> +
+ +

Housing

+
+ + lineOption( + t, + ['NL01', ...RING].map((r) => ({ + name: NAMES[r], + points: pts(`cbs:house:avgprice:${r}`), + color: COLORS[r], + dashed: r === 'NL01' + })), + { unit: '€', decimals: 0 } + )} + /> + + lineOption( + t, + [ + { name: 'Oost-Zuid-Holland', points: pts('cbs:house:hpi:cr28'), color: '#f97316' }, + { name: 'Netherlands', points: pts('cbs:house:hpi:nl'), color: '#0f172a', dashed: true } + ], + { decimals: 1, zoom: true } + )} + /> + barRankOption(t, priceRank, { decimals: 0, unit: 'k€', color: '#64748b' })} + /> + + lineOption( + t, + [ + { + name: 'Owner-occupied', + points: pts('cbs:stock:owner:GM0513'), + color: '#3b82f6', + kind: 'bar', + stack: 's' + }, + { + name: 'Social rent (housing corporation)', + points: pts('cbs:stock:social:GM0513'), + color: '#f97316', + kind: 'bar', + stack: 's' + }, + { + name: 'Private rent', + points: pts('cbs:stock:private:GM0513'), + color: '#a855f7', + kind: 'bar', + stack: 's' + } + ], + { decimals: 0 } + )} + /> + barRankOption(t, socialShare, { unit: '%', color: '#64748b' })} + /> + barRankOption(t, levyRank, { decimals: 0, unit: '€', color: '#64748b' })} + /> + + lineOption( + t, + [ + { + name: 'Property tax (OZB)', + points: pts('cbs:levy:ozb:GM0513'), + color: '#3b82f6', + kind: 'bar', + stack: 'l' + }, + { + name: 'Sewer charge', + points: pts('cbs:levy:sewer:GM0513'), + color: '#06b6d4', + kind: 'bar', + stack: 'l' + }, + { + name: 'Waste charge', + points: pts('cbs:levy:waste:GM0513'), + color: '#10b981', + kind: 'bar', + stack: 'l' + } + ], + { unit: '€', decimals: 0 } + )} + /> +
+ +

Neighbourhoods ({data.kwbYear})

+{#if wijken.length} +
+ + barRankOption(t, wijkBars('M001642'), { decimals: 0, unit: 'k€', color: '#e11d48' })} + /> + + barRankOption(t, wijkBars('M000224'), { decimals: 1, unit: 'k€', color: '#10b981' })} + /> +
+
+ + + + + {#each COLS as c (c.key)}{/each} + + + + {#each wijken as w (w.code)} + + + {#each COLS as c (c.key)}{/each} + + {/each} + {#each totals as w (w.code)} + + + {#each COLS as c (c.key)}{/each} + + {/each} + +
Neighbourhood{c.label}{c.unit ? ` (${c.unit})` : ''}
{w.title}{num(w.values[c.key], c.decimals)}
{w.title}{num(w.values[c.key], c.decimals)}
+
+

+ {data.attributions.cbs ?? ''} · Kerncijfers wijken en buurten {data.kwbYear}. Blank cells are + suppressed by CBS for privacy. +

+{:else} +

Neighbourhood figures are not loaded yet.

+{/if} + +