stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps, solar, cars, population (monthly, since 1942, components), house prices, housing stock, municipal levies for Gouda and its neighbours - shared StatChart/Kpi/chart helpers with per-chart source attribution - drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
This commit is contained in:
@@ -23,4 +23,570 @@ export const RING = [
|
|||||||
...GOUDA_RING.map((code) => ({ code, region: code }))
|
...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')
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export const EUROSTAT_DATASETS: Dataset[] = [
|
|||||||
theme: 'energy',
|
theme: 'energy',
|
||||||
params: { nrg_cons: 'GJ20-199', currency: 'EUR', unit: 'KWH' }
|
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: 'nrg_ind_id', theme: 'energy', params: { siec: 'TOTAL' } },
|
||||||
{
|
{
|
||||||
code: 'irt_lt_mcby_m',
|
code: 'irt_lt_mcby_m',
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||||
|
import ChartLine from 'lucide-svelte/icons/chart-line';
|
||||||
|
import Flame from 'lucide-svelte/icons/flame';
|
||||||
|
import Landmark from 'lucide-svelte/icons/landmark';
|
||||||
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
|
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
|
||||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||||
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||||
@@ -38,7 +41,11 @@
|
|||||||
'house tracker': House,
|
'house tracker': House,
|
||||||
lists: ListChecks,
|
lists: ListChecks,
|
||||||
health: HeartPulse,
|
health: HeartPulse,
|
||||||
books: BookOpen
|
books: BookOpen,
|
||||||
|
stats: ChartLine,
|
||||||
|
economy: Landmark,
|
||||||
|
energy: Flame,
|
||||||
|
gouda: House
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
import { projects, visibleTrackers, statsPages } from '#lib/projects.js';
|
||||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
||||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
{ label: 'home', href: '/' },
|
{ label: 'home', href: '/' },
|
||||||
{ label: 'projects', href: '/projects' },
|
{ label: 'projects', href: '/projects' },
|
||||||
{ label: 'trackers', href: '/trackers' },
|
{ label: 'trackers', href: '/trackers' },
|
||||||
|
{ label: 'stats', href: '/stats' },
|
||||||
{ label: 'blog', href: '/blog' },
|
{ label: 'blog', href: '/blog' },
|
||||||
{ label: 'lists', href: '/lists' }
|
{ label: 'lists', href: '/lists' }
|
||||||
] as const;
|
] as const;
|
||||||
@@ -65,7 +66,8 @@
|
|||||||
};
|
};
|
||||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||||
'/projects': { label: 'projects', items: projects },
|
'/projects': { label: 'projects', items: projects },
|
||||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) }
|
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) },
|
||||||
|
'/stats': { label: 'stats', items: statsPages }
|
||||||
});
|
});
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||||
@@ -274,7 +276,13 @@
|
|||||||
aria-label="Stan Runge home"
|
aria-label="Stan Runge home"
|
||||||
>
|
>
|
||||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||||
><img src={logo} alt="" width="112" height="112" class="h-full w-full object-cover" /></span
|
><img
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
width="112"
|
||||||
|
height="112"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/></span
|
||||||
>
|
>
|
||||||
<span>stan runge</span>
|
<span>stan runge</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ export const projects = [
|
|||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// The public /stats section: open data on the economy, energy and Gouda (src/routes/stats).
|
||||||
|
export const statsPages = [
|
||||||
|
{
|
||||||
|
name: 'economy',
|
||||||
|
subtitle: 'rates, inflation, bond spreads, GDP and debt',
|
||||||
|
href: '/stats/economy',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'energy',
|
||||||
|
subtitle: 'tariffs, oil and gas, renewables, heat pumps and EVs',
|
||||||
|
href: '/stats/energy',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gouda',
|
||||||
|
subtitle: 'population, housing and neighbourhoods in Gouda',
|
||||||
|
href: '/stats/gouda',
|
||||||
|
external: false
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
type Tracker = {
|
type Tracker = {
|
||||||
name: string;
|
name: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
|||||||
183
src/lib/server/stats.ts
Normal file
183
src/lib/server/stats.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Read side of the public /stats section: normalised series from stats.series/obs, source
|
||||||
|
// attributions and data freshness from stats.fetch_run. Everything here is open data.
|
||||||
|
import { client } from '#lib/server/db/index.js';
|
||||||
|
|
||||||
|
// Data changes at most hourly; keep page loads cheap with a short in-process cache.
|
||||||
|
const TTL_MS = 10 * 60 * 1000;
|
||||||
|
const memo = new Map<string, { at: number; value: Promise<unknown> }>();
|
||||||
|
export function cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const hit = memo.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.value as Promise<T>;
|
||||||
|
const value = fn().catch((error) => {
|
||||||
|
memo.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
memo.set(key, { at: Date.now(), value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatPoint = [period: string, value: number];
|
||||||
|
export type StatSeries = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
unit: string;
|
||||||
|
freq: string;
|
||||||
|
region: string | null;
|
||||||
|
sourceId: string;
|
||||||
|
points: StatPoint[];
|
||||||
|
};
|
||||||
|
export type SeriesRequest = {
|
||||||
|
id: string;
|
||||||
|
/** Only observations on or after this date (YYYY-MM-DD). */
|
||||||
|
from?: string;
|
||||||
|
/** Average daily data per month or week to keep payloads small. */
|
||||||
|
resample?: 'M' | 'W';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loads the requested series (missing ids are simply absent from the result). */
|
||||||
|
export async function loadSeries(
|
||||||
|
requests: (string | SeriesRequest)[]
|
||||||
|
): Promise<Record<string, StatSeries>> {
|
||||||
|
const reqs = requests.map((r) => (typeof r === 'string' ? { id: r } : r));
|
||||||
|
if (!reqs.length) return {};
|
||||||
|
const meta = await client<
|
||||||
|
{
|
||||||
|
series_id: string;
|
||||||
|
title: string;
|
||||||
|
unit: string;
|
||||||
|
freq: string;
|
||||||
|
region_code: string | null;
|
||||||
|
source_id: string;
|
||||||
|
}[]
|
||||||
|
>`select series_id, title, unit, freq, region_code, source_id from stats.series
|
||||||
|
where series_id = any(${reqs.map((r) => r.id)})`;
|
||||||
|
const out: Record<string, StatSeries> = {};
|
||||||
|
await Promise.all(
|
||||||
|
meta.map(async (m) => {
|
||||||
|
const req = reqs.find((r) => r.id === m.series_id)!;
|
||||||
|
const from = req.from ?? '0001-01-01';
|
||||||
|
const rows = req.resample
|
||||||
|
? await client<{ period: string; value: number }[]>`
|
||||||
|
select to_char(date_trunc(${req.resample === 'M' ? 'month' : 'week'}, period), 'YYYY-MM-DD') as period,
|
||||||
|
avg(value)::float8 as value
|
||||||
|
from stats.obs where series_id = ${m.series_id} and period >= ${from} and value is not null
|
||||||
|
group by 1 order by 1`
|
||||||
|
: await client<{ period: string; value: number }[]>`
|
||||||
|
select to_char(period, 'YYYY-MM-DD') as period, value from stats.obs
|
||||||
|
where series_id = ${m.series_id} and period >= ${from} and value is not null order by period`;
|
||||||
|
out[m.series_id] = {
|
||||||
|
id: m.series_id,
|
||||||
|
title: m.title,
|
||||||
|
unit: m.unit,
|
||||||
|
freq: m.freq,
|
||||||
|
region: m.region_code,
|
||||||
|
sourceId: m.source_id,
|
||||||
|
points: rows.map((r) => [r.period, Math.round(r.value * 1e6) / 1e6])
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatSource = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
attribution: string;
|
||||||
|
licence: string;
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
export async function loadSources(): Promise<Record<string, StatSource>> {
|
||||||
|
const rows = await client<
|
||||||
|
{ source_id: string; name: string; attribution: string; licence: string; base_url: string }[]
|
||||||
|
>`
|
||||||
|
select source_id, name, attribution, licence, base_url from stats.source order by source_id`;
|
||||||
|
return Object.fromEntries(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.source_id,
|
||||||
|
{
|
||||||
|
id: r.source_id,
|
||||||
|
name: r.name,
|
||||||
|
attribution: r.attribution,
|
||||||
|
licence: r.licence,
|
||||||
|
baseUrl: r.base_url
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Freshness = {
|
||||||
|
sourceId: string;
|
||||||
|
name: string;
|
||||||
|
lastOk: string | null;
|
||||||
|
lastRun: string | null;
|
||||||
|
lastRunOk: boolean | null;
|
||||||
|
rows: number | null;
|
||||||
|
series: number;
|
||||||
|
observations: number;
|
||||||
|
latestPeriod: string | null;
|
||||||
|
};
|
||||||
|
export async function loadFreshness(): Promise<Freshness[]> {
|
||||||
|
const rows = await client<
|
||||||
|
{
|
||||||
|
source_id: string;
|
||||||
|
name: string;
|
||||||
|
last_ok: Date | string | null;
|
||||||
|
last_run: Date | string | null;
|
||||||
|
last_run_ok: boolean | null;
|
||||||
|
rows: number | null;
|
||||||
|
series: number;
|
||||||
|
observations: number;
|
||||||
|
latest_period: string | null;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select s.source_id, s.name,
|
||||||
|
(select max(finished_at) from stats.fetch_run r where r.source_id = s.source_id and r.ok) as last_ok,
|
||||||
|
l.started_at as last_run, l.ok as last_run_ok, l.rows,
|
||||||
|
coalesce(c.series, 0)::int as series, coalesce(c.observations, 0)::int as observations,
|
||||||
|
to_char(c.latest, 'YYYY-MM-DD') as latest_period
|
||||||
|
from stats.source s
|
||||||
|
left join lateral (select started_at, ok, rows from stats.fetch_run r
|
||||||
|
where r.source_id = s.source_id order by started_at desc limit 1) l on true
|
||||||
|
left join lateral (select count(distinct se.series_id) as series, count(o.*) as observations, max(o.period) as latest
|
||||||
|
from stats.series se left join stats.obs o on o.series_id = se.series_id
|
||||||
|
where se.source_id = s.source_id) c on true
|
||||||
|
order by s.source_id`;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
sourceId: r.source_id,
|
||||||
|
name: r.name,
|
||||||
|
lastOk: r.last_ok ? new Date(r.last_ok).toISOString() : null,
|
||||||
|
lastRun: r.last_run ? new Date(r.last_run).toISOString() : null,
|
||||||
|
lastRunOk: r.last_run_ok,
|
||||||
|
rows: r.rows,
|
||||||
|
series: r.series,
|
||||||
|
observations: r.observations,
|
||||||
|
latestPeriod: r.latest_period
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows of a raw CBS table for one measure, grouped by a dimension (e.g. kwb per buurt). */
|
||||||
|
export async function loadCbsCross(
|
||||||
|
table: string,
|
||||||
|
measures: string[],
|
||||||
|
dim: string,
|
||||||
|
codePrefix: string[]
|
||||||
|
): Promise<{ code: string; title: string; values: Record<string, number | null> }[]> {
|
||||||
|
const rows = await client<
|
||||||
|
{ code: string; title: string; measure: string; value: number | null }[]
|
||||||
|
>`
|
||||||
|
select o.dims->>${dim} as code, coalesce(c.title, o.dims->>${dim}) as title, o.measure, o.value
|
||||||
|
from stats.cbs_obs o
|
||||||
|
left join stats.cbs_code c on c.table_id = o.table_id and c.dimension = ${dim} and c.code = o.dims->>${dim}
|
||||||
|
where o.table_id = ${table} and o.measure = any(${measures})
|
||||||
|
and left(o.dims->>${dim}, 6) = any(${codePrefix})`;
|
||||||
|
const byCode = new Map<
|
||||||
|
string,
|
||||||
|
{ code: string; title: string; values: Record<string, number | null> }
|
||||||
|
>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const entry = byCode.get(r.code) ?? { code: r.code, title: r.title, values: {} };
|
||||||
|
entry.values[r.measure] = r.value;
|
||||||
|
byCode.set(r.code, entry);
|
||||||
|
}
|
||||||
|
return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
}
|
||||||
50
src/lib/stats/Kpi.svelte
Normal file
50
src/lib/stats/Kpi.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// One overview tile: coloured dot, label, big value, and a sub line (period / change).
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit = '',
|
||||||
|
sub = '',
|
||||||
|
color = '#3b82f6',
|
||||||
|
delta = null,
|
||||||
|
deltaUnit = '',
|
||||||
|
invert = false,
|
||||||
|
href = ''
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
sub?: string;
|
||||||
|
color?: string;
|
||||||
|
delta?: number | null;
|
||||||
|
deltaUnit?: string;
|
||||||
|
/** Treat a rise as bad (red), e.g. prices. */
|
||||||
|
invert?: boolean;
|
||||||
|
href?: string;
|
||||||
|
} = $props();
|
||||||
|
const good = $derived(delta == null ? null : invert ? delta < 0 : delta > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element this={href ? 'a' : 'div'} class="kpi" href={href || undefined}>
|
||||||
|
<span class="kpi-label"><span class="dot" style:background={color}></span>{label}</span>
|
||||||
|
<span class="kpi-value">{value}<small>{unit}</small></span>
|
||||||
|
<span class="kpi-sub"
|
||||||
|
>{#if delta != null && Number.isFinite(delta)}<span class={good ? 'up' : 'down'}
|
||||||
|
>{delta > 0 ? '▲' : delta < 0 ? '▼' : '•'}
|
||||||
|
{Math.abs(delta).toLocaleString('en-GB', { maximumFractionDigits: 2 })}{deltaUnit}</span
|
||||||
|
>
|
||||||
|
{/if}{sub}</span
|
||||||
|
>
|
||||||
|
</svelte:element>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
small {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--site-muted);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
}
|
||||||
|
a.kpi:hover {
|
||||||
|
border-color: var(--site-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/lib/stats/StatChart.svelte
Normal file
88
src/lib/stats/StatChart.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// One /stats chart panel: title, optional subtitle, the chart, and an attribution footer
|
||||||
|
// built from stats.source.attribution for every source the chart uses.
|
||||||
|
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle = '',
|
||||||
|
option,
|
||||||
|
sources,
|
||||||
|
attributions,
|
||||||
|
height = '320px',
|
||||||
|
note = '',
|
||||||
|
wide = false,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
option: (theme: ChartTheme) => EChartsCoreOption;
|
||||||
|
/** Source ids (cbs, eurostat, fred, bis, …). */
|
||||||
|
sources: string[];
|
||||||
|
/** source id → attribution line (layout data). */
|
||||||
|
attributions: Record<string, string>;
|
||||||
|
height?: string;
|
||||||
|
note?: string;
|
||||||
|
wide?: boolean;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
const footer = $derived(
|
||||||
|
[...new Set(sources)]
|
||||||
|
.map((s) => attributions[s])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="stat-panel" class:wide>
|
||||||
|
<header>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{#if subtitle}<span>{subtitle}</span>{/if}
|
||||||
|
</header>
|
||||||
|
{@render children?.()}
|
||||||
|
<EnergyChart {option} label={title} {height} />
|
||||||
|
{#if note}<p class="note">{note}</p>{/if}
|
||||||
|
<figcaption>{footer}</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat-panel {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-bg);
|
||||||
|
padding: 1rem 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
header span,
|
||||||
|
.note {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-top: 1px solid var(--site-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
230
src/lib/stats/chart.ts
Normal file
230
src/lib/stats/chart.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// ECharts option builders for /stats: black-and-white chrome from the site theme, colourful
|
||||||
|
// series. Used through StatChart.svelte (which renders with the shared lazy ECharts wrapper).
|
||||||
|
// Same shape as ChartTheme in #lib/energy/EnergyChart.svelte (svelte module types can't be
|
||||||
|
// imported from plain .ts under tsgo).
|
||||||
|
export type ChartTheme = {
|
||||||
|
text: string;
|
||||||
|
muted: string;
|
||||||
|
border: string;
|
||||||
|
surface: string;
|
||||||
|
dark: boolean;
|
||||||
|
};
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
|
||||||
|
export type StatPoint = [string, number];
|
||||||
|
export type Line = {
|
||||||
|
name: string;
|
||||||
|
points: StatPoint[];
|
||||||
|
color?: string;
|
||||||
|
/** 'line' (default), 'bar', 'area' or 'step'. */
|
||||||
|
kind?: 'line' | 'bar' | 'area' | 'step';
|
||||||
|
dashed?: boolean;
|
||||||
|
/** Plot on the right-hand axis. */
|
||||||
|
right?: boolean;
|
||||||
|
stack?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PALETTE = [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#f97316', // orange
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#e11d48', // rose
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#84cc16', // lime
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#64748b' // slate
|
||||||
|
];
|
||||||
|
|
||||||
|
export type AxisOptions = {
|
||||||
|
unit?: string;
|
||||||
|
rightUnit?: string;
|
||||||
|
decimals?: number;
|
||||||
|
/** Category x axis (e.g. years as labels) instead of a time axis. */
|
||||||
|
category?: boolean;
|
||||||
|
zoom?: boolean;
|
||||||
|
legend?: boolean;
|
||||||
|
min?: number | 'dataMin';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (decimals: number) =>
|
||||||
|
new Intl.NumberFormat('en-GB', { maximumFractionDigits: decimals, minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
export function lineOption(
|
||||||
|
theme: ChartTheme,
|
||||||
|
lines: Line[],
|
||||||
|
o: AxisOptions = {}
|
||||||
|
): EChartsCoreOption {
|
||||||
|
const number = fmt(o.decimals ?? 2);
|
||||||
|
const axisLabel = { color: theme.muted, fontSize: 11 };
|
||||||
|
const splitLine = { lineStyle: { color: theme.border, opacity: 0.6 } };
|
||||||
|
const yAxis = (unit: string | undefined, right = false) => ({
|
||||||
|
type: 'value',
|
||||||
|
position: right ? 'right' : 'left',
|
||||||
|
scale: o.min === 'dataMin',
|
||||||
|
min: typeof o.min === 'number' ? o.min : undefined,
|
||||||
|
axisLabel: { ...axisLabel, formatter: (v: number) => fmt(2).format(v) },
|
||||||
|
splitLine: right ? { show: false } : splitLine,
|
||||||
|
name: unit,
|
||||||
|
nameTextStyle: { color: theme.muted, fontSize: 11, align: right ? 'right' : 'left' }
|
||||||
|
});
|
||||||
|
const hasRight = lines.some((l) => l.right);
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
grid: {
|
||||||
|
left: 8,
|
||||||
|
right: hasRight ? 8 : 16,
|
||||||
|
top: o.legend === false ? 28 : 56,
|
||||||
|
bottom: o.zoom ? 56 : 24,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
legend:
|
||||||
|
o.legend === false || lines.length < 2
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
type: 'scroll',
|
||||||
|
textStyle: { color: theme.muted, fontSize: 11 },
|
||||||
|
icon: 'roundRect',
|
||||||
|
itemWidth: 10,
|
||||||
|
itemHeight: 10
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
borderColor: theme.border,
|
||||||
|
textStyle: { color: theme.text, fontSize: 12 },
|
||||||
|
valueFormatter: (v: number) => (v == null ? '–' : number.format(v))
|
||||||
|
},
|
||||||
|
xAxis: o.category
|
||||||
|
? { type: 'category', axisLabel, axisLine: { lineStyle: { color: theme.border } } }
|
||||||
|
: {
|
||||||
|
type: 'time',
|
||||||
|
axisLabel,
|
||||||
|
axisLine: { lineStyle: { color: theme.border } },
|
||||||
|
splitLine: { show: false }
|
||||||
|
},
|
||||||
|
yAxis: hasRight ? [yAxis(o.unit), yAxis(o.rightUnit, true)] : yAxis(o.unit),
|
||||||
|
dataZoom: o.zoom
|
||||||
|
? [
|
||||||
|
{ type: 'inside', throttle: 50 },
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
height: 18,
|
||||||
|
bottom: 8,
|
||||||
|
borderColor: theme.border,
|
||||||
|
textStyle: { color: theme.muted },
|
||||||
|
fillerColor: 'rgba(127,127,127,0.15)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
series: lines.map((l, i) => {
|
||||||
|
const color = l.color ?? PALETTE[i % PALETTE.length];
|
||||||
|
const base = {
|
||||||
|
name: l.name,
|
||||||
|
data: l.points,
|
||||||
|
yAxisIndex: l.right ? 1 : 0,
|
||||||
|
itemStyle: { color },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
stack: l.stack
|
||||||
|
};
|
||||||
|
if (l.kind === 'bar') return { ...base, type: 'bar', barMaxWidth: 18 };
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type: 'line',
|
||||||
|
step: l.kind === 'step' ? 'end' : undefined,
|
||||||
|
showSymbol: l.points.length < 40,
|
||||||
|
symbolSize: 5,
|
||||||
|
sampling: 'lttb',
|
||||||
|
lineStyle: { width: 2, color, type: l.dashed ? 'dashed' : 'solid' },
|
||||||
|
areaStyle: l.kind === 'area' ? { opacity: 0.18, color } : undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal bar ranking (e.g. countries or neighbourhoods). */
|
||||||
|
export function barRankOption(
|
||||||
|
theme: ChartTheme,
|
||||||
|
rows: { label: string; value: number; highlight?: boolean }[],
|
||||||
|
o: { decimals?: number; unit?: string; color?: string } = {}
|
||||||
|
): EChartsCoreOption {
|
||||||
|
const number = fmt(o.decimals ?? 1);
|
||||||
|
const sorted = [...rows].sort((a, b) => a.value - b.value);
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
grid: { left: 8, right: 56, top: 8, bottom: 8, containLabel: true },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
borderColor: theme.border,
|
||||||
|
textStyle: { color: theme.text, fontSize: 12 },
|
||||||
|
valueFormatter: (v: number) => `${number.format(v)}${o.unit ? ` ${o.unit}` : ''}`
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { color: theme.muted, fontSize: 11 },
|
||||||
|
splitLine: { lineStyle: { color: theme.border, opacity: 0.6 } }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sorted.map((r) => r.label),
|
||||||
|
axisLabel: { color: theme.text, fontSize: 11 },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 16,
|
||||||
|
data: sorted.map((r) => ({
|
||||||
|
value: r.value,
|
||||||
|
itemStyle: {
|
||||||
|
color: r.highlight ? '#f97316' : (o.color ?? '#3b82f6'),
|
||||||
|
borderRadius: [0, 3, 3, 0]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
color: theme.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: (p: { value: number }) => number.format(p.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last point of a series, or null. */
|
||||||
|
export const latest = (points: StatPoint[] | undefined) =>
|
||||||
|
points?.length ? points[points.length - 1] : null;
|
||||||
|
|
||||||
|
/** Value a given number of periods back (by index), or null. */
|
||||||
|
export function change(points: StatPoint[] | undefined, back: number) {
|
||||||
|
if (!points || points.length <= back) return null;
|
||||||
|
const now = points[points.length - 1][1];
|
||||||
|
const then = points[points.length - 1 - back][1];
|
||||||
|
return { abs: now - then, pct: then ? ((now - then) / Math.abs(then)) * 100 : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Difference of two series on shared dates (a − b). */
|
||||||
|
export function spread(a: StatPoint[] = [], b: StatPoint[] = []): StatPoint[] {
|
||||||
|
const map = new Map(b);
|
||||||
|
return a
|
||||||
|
.filter(([d]) => map.has(d))
|
||||||
|
.map(([d, v]) => [d, Math.round((v - map.get(d)!) * 1000) / 1000]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year-over-year % change for monthly (12) or quarterly (4) points. */
|
||||||
|
export function yoy(points: StatPoint[] = [], lag = 12): StatPoint[] {
|
||||||
|
const out: StatPoint[] = [];
|
||||||
|
for (let i = lag; i < points.length; i++) {
|
||||||
|
const prev = points[i - lag][1];
|
||||||
|
if (prev) out.push([points[i][0], Math.round(((points[i][1] - prev) / prev) * 10000) / 100]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
38
src/lib/stats/format.ts
Normal file
38
src/lib/stats/format.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Number and period formatting for /stats tiles and tables.
|
||||||
|
const cache = new Map<number, Intl.NumberFormat>();
|
||||||
|
export function num(v: number | null | undefined, decimals = 1) {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '–';
|
||||||
|
let f = cache.get(decimals);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.NumberFormat('en-GB', {
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
minimumFractionDigits: decimals
|
||||||
|
});
|
||||||
|
cache.set(decimals, f);
|
||||||
|
}
|
||||||
|
return f.format(v);
|
||||||
|
}
|
||||||
|
export function compact(v: number | null | undefined) {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '–';
|
||||||
|
return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(
|
||||||
|
v
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
/** Period label for a series frequency: 2026 / Q3 2026 / Sep 2026 / 24 Oct 2026. */
|
||||||
|
export function period(p: string | null | undefined, freq = 'M') {
|
||||||
|
if (!p) return '–';
|
||||||
|
const [y, m, d] = p.split('-').map(Number);
|
||||||
|
if (freq === 'A') return String(y);
|
||||||
|
if (freq === 'S') return `H${m <= 6 ? 1 : 2} ${y}`;
|
||||||
|
if (freq === 'Q') return `Q${Math.floor((m - 1) / 3) + 1} ${y}`;
|
||||||
|
if (freq === 'M') return `${MONTHS[m - 1]} ${y}`;
|
||||||
|
return `${d} ${MONTHS[m - 1]} ${y}`;
|
||||||
|
}
|
||||||
|
export function ago(iso: string | null | undefined) {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
if (s < 3600) return `${Math.max(1, Math.round(s / 60))} min ago`;
|
||||||
|
if (s < 86400 * 2) return `${Math.round(s / 3600)} h ago`;
|
||||||
|
return `${Math.round(s / 86400)} days ago`;
|
||||||
|
}
|
||||||
35
src/lib/stats/regions.ts
Normal file
35
src/lib/stats/regions.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Display names for the region codes used by Eurostat (geo), BIS (ref_area) and ISO3.
|
||||||
|
export const COUNTRY: Record<string, string> = {
|
||||||
|
NL: 'Netherlands',
|
||||||
|
DE: 'Germany',
|
||||||
|
FR: 'France',
|
||||||
|
BE: 'Belgium',
|
||||||
|
IT: 'Italy',
|
||||||
|
ES: 'Spain',
|
||||||
|
PL: 'Poland',
|
||||||
|
SE: 'Sweden',
|
||||||
|
DK: 'Denmark',
|
||||||
|
AT: 'Austria',
|
||||||
|
EU27_2020: 'EU 27',
|
||||||
|
EA20: 'Euro area',
|
||||||
|
EA: 'Euro area',
|
||||||
|
US: 'United States',
|
||||||
|
XM: 'Euro area',
|
||||||
|
KR: 'South Korea',
|
||||||
|
CN: 'China',
|
||||||
|
GB: 'United Kingdom',
|
||||||
|
JP: 'Japan',
|
||||||
|
NLD: 'Netherlands',
|
||||||
|
USA: 'United States',
|
||||||
|
KOR: 'South Korea',
|
||||||
|
CHN: 'China',
|
||||||
|
CUW: 'Curaçao',
|
||||||
|
EUU: 'European Union',
|
||||||
|
WLD: 'World',
|
||||||
|
DEU: 'Germany',
|
||||||
|
FRA: 'France',
|
||||||
|
BEL: 'Belgium',
|
||||||
|
GBR: 'United Kingdom',
|
||||||
|
JPN: 'Japan',
|
||||||
|
IND: 'India'
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||||
import ListeningCard from '#lib/music/ListeningCard.svelte';
|
import ListeningCard from '#lib/music/ListeningCard.svelte';
|
||||||
import CatalogGrid from '#lib/components/CatalogGrid.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 ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
import Mail from 'lucide-svelte/icons/mail';
|
import Mail from 'lucide-svelte/icons/mail';
|
||||||
@@ -144,6 +144,17 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="stats-heading" class="border-t border-[var(--site-border)] pt-8">
|
||||||
|
<h2 id="stats-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
||||||
|
<a
|
||||||
|
href={resolve('/stats')}
|
||||||
|
class="inline-flex items-center gap-1 hover:text-[var(--site-muted)]"
|
||||||
|
>stats <ArrowRight size={20} aria-hidden="true" /></a
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
<CatalogGrid items={statsPages} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
||||||
<h2 id="lists-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
<h2 id="lists-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const staticPaths = [
|
|||||||
'/trackers',
|
'/trackers',
|
||||||
'/blog',
|
'/blog',
|
||||||
'/lists',
|
'/lists',
|
||||||
|
'/stats',
|
||||||
|
'/stats/economy',
|
||||||
|
'/stats/energy',
|
||||||
|
'/stats/gouda',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/spotting',
|
'/trackers/spotting',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
{ name: 'Weigh-in', href: '/trackers/health#body' },
|
{ name: 'Weigh-in', href: '/trackers/health#body' },
|
||||||
{ name: 'DB', href: '/lists/db' }
|
{ name: 'DB', href: '/lists/db' }
|
||||||
];
|
];
|
||||||
|
const statsHits = [
|
||||||
|
{ name: 'Stats', href: '/stats' },
|
||||||
|
{ name: 'Stats: economy', href: '/stats/economy' },
|
||||||
|
{ name: 'Stats: energy', href: '/stats/energy' },
|
||||||
|
{ name: 'Stats: Gouda', href: '/stats/gouda' }
|
||||||
|
];
|
||||||
|
|
||||||
const host = $derived(data.host);
|
const host = $derived(data.host);
|
||||||
|
|
||||||
@@ -328,7 +334,8 @@
|
|||||||
...trackers
|
...trackers
|
||||||
.flatMap((t) => [t, ...(t.children ?? [])])
|
.flatMap((t) => [t, ...(t.children ?? [])])
|
||||||
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
|
.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 hits = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -559,6 +566,7 @@
|
|||||||
>{servicesDown.length ? `${servicesDown.length} down` : 'all up'}</span
|
>{servicesDown.length ? `${servicesDown.length} down` : 'all up'}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
<a class="chip" href="/stats" title="Economy, energy and Gouda statistics">stats</a>
|
||||||
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
|
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
|
||||||
>{data.weight.kg} kg</a
|
>{data.weight.kg} kg</a
|
||||||
>{/if}
|
>{/if}
|
||||||
|
|||||||
11
src/routes/stats/+layout.server.ts
Normal file
11
src/routes/stats/+layout.server.ts
Normal file
@@ -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]))
|
||||||
|
};
|
||||||
|
};
|
||||||
217
src/routes/stats/+layout.svelte
Normal file
217
src/routes/stats/+layout.svelte
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { statsPages } from '#lib/projects.js';
|
||||||
|
import ChartLine from 'lucide-svelte/icons/chart-line';
|
||||||
|
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';
|
||||||
|
let { children } = $props();
|
||||||
|
const icons = {
|
||||||
|
overview: ChartLine,
|
||||||
|
economy: Landmark,
|
||||||
|
energy: Flame,
|
||||||
|
gouda: House,
|
||||||
|
countries: Globe
|
||||||
|
} as const;
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'overview', href: '/stats' },
|
||||||
|
...statsPages.map((p) => ({ name: p.name, href: p.href }))
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="stats-page">
|
||||||
|
<nav class="stats-tabs" aria-label="Stats sections">
|
||||||
|
{#each tabs as tab (tab.href)}
|
||||||
|
{@const Icon = icons[tab.name as keyof typeof icons] ?? ChartLine}
|
||||||
|
<a
|
||||||
|
href={resolve(tab.href as '/stats')}
|
||||||
|
class:active={page.url.pathname === tab.href}
|
||||||
|
aria-current={page.url.pathname === tab.href ? 'page' : undefined}
|
||||||
|
><span class="tab-icon tab-{tab.name}"><Icon size={14} /></span>{tab.name}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats-page {
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.stats-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
border-bottom: 1px solid var(--site-border);
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.stats-tabs a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.stats-tabs a:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.stats-tabs a.active {
|
||||||
|
background: var(--site-text);
|
||||||
|
border-color: var(--site-text);
|
||||||
|
color: var(--site-bg);
|
||||||
|
}
|
||||||
|
.tab-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tab-overview {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
/* Shared page furniture for every /stats page. */
|
||||||
|
.stats-page :global(.stats-intro) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-eyebrow) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-intro h1) {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-intro p) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-grid) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-section-title) {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.kpis) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 170px), 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.kpi) {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.stats-page :global(.kpi .kpi-label) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.kpi .kpi-value) {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats-page :global(.kpi .kpi-sub) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.up) {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
.stats-page :global(.down) {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.stats-page :global(.dot) {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-table th),
|
||||||
|
.stats-page :global(.stats-table td) {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--site-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-table th) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-table td.num) {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats-page :global(.table-wrap) {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.stats-note) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.stats-page :global(.empty) {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/stats/+page.server.ts
Normal file
29
src/routes/stats/+page.server.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
323
src/routes/stats/+page.svelte
Normal file
323
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import Kpi from '#lib/stats/Kpi.svelte';
|
||||||
|
import StatChart from '#lib/stats/StatChart.svelte';
|
||||||
|
import { change, latest, lineOption, spread, type StatPoint } from '#lib/stats/chart.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 ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.series);
|
||||||
|
const a = $derived(data.attributions);
|
||||||
|
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||||
|
const last = (id: string) => latest(s[id]?.points);
|
||||||
|
const delta = (id: string, back: number) => change(pts(id), back)?.abs ?? null;
|
||||||
|
|
||||||
|
const frde = $derived(
|
||||||
|
spread(pts('eurostat:irt_lt_mcby_m:MCBY.FR'), pts('eurostat:irt_lt_mcby_m:MCBY.DE'))
|
||||||
|
);
|
||||||
|
const elec = $derived.by(() => {
|
||||||
|
const tax = new Map(pts('cbs:tariff:elec:tax'));
|
||||||
|
return pts('cbs:tariff:elec:variable')
|
||||||
|
.filter(([d]) => tax.has(d))
|
||||||
|
.map(([d, v]) => [d, Math.round((v + tax.get(d)!) * 1000) / 1000] as StatPoint);
|
||||||
|
});
|
||||||
|
const k = $derived({
|
||||||
|
brent: last('fred:DCOILBRENTEU'),
|
||||||
|
ttf: last('fred:PNGASEUUSDM'),
|
||||||
|
fed: last('bis:D:US'),
|
||||||
|
ecb: last('bis:D:XM'),
|
||||||
|
bok: last('bis:D:KR'),
|
||||||
|
cpi: last('cbs:cpi:yoy'),
|
||||||
|
ea: last('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'),
|
||||||
|
frde: latest(frde),
|
||||||
|
elec: latest(elec),
|
||||||
|
renew: last('cbs:renew:total:share'),
|
||||||
|
pop: last('cbs:pop:monthly:GM0513'),
|
||||||
|
house: last('cbs:house:avgprice:GM0513'),
|
||||||
|
houseNl: last('cbs:house:avgprice:NL01')
|
||||||
|
});
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
href: '/stats/economy',
|
||||||
|
title: 'Economy',
|
||||||
|
icon: Landmark,
|
||||||
|
grad: 'linear-gradient(135deg,#3b82f6,#a855f7)',
|
||||||
|
text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/stats/energy',
|
||||||
|
title: 'Energy',
|
||||||
|
icon: Flame,
|
||||||
|
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.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/stats/gouda',
|
||||||
|
title: 'Gouda',
|
||||||
|
icon: House,
|
||||||
|
grad: 'linear-gradient(135deg,#10b981,#06b6d4)',
|
||||||
|
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>stats · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Public statistics I follow, refreshed automatically from CBS, Eurostat, FRED and the BIS: the economy, energy and my home town Gouda."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="stats-intro">
|
||||||
|
<span class="stats-eyebrow">stats</span>
|
||||||
|
<h1>Statistics</h1>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<Kpi
|
||||||
|
href="/stats/energy"
|
||||||
|
label="Brent crude"
|
||||||
|
value={num(k.brent?.[1], 1)}
|
||||||
|
unit="$/bbl"
|
||||||
|
sub={` 4 wk · ${period(k.brent?.[0], 'D')}`}
|
||||||
|
delta={delta('fred:DCOILBRENTEU', 4)}
|
||||||
|
invert
|
||||||
|
color="#64748b"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/energy"
|
||||||
|
label="EU gas (TTF)"
|
||||||
|
value={num(k.ttf?.[1], 1)}
|
||||||
|
unit="$/MMBtu"
|
||||||
|
sub={` 1 mo · ${period(k.ttf?.[0])}`}
|
||||||
|
delta={delta('fred:PNGASEUUSDM', 1)}
|
||||||
|
invert
|
||||||
|
color="#3b82f6"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/energy"
|
||||||
|
label="NL electricity"
|
||||||
|
value={num(k.elec?.[1], 3)}
|
||||||
|
unit="€/kWh"
|
||||||
|
sub={`variable + tax · ${period(k.elec?.[0])}`}
|
||||||
|
color="#eab308"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/energy"
|
||||||
|
label="Renewable electricity"
|
||||||
|
value={num(k.renew?.[1], 0)}
|
||||||
|
unit="%"
|
||||||
|
sub={period(k.renew?.[0], 'A')}
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/economy"
|
||||||
|
label="Fed / ECB / BoK"
|
||||||
|
value={`${num(k.fed?.[1], 2)} / ${num(k.ecb?.[1], 2)} / ${num(k.bok?.[1], 2)}`}
|
||||||
|
unit="%"
|
||||||
|
sub={`policy rates · ${period(k.fed?.[0], 'D')}`}
|
||||||
|
color="#a855f7"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/economy"
|
||||||
|
label="Dutch inflation"
|
||||||
|
value={num(k.cpi?.[1], 1)}
|
||||||
|
unit="%"
|
||||||
|
sub={`CPI y/y · euro area ${num(k.ea?.[1], 1)}% · ${period(k.cpi?.[0])}`}
|
||||||
|
color="#e11d48"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/economy"
|
||||||
|
label="France – Germany 10y"
|
||||||
|
value={num(k.frde ? k.frde[1] * 100 : null, 0)}
|
||||||
|
unit="bp"
|
||||||
|
sub={period(k.frde?.[0])}
|
||||||
|
color="#06b6d4"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/gouda"
|
||||||
|
label="Gouda inhabitants"
|
||||||
|
value={num(k.pop?.[1], 0)}
|
||||||
|
sub={` in a year · ${period(k.pop?.[0])}`}
|
||||||
|
delta={delta('cbs:pop:monthly:GM0513', 12)}
|
||||||
|
color="#f97316"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
href="/stats/gouda"
|
||||||
|
label="Gouda avg house price"
|
||||||
|
value={`€${compact(k.house?.[1])}`}
|
||||||
|
sub={`${period(k.house?.[0], 'A')} · NL €${compact(k.houseNl?.[1])}`}
|
||||||
|
color="#84cc16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sections">
|
||||||
|
{#each SECTIONS as sec (sec.href)}
|
||||||
|
{@const Icon = sec.icon}
|
||||||
|
<a class="section-card" href={resolve(sec.href)}>
|
||||||
|
<span class="section-icon" style:background={sec.grad}><Icon size={20} /></span>
|
||||||
|
<span class="section-body">
|
||||||
|
<strong>{sec.title}</strong>
|
||||||
|
<span>{sec.text}</span>
|
||||||
|
</span>
|
||||||
|
<span class="section-arrow"><ArrowRight size={16} /></span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Policy rates since 2015"
|
||||||
|
subtitle="% · Fed, ECB deposit rate, Bank of Korea"
|
||||||
|
sources={['bis']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Inflation: Netherlands vs euro area"
|
||||||
|
subtitle="% year on year (HICP)"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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: '%' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Brent crude and EU gas"
|
||||||
|
subtitle="$/barrel (weekly) · $/MMBtu (monthly)"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Gouda's population"
|
||||||
|
subtitle="inhabitants, monthly"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
[{ name: 'Gouda', points: pts('cbs:pop:monthly:GM0513'), color: '#f97316', kind: 'area' }],
|
||||||
|
{ decimals: 0, legend: false, min: 'dataMin' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Sources and freshness</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
><th>Source</th><th class="num">Series</th><th class="num">Observations</th><th
|
||||||
|
>Latest data</th
|
||||||
|
><th>Last refresh</th></tr
|
||||||
|
>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.freshness.filter((f) => f.series > 0) as f (f.sourceId)}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{f.name}</strong></td>
|
||||||
|
<td class="num">{num(f.series, 0)}</td>
|
||||||
|
<td class="num">{num(f.observations, 0)}</td>
|
||||||
|
<td>{period(f.latestPeriod, 'D')}</td>
|
||||||
|
<td
|
||||||
|
><span class="dot" style:background={f.lastRunOk === false ? '#e11d48' : '#10b981'}
|
||||||
|
></span>
|
||||||
|
{ago(f.lastOk)}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="stats-note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sections {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
}
|
||||||
|
.section-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
color: var(--site-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.section-card:hover {
|
||||||
|
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 {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.section-body strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.section-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/routes/stats/economy/+page.server.ts
Normal file
40
src/routes/stats/economy/+page.server.ts
Normal file
@@ -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}`
|
||||||
|
])
|
||||||
|
])
|
||||||
|
)
|
||||||
|
});
|
||||||
406
src/routes/stats/economy/+page.svelte
Normal file
406
src/routes/stats/economy/+page.svelte
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import StatChart from '#lib/stats/StatChart.svelte';
|
||||||
|
import Kpi from '#lib/stats/Kpi.svelte';
|
||||||
|
import { barRankOption, latest, lineOption, spread, yoy } from '#lib/stats/chart.js';
|
||||||
|
import { num, period } from '#lib/stats/format.js';
|
||||||
|
import { COUNTRY } from '#lib/stats/regions.js';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.series);
|
||||||
|
const a = $derived(data.attributions);
|
||||||
|
const pts = (id: string) => s[id]?.points ?? [];
|
||||||
|
|
||||||
|
const RATES = [
|
||||||
|
['US', 'Fed', '#3b82f6'],
|
||||||
|
['XM', 'ECB', '#eab308'],
|
||||||
|
['KR', 'Bank of Korea', '#e11d48'],
|
||||||
|
['GB', 'Bank of England', '#a855f7'],
|
||||||
|
['JP', 'Bank of Japan', '#10b981'],
|
||||||
|
['CN', 'PBoC', '#f97316']
|
||||||
|
] as const;
|
||||||
|
const usCpi = $derived(yoy(pts('fred:CPIAUCSL')).filter(([d]) => d >= '1997-01-01'));
|
||||||
|
const hicp = (g: string) => pts(`eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`);
|
||||||
|
const y = (g: string) => pts(`eurostat:irt_lt_mcby_m:MCBY.${g}`);
|
||||||
|
const frde = $derived(spread(y('FR'), y('DE')));
|
||||||
|
const itde = $derived(spread(y('IT'), y('DE')));
|
||||||
|
const nlde = $derived(spread(y('NL'), y('DE')));
|
||||||
|
const us10 = $derived(pts('fred:DGS10'));
|
||||||
|
const curve = $derived(spread(us10, pts('fred:DGS2')));
|
||||||
|
const EU_LIST = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
|
||||||
|
const lastOf = (id: string) => latest(s[id]?.points);
|
||||||
|
const lastYear = (prefix: string) =>
|
||||||
|
EU_LIST.map((g) => ({ g, p: lastOf(`${prefix}.${g}`) }))
|
||||||
|
.filter((r) => r.p)
|
||||||
|
.map((r) => ({
|
||||||
|
label: COUNTRY[r.g] ?? r.g,
|
||||||
|
value: r.p![1],
|
||||||
|
highlight: r.g === 'NL',
|
||||||
|
year: r.p![0].slice(0, 4)
|
||||||
|
}));
|
||||||
|
const debt = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.GD'));
|
||||||
|
const deficit = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.B9'));
|
||||||
|
const migrationPerK = $derived(
|
||||||
|
EU_LIST.filter((g) => !g.startsWith('E'))
|
||||||
|
.map((g) => {
|
||||||
|
const m = lastOf(`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`);
|
||||||
|
const pop = pts(`eurostat:demo_pjan:NR.TOTAL.T.${g}`).find(([d]) => m && d === m[0]);
|
||||||
|
return m && pop
|
||||||
|
? { label: COUNTRY[g] ?? g, value: (m[1] / pop[1]) * 1000, highlight: g === 'NL' }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((r) => r != null)
|
||||||
|
);
|
||||||
|
const k = (id: string) => lastOf(id);
|
||||||
|
const fed = $derived(k('bis:M:US'));
|
||||||
|
const ecb = $derived(k('bis:M:XM'));
|
||||||
|
const bok = $derived(k('bis:M:KR'));
|
||||||
|
const nlInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'));
|
||||||
|
const eaInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'));
|
||||||
|
const usInfl = $derived(latest(usCpi));
|
||||||
|
const spreadNow = $derived(latest(frde));
|
||||||
|
const us10Now = $derived(latest(us10));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>economy · stats · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Interest rates, inflation, bond spreads, stock indices, GDP, public debt and migration: the Netherlands, Europe, the US and Korea."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="stats-intro">
|
||||||
|
<span class="stats-eyebrow">stats / economy</span>
|
||||||
|
<h1>Economy</h1>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<Kpi
|
||||||
|
label="Fed policy rate"
|
||||||
|
value={num(fed?.[1], 2)}
|
||||||
|
unit="%"
|
||||||
|
sub={period(fed?.[0])}
|
||||||
|
color="#3b82f6"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="ECB policy rate"
|
||||||
|
value={num(ecb?.[1], 2)}
|
||||||
|
unit="%"
|
||||||
|
sub={period(ecb?.[0])}
|
||||||
|
color="#eab308"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Bank of Korea"
|
||||||
|
value={num(bok?.[1], 2)}
|
||||||
|
unit="%"
|
||||||
|
sub={period(bok?.[0])}
|
||||||
|
color="#e11d48"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="NL inflation (HICP)"
|
||||||
|
value={num(nlInfl?.[1])}
|
||||||
|
unit="%"
|
||||||
|
sub={period(nlInfl?.[0])}
|
||||||
|
color="#f97316"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Euro area inflation"
|
||||||
|
value={num(eaInfl?.[1])}
|
||||||
|
unit="%"
|
||||||
|
sub={period(eaInfl?.[0])}
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="US inflation (CPI)"
|
||||||
|
value={num(usInfl?.[1])}
|
||||||
|
unit="%"
|
||||||
|
sub={period(usInfl?.[0])}
|
||||||
|
color="#06b6d4"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="France–Germany 10y spread"
|
||||||
|
value={num(spreadNow ? spreadNow[1] * 100 : null, 0)}
|
||||||
|
unit="bp"
|
||||||
|
sub={period(spreadNow?.[0])}
|
||||||
|
color="#a855f7"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="US 10-year yield"
|
||||||
|
value={num(us10Now?.[1], 2)}
|
||||||
|
unit="%"
|
||||||
|
sub={period(us10Now?.[0])}
|
||||||
|
color="#64748b"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Rates and inflation</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Central bank policy rates"
|
||||||
|
subtitle="% · monthly"
|
||||||
|
sources={['bis']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
RATES.map(([c, name, color]) => ({ name, color, kind: 'step', points: pts(`bis:M:${c}`) })),
|
||||||
|
{ unit: '%', zoom: true }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Inflation"
|
||||||
|
subtitle="% year on year · HICP (Europe), CPI-U (US)"
|
||||||
|
sources={['eurostat', 'fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Bond markets</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="10-year government bond yields"
|
||||||
|
subtitle="% · monthly (EMU convergence criterion yields; US Treasury, Korea via OECD)"
|
||||||
|
sources={['eurostat', 'fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Spreads over Germany"
|
||||||
|
subtitle="percentage points · 10-year yield minus the German Bund"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="US yield curve: 10-year minus 2-year"
|
||||||
|
subtitle="percentage points · monthly average · below zero = inverted"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(t, [{ name: '10y − 2y', points: curve, color: '#a855f7', kind: 'area' }], {
|
||||||
|
unit: 'pp',
|
||||||
|
zoom: true,
|
||||||
|
legend: false
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Share prices"
|
||||||
|
subtitle="index 2015 = 100 · OECD main economic indicators"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="S&P 500 and NASDAQ Composite"
|
||||||
|
subtitle="index points · weekly average"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
wide
|
||||||
|
option={(t) =>
|
||||||
|
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' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Growth and jobs</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Real GDP growth"
|
||||||
|
subtitle="% change on the previous year"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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: '%' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="US real GDP and unemployment"
|
||||||
|
subtitle="billions of chained 2017 USD (SAAR) · unemployment %"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Public finances and migration</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Government debt"
|
||||||
|
subtitle={`% of GDP · ${debt[0]?.year ?? ''}`}
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) => barRankOption(t, debt, { unit: '% of GDP', color: '#a855f7' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Government balance"
|
||||||
|
subtitle={`surplus (+) or deficit (−), % of GDP · ${deficit[0]?.year ?? ''}`}
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) => barRankOption(t, deficit, { unit: '% of GDP', color: '#10b981' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Dutch government: spending vs revenue"
|
||||||
|
subtitle="% of GDP · general government"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Immigration"
|
||||||
|
subtitle="people arriving per year"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Immigration per 1,000 inhabitants"
|
||||||
|
subtitle="latest year"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="340px"
|
||||||
|
option={(t) => barRankOption(t, migrationPerK, { unit: 'per 1,000', color: '#06b6d4' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="stats-note">
|
||||||
|
Policy rates: BIS central bank policy rates (month end). Yields for the euro countries are
|
||||||
|
Eurostat's EMU convergence-criterion series.
|
||||||
|
</p>
|
||||||
58
src/routes/stats/energy/+page.server.ts
Normal file
58
src/routes/stats/energy/+page.server.ts
Normal file
@@ -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'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
});
|
||||||
605
src/routes/stats/energy/+page.svelte
Normal file
605
src/routes/stats/energy/+page.svelte
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import StatChart from '#lib/stats/StatChart.svelte';
|
||||||
|
import Kpi from '#lib/stats/Kpi.svelte';
|
||||||
|
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||||
|
import { compact, num, period } from '#lib/stats/format.js';
|
||||||
|
import { COUNTRY } from '#lib/stats/regions.js';
|
||||||
|
import Zap from 'lucide-svelte/icons/zap';
|
||||||
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.series);
|
||||||
|
const a = $derived(data.attributions);
|
||||||
|
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||||
|
const last = (id: string) => latest(s[id]?.points);
|
||||||
|
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||||
|
|
||||||
|
/** a ÷ b × k on shared dates. */
|
||||||
|
function ratio(x: StatPoint[], y: StatPoint[], k = 100): StatPoint[] {
|
||||||
|
const m = new Map(y);
|
||||||
|
return x
|
||||||
|
.filter(([d]) => m.get(d))
|
||||||
|
.map(([d, v]) => [d, Math.round((v / m.get(d)!) * k * 100) / 100]);
|
||||||
|
}
|
||||||
|
function sum(...series: StatPoint[][]): StatPoint[] {
|
||||||
|
const maps = series.map((x) => new Map(x));
|
||||||
|
return series[0]
|
||||||
|
.filter(([d]) => maps.every((m) => m.has(d)))
|
||||||
|
.map(([d]) => [d, Math.round(maps.reduce((t, m) => t + m.get(d)!, 0) * 10000) / 10000]);
|
||||||
|
}
|
||||||
|
const elecAllIn = $derived(sum(pts('cbs:tariff:elec:variable'), pts('cbs:tariff:elec:tax')));
|
||||||
|
const gasAllIn = $derived(sum(pts('cbs:tariff:gas:variable'), pts('cbs:tariff:gas:tax')));
|
||||||
|
const elec1823 = $derived(
|
||||||
|
sum(pts('cbs:tariff1823:elec:variable'), pts('cbs:tariff1823:elec:tax'))
|
||||||
|
);
|
||||||
|
const gas1823 = $derived(sum(pts('cbs:tariff1823:gas:variable'), pts('cbs:tariff1823:gas:tax')));
|
||||||
|
const importDep = $derived(ratio(pts('cbs:energy:netimport'), pts('cbs:energy:tpes')));
|
||||||
|
const evShare = $derived(ratio(pts('cbs:cars:electric'), pts('cbs:cars:total')));
|
||||||
|
const evNewShare = $derived(
|
||||||
|
ratio(pts('eurostat:road_eqr_carpda:NR.ELC.NL'), pts('eurostat:road_eqr_carpda:NR.TOTAL.NL'))
|
||||||
|
);
|
||||||
|
const elecPrice = (g: string) => pts(`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`);
|
||||||
|
const gasPrice = (g: string) => pts(`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`);
|
||||||
|
const ranking = (f: (g: string) => StatPoint[]) => {
|
||||||
|
const rows = EU.map((g) => ({ g, p: latest(f(g)) })).filter((r) => r.p);
|
||||||
|
const newest = rows.reduce((m, r) => (r.p![0] > m ? r.p![0] : m), '');
|
||||||
|
return {
|
||||||
|
when: newest,
|
||||||
|
rows: rows
|
||||||
|
.filter((r) => r.p![0] === newest)
|
||||||
|
.map((r) => ({ label: COUNTRY[r.g] ?? r.g, value: r.p![1], highlight: r.g === 'NL' }))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const elecRank = $derived(ranking(elecPrice));
|
||||||
|
const gasRank = $derived(ranking(gasPrice));
|
||||||
|
const depRank = $derived(ranking((g) => pts(`eurostat:nrg_ind_id:TOTAL.PC.${g}`)));
|
||||||
|
const usElec = $derived(pts('fred:APU000072610'));
|
||||||
|
const giga = (p: StatPoint[], k: number) =>
|
||||||
|
p.map(([d, v]) => [d, Math.round((v / k) * 100) / 100] as StatPoint);
|
||||||
|
const goudaSolarPerK = $derived(
|
||||||
|
ratio(pts('cbs:solar:homes:n:GM0513'), pts('cbs:pop:jan1:GM0513'), 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const kE = $derived(latest(elecAllIn));
|
||||||
|
const kG = $derived(latest(gasAllIn));
|
||||||
|
const kBrent = $derived(last('fred:DCOILBRENTEU'));
|
||||||
|
const kTtf = $derived(last('fred:PNGASEUUSDM'));
|
||||||
|
const kRenew = $derived(last('cbs:renew:total:share'));
|
||||||
|
const kHp = $derived(last('cbs:heatpumps:homes'));
|
||||||
|
const kEv = $derived(last('cbs:cars:electric'));
|
||||||
|
const kDep = $derived(latest(importDep));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>energy · stats · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Dutch household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="stats-intro">
|
||||||
|
<span class="stats-eyebrow">stats / energy</span>
|
||||||
|
<h1>Energy</h1>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<Kpi
|
||||||
|
label="Electricity, variable + tax"
|
||||||
|
value={num(kE?.[1], 3)}
|
||||||
|
unit="€/kWh"
|
||||||
|
sub={period(kE?.[0])}
|
||||||
|
color="#eab308"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Gas, variable + tax"
|
||||||
|
value={num(kG?.[1], 2)}
|
||||||
|
unit="€/m³"
|
||||||
|
sub={period(kG?.[0])}
|
||||||
|
color="#f97316"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Brent crude"
|
||||||
|
value={num(kBrent?.[1], 1)}
|
||||||
|
unit="$/bbl"
|
||||||
|
sub={`week of ${period(kBrent?.[0], 'D')}`}
|
||||||
|
color="#64748b"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="EU gas (TTF)"
|
||||||
|
value={num(kTtf?.[1], 1)}
|
||||||
|
unit="$/MMBtu"
|
||||||
|
sub={period(kTtf?.[0])}
|
||||||
|
color="#3b82f6"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Renewable electricity"
|
||||||
|
value={num(kRenew?.[1], 0)}
|
||||||
|
unit="%"
|
||||||
|
sub={`of consumption, ${period(kRenew?.[0], 'A')}`}
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Import dependence"
|
||||||
|
value={num(kDep?.[1], 0)}
|
||||||
|
unit="%"
|
||||||
|
sub={`net imports / supply, ${period(kDep?.[0], 'A')}`}
|
||||||
|
color="#e11d48"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Heat pumps in homes"
|
||||||
|
value={compact(kHp?.[1])}
|
||||||
|
sub={`end of ${period(kHp?.[0], 'A')}`}
|
||||||
|
color="#06b6d4"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Electric cars"
|
||||||
|
value={compact(kEv?.[1])}
|
||||||
|
sub={`1 Jan ${period(kEv?.[0], 'A')}`}
|
||||||
|
color="#a855f7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Household tariffs in the Netherlands</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Electricity price per kWh"
|
||||||
|
subtitle="€/kWh incl. VAT · variable supply + energy tax (CBS) and all-in average 2007–2022"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
note="Since 2023 the energy tax reduction (≈ €600 a year) is a fixed credit, so the marginal kWh is pricier than the average bill. The 2023 price cap is not included."
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Gas price per m³"
|
||||||
|
subtitle="€/m³ incl. VAT · variable supply + energy tax"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="What a kWh is made of"
|
||||||
|
subtitle="€/kWh incl. VAT · supply vs taxes"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Fixed costs per year"
|
||||||
|
subtitle="€ per year incl. VAT · electricity connection"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Compared with Europe and the US</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Household electricity price, Europe"
|
||||||
|
subtitle={`€/kWh incl. all taxes · 2,500–5,000 kWh a year · ${period(elecRank.when, 'S')}`}
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) =>
|
||||||
|
barRankOption(t, elecRank.rows, { decimals: 3, unit: '€/kWh', color: '#eab308' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Electricity: Netherlands, Germany, EU and the US"
|
||||||
|
subtitle="per kWh incl. taxes · Europe in €, US city average in $ (BLS)"
|
||||||
|
sources={['eurostat', 'fred']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Household gas price, Europe"
|
||||||
|
subtitle={`€/kWh incl. all taxes · 20–200 GJ a year · ${period(gasRank.when, 'S')}`}
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) => barRankOption(t, gasRank.rows, { decimals: 3, unit: '€/kWh', color: '#f97316' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Dutch household prices: taxes vs the rest"
|
||||||
|
subtitle="€/kWh · Eurostat semesters"
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="360px"
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Oil, gas and imports</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Brent crude and EU natural gas"
|
||||||
|
subtitle="Brent $/barrel (weekly) · TTF $/MMBtu (monthly, IMF)"
|
||||||
|
sources={['fred']}
|
||||||
|
attributions={a}
|
||||||
|
wide
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Import dependence since 1960"
|
||||||
|
subtitle="net energy imports as % of total primary supply"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(t, [{ name: 'Netherlands', points: importDep, color: '#e11d48', kind: 'area' }], {
|
||||||
|
unit: '%',
|
||||||
|
legend: false
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Natural gas: production vs imports"
|
||||||
|
subtitle="PJ per year · the Groningen field from boom to shutdown"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Energy import dependency, Europe"
|
||||||
|
subtitle={`% of gross available energy · ${period(depRank.when, 'A')}`}
|
||||||
|
sources={['eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
height="340px"
|
||||||
|
option={(t) => barRankOption(t, depRank.rows, { unit: '%', color: '#e11d48' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">The switch</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Renewable electricity"
|
||||||
|
subtitle="% of electricity consumption · normalised for wind and water"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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: '%' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Installed solar and wind capacity"
|
||||||
|
subtitle="GW, end of year"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Heat pumps in Dutch homes"
|
||||||
|
subtitle="installed at the end of each year"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Solar panels on homes: Netherlands and Gouda"
|
||||||
|
subtitle="installed panel capacity, MWp · Gouda on the right axis"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
note={goudaSolarPerK.length
|
||||||
|
? `Gouda: ${num(latest(goudaSolarPerK)?.[1], 0)} home installations per 1,000 inhabitants in ${period(latest(goudaSolarPerK)?.[0], 'A')}.`
|
||||||
|
: ''}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Electric cars"
|
||||||
|
subtitle="share of the passenger car fleet (1 January) and of new registrations"
|
||||||
|
sources={['cbs', 'eurostat']}
|
||||||
|
attributions={a}
|
||||||
|
wide
|
||||||
|
option={(t) =>
|
||||||
|
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: '%' }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
44
src/routes/stats/gouda/+page.server.ts
Normal file
44
src/routes/stats/gouda/+page.server.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
396
src/routes/stats/gouda/+page.svelte
Normal file
396
src/routes/stats/gouda/+page.svelte
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import StatChart from '#lib/stats/StatChart.svelte';
|
||||||
|
import Kpi from '#lib/stats/Kpi.svelte';
|
||||||
|
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||||
|
import { compact, num, period } from '#lib/stats/format.js';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.series);
|
||||||
|
const a = $derived(data.attributions);
|
||||||
|
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||||
|
const last = (id: string) => latest(s[id]?.points);
|
||||||
|
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
NL01: 'Netherlands',
|
||||||
|
GM0513: 'Gouda',
|
||||||
|
GM0627: 'Waddinxveen',
|
||||||
|
GM1901: 'Bodegraven-Reeuwijk',
|
||||||
|
GM1892: 'Zuidplas',
|
||||||
|
GM1931: 'Krimpenerwaard',
|
||||||
|
GM0484: 'Alphen aan den Rijn',
|
||||||
|
GM0632: 'Woerden'
|
||||||
|
};
|
||||||
|
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||||
|
const COLORS: Record<string, string> = {
|
||||||
|
GM0513: '#f97316',
|
||||||
|
NL01: '#0f172a',
|
||||||
|
GM0627: '#3b82f6',
|
||||||
|
GM1901: '#10b981',
|
||||||
|
GM1892: '#a855f7',
|
||||||
|
GM1931: '#06b6d4',
|
||||||
|
GM0484: '#eab308',
|
||||||
|
GM0632: '#e11d48'
|
||||||
|
};
|
||||||
|
|
||||||
|
const pop = $derived(last('cbs:pop:monthly:GM0513'));
|
||||||
|
const popYoy = $derived(change(pts('cbs:pop:monthly:GM0513'), 12)?.abs ?? null);
|
||||||
|
const price = $derived(last('cbs:house:avgprice:GM0513'));
|
||||||
|
const priceNl = $derived(last('cbs:house:avgprice:NL01'));
|
||||||
|
const hpi = $derived(pts('cbs:house:hpi:cr28'));
|
||||||
|
const hpiYoy = $derived(hpi.length > 4 ? (hpi.at(-1)![1] / hpi.at(-5)![1] - 1) * 100 : null);
|
||||||
|
const stock = $derived(last('cbs:stock:total:GM0513'));
|
||||||
|
const social = $derived(last('cbs:stock:social:GM0513'));
|
||||||
|
const levy = $derived(last('cbs:levy:total:GM0513'));
|
||||||
|
|
||||||
|
// Index every ring municipality's monthly population to 100 in Jan 2002 so sizes compare.
|
||||||
|
const indexed = (p: StatPoint[]): StatPoint[] =>
|
||||||
|
p.length ? p.map(([d, v]) => [d, Math.round((v / p[0][1]) * 1000) / 10]) : [];
|
||||||
|
const levyRank = $derived(
|
||||||
|
RING.map((r) => ({ r, p: last(`cbs:levy:total:${r}`) }))
|
||||||
|
.filter((x) => x.p)
|
||||||
|
.map((x) => ({ label: NAMES[x.r], value: x.p![1], highlight: x.r === 'GM0513' }))
|
||||||
|
);
|
||||||
|
const priceRank = $derived(
|
||||||
|
['NL01', ...RING]
|
||||||
|
.map((r) => ({ r, p: last(`cbs:house:avgprice:${r}`) }))
|
||||||
|
.filter((x) => x.p)
|
||||||
|
.map((x) => ({
|
||||||
|
label: NAMES[x.r],
|
||||||
|
value: Math.round(x.p![1] / 1000),
|
||||||
|
highlight: x.r === 'GM0513'
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const socialShare = $derived(
|
||||||
|
['NL01', ...RING]
|
||||||
|
.map((r) => {
|
||||||
|
const t = last(`cbs:stock:total:${r}`);
|
||||||
|
const so = last(`cbs:stock:social:${r}`);
|
||||||
|
return t && so
|
||||||
|
? {
|
||||||
|
label: NAMES[r],
|
||||||
|
value: Math.round((so[1] / t[1]) * 1000) / 10,
|
||||||
|
highlight: r === 'GM0513'
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((x) => x != null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const wijken = $derived(data.wijken.filter((w) => w.code.startsWith('WK')));
|
||||||
|
const totals = $derived(data.wijken.filter((w) => !w.code.startsWith('WK')));
|
||||||
|
const COLS: { key: string; label: string; decimals: number; unit?: string }[] = [
|
||||||
|
{ key: 'T001036', label: 'Inhabitants', decimals: 0 },
|
||||||
|
{ key: 'M001642', label: 'Avg WOZ', decimals: 0, unit: 'k€' },
|
||||||
|
{ key: '1014850_2', label: 'Rented', decimals: 0, unit: '%' },
|
||||||
|
{ key: 'A047047', label: 'Social housing', decimals: 0, unit: '%' },
|
||||||
|
{ key: 'M000224', label: 'Income / inhabitant', decimals: 1, unit: 'k€' },
|
||||||
|
{ key: 'M008349', label: 'In poverty', decimals: 1, unit: '%' },
|
||||||
|
{ key: 'M000219_2', label: 'Gas use', decimals: 0, unit: 'm³' },
|
||||||
|
{ key: 'M008297', label: 'Solar homes', decimals: 0, unit: '%' }
|
||||||
|
];
|
||||||
|
const wijkBars = (key: string) =>
|
||||||
|
wijken
|
||||||
|
.filter((w) => w.values[key] != null)
|
||||||
|
.map((w) => ({ label: w.title, value: w.values[key] as number }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Gouda · stats · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Gouda in numbers: population since 1942, births, deaths and migration, house prices, housing stock, municipal levies and neighbourhoods, compared with the surrounding municipalities."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="stats-intro">
|
||||||
|
<span class="stats-eyebrow">stats / gouda</span>
|
||||||
|
<h1>Gouda</h1>
|
||||||
|
<p>
|
||||||
|
My home town in numbers, next to its neighbours: Waddinxveen, Bodegraven-Reeuwijk, Zuidplas,
|
||||||
|
Krimpenerwaard, Alphen aan den Rijn and Woerden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<Kpi
|
||||||
|
label="Inhabitants"
|
||||||
|
value={num(pop?.[1], 0)}
|
||||||
|
sub={` in a year · ${period(pop?.[0])}`}
|
||||||
|
delta={popYoy}
|
||||||
|
color="#f97316"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Average sale price"
|
||||||
|
value={`€${compact(price?.[1])}`}
|
||||||
|
sub={`${period(price?.[0], 'A')} · NL €${compact(priceNl?.[1])}`}
|
||||||
|
color="#e11d48"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="House prices, region"
|
||||||
|
value={num(hpi.at(-1)?.[1], 0)}
|
||||||
|
unit="index"
|
||||||
|
sub={` in a year · ${period(hpi.at(-1)?.[0], 'Q')}`}
|
||||||
|
delta={hpiYoy}
|
||||||
|
deltaUnit="%"
|
||||||
|
invert
|
||||||
|
color="#a855f7"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Homes"
|
||||||
|
value={num(stock?.[1], 0)}
|
||||||
|
sub={`${num(social && stock ? (social[1] / stock[1]) * 100 : null, 0)}% social rent · ${period(stock?.[0], 'A')}`}
|
||||||
|
color="#3b82f6"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label="Municipal levies"
|
||||||
|
value={`€${num(levy?.[1], 0)}`}
|
||||||
|
sub={`multi-person household, ${period(levy?.[0], 'A')}`}
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">People</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Population of Gouda since 1942"
|
||||||
|
subtitle="inhabitants on 1 January"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
[{ name: 'Gouda', points: pts('cbs:pop:jan1:GM0513'), color: '#f97316', kind: 'area' }],
|
||||||
|
{ decimals: 0, legend: false }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Growth since 2002, Gouda and its neighbours"
|
||||||
|
subtitle="monthly population, January 2002 = 100"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Births, deaths and migration in Gouda"
|
||||||
|
subtitle="people per year"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
wide
|
||||||
|
note="Moving in/out counts moves to and from other Dutch municipalities; immigration and emigration are moves across the border."
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Housing</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Average sale price of existing homes"
|
||||||
|
subtitle="€ per year · Gouda vs neighbours and the Netherlands"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
['NL01', ...RING].map((r) => ({
|
||||||
|
name: NAMES[r],
|
||||||
|
points: pts(`cbs:house:avgprice:${r}`),
|
||||||
|
color: COLORS[r],
|
||||||
|
dashed: r === 'NL01'
|
||||||
|
})),
|
||||||
|
{ unit: '€', decimals: 0 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="House price index"
|
||||||
|
subtitle="2020 = 100 · Oost-Zuid-Holland (Gouda's COROP region, quarterly) vs NL (monthly)"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Average sale price, latest year"
|
||||||
|
subtitle={`thousand € · ${period(price?.[0], 'A')}`}
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) => barRankOption(t, priceRank, { decimals: 0, unit: 'k€', color: '#64748b' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Gouda's homes by ownership"
|
||||||
|
subtitle="dwellings on 1 January"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Share of social rented homes"
|
||||||
|
subtitle={`% of dwellings · ${period(stock?.[0], 'A')}`}
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) => barRankOption(t, socialShare, { unit: '%', color: '#64748b' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Municipal levies"
|
||||||
|
subtitle={`€ per multi-person household (property tax, sewer, waste) · ${period(levy?.[0], 'A')}`}
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) => barRankOption(t, levyRank, { decimals: 0, unit: '€', color: '#64748b' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Gouda's levies over time"
|
||||||
|
subtitle="€ per multi-person household"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
wide
|
||||||
|
option={(t) =>
|
||||||
|
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 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Neighbourhoods ({data.kwbYear})</h2>
|
||||||
|
{#if wijken.length}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="Average WOZ value by neighbourhood"
|
||||||
|
subtitle={`thousand € · ${data.kwbYear}`}
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
barRankOption(t, wijkBars('M001642'), { decimals: 0, unit: 'k€', color: '#e11d48' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Income per inhabitant by neighbourhood"
|
||||||
|
subtitle={`thousand € a year · ${data.kwbYear}`}
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
barRankOption(t, wijkBars('M000224'), { decimals: 1, unit: 'k€', color: '#10b981' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Neighbourhood</th>
|
||||||
|
{#each COLS as c (c.key)}<th class="num">{c.label}{c.unit ? ` (${c.unit})` : ''}</th
|
||||||
|
>{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each wijken as w (w.code)}
|
||||||
|
<tr>
|
||||||
|
<td>{w.title}</td>
|
||||||
|
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#each totals as w (w.code)}
|
||||||
|
<tr class="total">
|
||||||
|
<td>{w.title}</td>
|
||||||
|
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="stats-note">
|
||||||
|
{data.attributions.cbs ?? ''} · Kerncijfers wijken en buurten {data.kwbYear}. Blank cells are
|
||||||
|
suppressed by CBS for privacy.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Neighbourhood figures are not loaded yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
tr.total td {
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 1px solid var(--site-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user