stats: core-7 economy page with CPI index levels, rebase and categories
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- /stats/economy defaults multi-country charts to CAN KOR CHN JPN USA NLD CUW, with add-a-country chips and an EU 27 comparison line - CPI index levels rebased to a chosen base year, index/YoY toggle, categories (food, energy, housing, core) where the source has them - new no-key collectors: oecd (CPI categories, 10y yields), statcan (CPI); bis WS_LONG_CPI, FRED US CPI categories, Eurostat prc_hicp_minr, WB FP.CPI.TOTL - Canada added as a region; CBS Curacao fallbacks for growth/unemployment - timers stats-oecd and stats-statcan
This commit is contained in:
96
src/lib/stats/CountryPicker.svelte
Normal file
96
src/lib/stats/CountryPicker.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
// Country chips for the multi-country charts on /stats/economy: the core seven are on by
|
||||
// default, the extra countries can be added, and the EU 27 comparison line can be toggled.
|
||||
import { COUNTRY } from './regions.js';
|
||||
import { FLAG } from './countries.js';
|
||||
import { CORE7, EXTRA } from './econ.js';
|
||||
|
||||
let { selected = $bindable(), eu = $bindable() }: { selected: string[]; eu: boolean } = $props();
|
||||
const toggle = (c: string) =>
|
||||
(selected = selected.includes(c) ? selected.filter((x) => x !== c) : [...selected, c]);
|
||||
const isCore = $derived(
|
||||
selected.length === CORE7.length && CORE7.every((c) => selected.includes(c))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="picker" role="group" aria-label="Countries on the charts">
|
||||
<div class="row">
|
||||
<span class="label">core</span>
|
||||
{#each CORE7 as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
aria-pressed={selected.includes(c)}
|
||||
onclick={() => toggle(c)}
|
||||
>
|
||||
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="chip eu" aria-pressed={eu} onclick={() => (eu = !eu)}>
|
||||
<span aria-hidden="true">🇪🇺</span>EU 27 line
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">add</span>
|
||||
{#each EXTRA as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
aria-pressed={selected.includes(c)}
|
||||
onclick={() => toggle(c)}
|
||||
>
|
||||
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
|
||||
</button>
|
||||
{/each}
|
||||
{#if !isCore}
|
||||
<button type="button" class="reset" onclick={() => (selected = [...CORE7])}>reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.label {
|
||||
width: 2.6rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.chip,
|
||||
.reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: transparent;
|
||||
color: var(--site-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip[aria-pressed='true'] {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.reset {
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ export const COUNTRY_PAGES = [
|
||||
'BEL',
|
||||
'GBR',
|
||||
'JPN',
|
||||
'CAN',
|
||||
'IND',
|
||||
'BRA',
|
||||
'RUS',
|
||||
@@ -34,6 +35,7 @@ export const FLAG: Record<string, string> = {
|
||||
BEL: '🇧🇪',
|
||||
GBR: '🇬🇧',
|
||||
JPN: '🇯🇵',
|
||||
CAN: '🇨🇦',
|
||||
IND: '🇮🇳',
|
||||
BRA: '🇧🇷',
|
||||
RUS: '🇷🇺',
|
||||
@@ -52,6 +54,7 @@ export const COUNTRY_COLOR: Record<string, string> = {
|
||||
BEL: '#a16207',
|
||||
GBR: '#a855f7',
|
||||
JPN: '#ec4899',
|
||||
CAN: '#dc2626',
|
||||
IND: '#10b981',
|
||||
BRA: '#84cc16',
|
||||
RUS: '#0d9488',
|
||||
|
||||
27
src/lib/stats/econ.test.ts
Normal file
27
src/lib/stats/econ.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CORE7, CPI_BY_CATEGORY, CPI_TOTAL, rebase, yearMean, yoyByDate } from './econ.js';
|
||||
|
||||
describe('econ helpers', () => {
|
||||
const pts: [string, number][] = [
|
||||
['2014-12-01', 99],
|
||||
['2015-01-01', 100],
|
||||
['2015-07-01', 102],
|
||||
['2016-01-01', 103],
|
||||
['2016-07-01', 104.04]
|
||||
];
|
||||
it('averages a calendar year', () => expect(yearMean(pts, 2015)).toBe(101));
|
||||
it('rebases to the base-year mean', () =>
|
||||
expect(rebase(pts, 2015)?.[3]).toEqual(['2016-01-01', 101.98]));
|
||||
it('returns null when the base year is missing', () => expect(rebase(pts, 2010)).toBeNull());
|
||||
it('computes year on year by date and skips gaps', () =>
|
||||
expect(yoyByDate(pts)).toEqual([
|
||||
['2016-01-01', 3],
|
||||
['2016-07-01', 2]
|
||||
]));
|
||||
it('covers every core country for CPI', () => {
|
||||
for (const c of CORE7) {
|
||||
expect(CPI_TOTAL[c]).toBeDefined();
|
||||
expect(CPI_BY_CATEGORY[c]?.ids.total).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
151
src/lib/stats/econ.ts
Normal file
151
src/lib/stats/econ.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Country sets and series maps for /stats/economy. The core set (CAN, KOR, CHN, JPN, USA, NLD,
|
||||
// CUW) is the default on every multi-country chart; the extra countries can be added, and the
|
||||
// EU aggregate is kept as a comparison line. Every id points at a stored series; nothing here is
|
||||
// estimated, gaps stay gaps.
|
||||
import type { StatPoint } from './chart.js';
|
||||
|
||||
export const CORE7 = ['CAN', 'KOR', 'CHN', 'JPN', 'USA', 'NLD', 'CUW'] as const;
|
||||
export const EXTRA = ['DEU', 'FRA', 'GBR', 'BEL', 'IND', 'BRA', 'RUS'] as const;
|
||||
export const ECON_COUNTRIES = [...CORE7, ...EXTRA] as const;
|
||||
export type EconCountry = (typeof ECON_COUNTRIES)[number];
|
||||
/** Comparison aggregates: EU 27 by default; the euro area where the data is per currency area. */
|
||||
export type Aggregate = 'EU27' | 'EA';
|
||||
|
||||
/** All-items CPI, index level, best monthly source per region (national office first). */
|
||||
export const CPI_TOTAL: Record<string, { id: string; source: string; base: string }> = {
|
||||
// CBS CPI as carried by the BIS long series (1965→). CBS 83131NED (2015 = 100) stops in
|
||||
// December 2025 and 86141NED (2025 = 100) starts in December 2009, so neither covers the span.
|
||||
NLD: { id: 'bis:cpi:NL', source: 'bis', base: '2010 = 100' },
|
||||
USA: { id: 'fred:CPIAUCSL', source: 'fred', base: '1982–84 = 100' },
|
||||
CAN: { id: 'statcan:cpi:total', source: 'statcan', base: '2002 = 100' },
|
||||
JPN: { id: 'bis:cpi:JP', source: 'bis', base: '2010 = 100' },
|
||||
KOR: { id: 'bis:cpi:KR', source: 'bis', base: '2010 = 100' },
|
||||
CHN: { id: 'bis:cpi:CN', source: 'bis', base: '2010 = 100' },
|
||||
CUW: { id: 'cw:cpi:index', source: 'cbscw', base: '2023 = 100' },
|
||||
DEU: { id: 'bis:cpi:DE', source: 'bis', base: '2010 = 100' },
|
||||
FRA: { id: 'bis:cpi:FR', source: 'bis', base: '2010 = 100' },
|
||||
GBR: { id: 'bis:cpi:GB', source: 'bis', base: '2010 = 100' },
|
||||
BEL: { id: 'bis:cpi:BE', source: 'bis', base: '2010 = 100' },
|
||||
IND: { id: 'bis:cpi:IN', source: 'bis', base: '2010 = 100' },
|
||||
BRA: { id: 'bis:cpi:BR', source: 'bis', base: '2010 = 100' },
|
||||
RUS: { id: 'bis:cpi:RU', source: 'bis', base: '2010 = 100' },
|
||||
EU27: {
|
||||
id: 'eurostat:prc_hicp_minr:I15.TOTAL.EU27_2020',
|
||||
source: 'eurostat',
|
||||
base: '2015 = 100'
|
||||
},
|
||||
EA: { id: 'eurostat:prc_hicp_minr:I15.TOTAL.EA20', source: 'eurostat', base: '2015 = 100' }
|
||||
};
|
||||
|
||||
export const CPI_CATS = ['total', 'food', 'housing', 'energy', 'core'] as const;
|
||||
export type CpiCat = (typeof CPI_CATS)[number];
|
||||
export const CPI_CAT_LABEL: Record<CpiCat, string> = {
|
||||
total: 'All items',
|
||||
food: 'Food',
|
||||
housing: 'Housing',
|
||||
energy: 'Energy',
|
||||
core: 'Core (excl. food and energy)'
|
||||
};
|
||||
const hicp = (g: string): Record<CpiCat, string> => ({
|
||||
total: `eurostat:prc_hicp_minr:I15.TOTAL.${g}`,
|
||||
food: `eurostat:prc_hicp_minr:I15.CP01.${g}`,
|
||||
housing: `eurostat:prc_hicp_minr:I15.CP04.${g}`,
|
||||
energy: `eurostat:prc_hicp_minr:I15.NRG.${g}`,
|
||||
core: `eurostat:prc_hicp_minr:I15.TOT_X_NRG_FOOD.${g}`
|
||||
});
|
||||
const oecd = (c: string, cats: CpiCat[]) =>
|
||||
Object.fromEntries(cats.map((k) => [k, `oecd:cpi:${c}:${k}`])) as Partial<Record<CpiCat, string>>;
|
||||
|
||||
/** CPI by main category where the source publishes it. */
|
||||
export const CPI_BY_CATEGORY: Record<
|
||||
string,
|
||||
{ ids: Partial<Record<CpiCat, string>>; source: string; note: string }
|
||||
> = {
|
||||
NLD: { ids: hicp('NL'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
|
||||
USA: {
|
||||
ids: {
|
||||
total: 'fred:CPIAUCSL',
|
||||
food: 'fred:CPIUFDSL',
|
||||
housing: 'fred:CPIHOSSL',
|
||||
energy: 'fred:CPIENGSL',
|
||||
core: 'fred:CPILFESL'
|
||||
},
|
||||
source: 'fred',
|
||||
note: 'BLS CPI-U, seasonally adjusted, via FRED'
|
||||
},
|
||||
CAN: {
|
||||
ids: Object.fromEntries(CPI_CATS.map((k) => [k, `statcan:cpi:${k}`])),
|
||||
source: 'statcan',
|
||||
note: 'Statistics Canada 18-10-0004-01; housing = shelter'
|
||||
},
|
||||
KOR: {
|
||||
ids: oecd('KOR', [...CPI_CATS]),
|
||||
source: 'oecd',
|
||||
note: 'OECD prices database'
|
||||
},
|
||||
JPN: {
|
||||
ids: oecd('JPN', [...CPI_CATS]),
|
||||
source: 'oecd',
|
||||
note: 'OECD prices database; the OECD series for Japan stop in June 2021'
|
||||
},
|
||||
CHN: {
|
||||
ids: oecd('CHN', ['total', 'food']),
|
||||
source: 'oecd',
|
||||
note: 'OECD prices database: all items and food only (other NBS categories are manual)'
|
||||
},
|
||||
CUW: {
|
||||
ids: { total: 'cw:cpi:index' },
|
||||
source: 'cbscw',
|
||||
note: 'CBS Curaçao: all items only'
|
||||
},
|
||||
EU27: { ids: hicp('EU27_2020'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
|
||||
EA: { ids: hicp('EA20'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' }
|
||||
};
|
||||
|
||||
/** BIS policy rate area per country (euro members → ECB); missing = no BIS series. */
|
||||
export const POLICY_RATE: Record<string, string> = {
|
||||
CAN: 'CA',
|
||||
KOR: 'KR',
|
||||
CHN: 'CN',
|
||||
JPN: 'JP',
|
||||
USA: 'US',
|
||||
NLD: 'XM',
|
||||
GBR: 'GB',
|
||||
EA: 'XM'
|
||||
};
|
||||
|
||||
/** Annual macro indicators: IMF WEO first, then CBS Curaçao for Curaçao, then the World Bank. */
|
||||
export const ANNUAL = {
|
||||
growth: { imf: 'NGDP_RPCH', wb: 'NY.GDP.MKTP.KD.ZG', cw: 'cw:gdp:real_growth_pct' },
|
||||
unemployment: {
|
||||
imf: 'LUR',
|
||||
wb: 'SL.UEM.TOTL.ZS',
|
||||
cw: 'cw:lfs:unemployment_rate_pct:total'
|
||||
},
|
||||
debt: { imf: 'GGXWDG_NGDP', wb: 'GC.DOD.TOTL.GD.ZS', cw: null },
|
||||
trade: { imf: null, wb: 'NE.TRD.GNFS.ZS', cw: null }
|
||||
} as const;
|
||||
|
||||
/** Mean of the points in a calendar year, or null when the year has no observations. */
|
||||
export function yearMean(points: StatPoint[], year: number): number | null {
|
||||
const vals = points.filter(([d]) => d.startsWith(`${year}-`)).map(([, v]) => v);
|
||||
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
||||
}
|
||||
|
||||
/** Rebase an index to (base year average) = 100. Null when the base year is not covered. */
|
||||
export function rebase(points: StatPoint[], year: number): StatPoint[] | null {
|
||||
const base = yearMean(points, year);
|
||||
if (!base) return null;
|
||||
return points.map(([d, v]) => [d, Math.round((v / base) * 10000) / 100]);
|
||||
}
|
||||
|
||||
/** Year-on-year % change by date (same month a year earlier); missing months stay missing. */
|
||||
export function yoyByDate(points: StatPoint[]): StatPoint[] {
|
||||
const map = new Map(points);
|
||||
const out: StatPoint[] = [];
|
||||
for (const [d, v] of points) {
|
||||
const prev = map.get(`${Number(d.slice(0, 4)) - 1}${d.slice(4)}`);
|
||||
if (prev) out.push([d, Math.round(((v - prev) / prev) * 10000) / 100]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const COUNTRY: Record<string, string> = {
|
||||
CN: 'China',
|
||||
GB: 'United Kingdom',
|
||||
JP: 'Japan',
|
||||
CA: 'Canada',
|
||||
NLD: 'Netherlands',
|
||||
USA: 'United States',
|
||||
KOR: 'South Korea',
|
||||
@@ -31,6 +32,7 @@ export const COUNTRY: Record<string, string> = {
|
||||
BEL: 'Belgium',
|
||||
GBR: 'United Kingdom',
|
||||
JPN: 'Japan',
|
||||
CAN: 'Canada',
|
||||
IND: 'India',
|
||||
BRA: 'Brazil',
|
||||
RUS: 'Russia',
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_TOTAL,
|
||||
ECON_COUNTRIES,
|
||||
POLICY_RATE
|
||||
} from '#lib/stats/econ.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'];
|
||||
const REGIONS = [...ECON_COUNTRIES, 'EU27', 'EA'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
series: await cached('stats:economy', () =>
|
||||
loadSeries([
|
||||
...['US', 'XM', 'KR', 'CN', 'GB', 'JP'].map((a) => ({
|
||||
...[...new Set(Object.values(POLICY_RATE))].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' },
|
||||
// CPI index levels (all items, then the categories)
|
||||
...[
|
||||
...new Set([
|
||||
...Object.values(CPI_TOTAL).map((c) => c.id),
|
||||
...Object.values(CPI_BY_CATEGORY).flatMap((c) => Object.values(c.ids))
|
||||
])
|
||||
].map((id) => ({ id, from: '1960-01-01' })),
|
||||
...REGIONS.map((c) => ({ id: `oecd:irlt:${c}`, from: '1980-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}`),
|
||||
...Object.values(ANNUAL).flatMap((ind) =>
|
||||
REGIONS.flatMap((c) => [
|
||||
...(ind.imf ? [{ id: `imf:${ind.imf}:${c}`, from: '1980-01-01' }] : []),
|
||||
{ id: `wb:${ind.wb}:${c}`, from: '1980-01-01' }
|
||||
])
|
||||
),
|
||||
...Object.values(ANNUAL).flatMap((ind) => (ind.cw ? [ind.cw] : [])),
|
||||
'fred:GDPC1',
|
||||
'fred:UNRATE',
|
||||
...EU.flatMap((g) => [
|
||||
|
||||
@@ -1,25 +1,159 @@
|
||||
<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 CountryPicker from '#lib/stats/CountryPicker.svelte';
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
import { barRankOption, latest, lineOption, spread, type Line } from '#lib/stats/chart.js';
|
||||
import { num, period } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import { COUNTRY_COLOR } from '#lib/stats/countries.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CORE7,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_CATS,
|
||||
CPI_CAT_LABEL,
|
||||
CPI_TOTAL,
|
||||
POLICY_RATE,
|
||||
rebase,
|
||||
yoyByDate,
|
||||
type CpiCat
|
||||
} from '#lib/stats/econ.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}`);
|
||||
// Country selection shared by every multi-country chart.
|
||||
let selected = $state<string[]>([...CORE7]);
|
||||
let eu = $state(true);
|
||||
const regions = $derived([...selected, ...(eu ? ['EU27'] : [])]);
|
||||
const nameOf = (c: string) =>
|
||||
c === 'EU27' ? 'EU 27' : c === 'EA' ? 'Euro area' : (COUNTRY[c] ?? c);
|
||||
const colorOf = (c: string) => COUNTRY_COLOR[c] ?? '#64748b';
|
||||
const missing = (list: string[]) =>
|
||||
list.length ? `No data for ${list.map(nameOf).join(', ')}.` : '';
|
||||
|
||||
// CPI: index level rebased to a common year, or year-on-year %.
|
||||
let cpiMode = $state<'level' | 'yoy'>('level');
|
||||
let baseYear = $state(2015);
|
||||
const BASE_YEARS = [2000, 2010, 2015, 2020, 2025];
|
||||
const cpiLine = (id: string): [string, number][] | null => {
|
||||
const p = pts(id);
|
||||
if (!p.length) return null;
|
||||
return cpiMode === 'yoy' ? yoyByDate(p) : rebase(p, baseYear);
|
||||
};
|
||||
const cpiLines = $derived.by(() => {
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of regions) {
|
||||
const src = CPI_TOTAL[c];
|
||||
const points = src ? cpiLine(src.id) : null;
|
||||
if (!points?.length) gaps.push(c);
|
||||
else lines.push({ name: nameOf(c), points, color: colorOf(c), dashed: c === 'EU27' });
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
const cpiSources = $derived([
|
||||
...new Set(regions.map((c) => CPI_TOTAL[c]?.source).filter(Boolean))
|
||||
] as string[]);
|
||||
|
||||
let catCountry = $state('NLD');
|
||||
const CAT_COLORS: Record<CpiCat, string> = {
|
||||
total: '#64748b',
|
||||
food: '#10b981',
|
||||
housing: '#a855f7',
|
||||
energy: '#f97316',
|
||||
core: '#3b82f6'
|
||||
};
|
||||
const catLines = $derived.by(() => {
|
||||
const def = CPI_BY_CATEGORY[catCountry];
|
||||
return CPI_CATS.flatMap((k) => {
|
||||
const id = def?.ids[k];
|
||||
const points = id ? cpiLine(id) : null;
|
||||
return points?.length
|
||||
? [{ name: CPI_CAT_LABEL[k], points, color: CAT_COLORS[k], dashed: k === 'total' }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
const catMissing = $derived(
|
||||
CPI_CATS.filter((k) => !CPI_BY_CATEGORY[catCountry]?.ids[k]).map((k) =>
|
||||
CPI_CAT_LABEL[k].toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
// Policy rates (BIS), euro members follow the ECB.
|
||||
const rateLines = $derived.by(() => {
|
||||
const seen: string[] = [];
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
|
||||
const area = POLICY_RATE[c] ?? (['DEU', 'FRA', 'BEL'].includes(c) ? 'XM' : undefined);
|
||||
const p = area ? pts(`bis:M:${area}`) : [];
|
||||
if (!p.length) gaps.push(c);
|
||||
else if (!seen.includes(area!)) {
|
||||
seen.push(area!);
|
||||
lines.push({
|
||||
name: area === 'XM' ? 'ECB (euro area)' : nameOf(c),
|
||||
points: p,
|
||||
color: area === 'XM' ? '#1d4ed8' : colorOf(c),
|
||||
kind: 'step'
|
||||
});
|
||||
}
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
|
||||
const yieldLines = $derived.by(() => {
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
|
||||
const p = pts(`oecd:irlt:${c}`);
|
||||
if (!p.length) gaps.push(c);
|
||||
else lines.push({ name: nameOf(c), points: p, color: colorOf(c), dashed: c === 'EA' });
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
|
||||
// Annual indicators: IMF WEO (outcomes only, forecasts cut), World Bank where the IMF has none.
|
||||
const annual = (key: keyof typeof ANNUAL) => {
|
||||
const ind = ANNUAL[key];
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
const used: string[] = [];
|
||||
for (const c of regions) {
|
||||
const imf = ind.imf ? s[`imf:${ind.imf}:${c}`] : undefined;
|
||||
const wb = s[`wb:${ind.wb}:${c}`];
|
||||
const cw = c === 'CUW' && ind.cw ? s[ind.cw] : undefined;
|
||||
const pick = imf?.points.length
|
||||
? imf
|
||||
: cw?.points.length
|
||||
? cw
|
||||
: wb?.points.length
|
||||
? wb
|
||||
: undefined;
|
||||
if (!pick) {
|
||||
gaps.push(c);
|
||||
continue;
|
||||
}
|
||||
if (!used.includes(pick.sourceId)) used.push(pick.sourceId);
|
||||
const cut = pick.forecastFrom ? `${pick.forecastFrom}-01-01` : '9999';
|
||||
lines.push({
|
||||
name: nameOf(c),
|
||||
points: pick.points.filter(([d]) => d < cut),
|
||||
color: colorOf(c),
|
||||
dashed: c === 'EU27'
|
||||
});
|
||||
}
|
||||
return { lines, gaps, sources: used };
|
||||
};
|
||||
const growth = $derived(annual('growth'));
|
||||
const unemployment = $derived(annual('unemployment'));
|
||||
const govDebt = $derived(annual('debt'));
|
||||
const trade = $derived(annual('trade'));
|
||||
|
||||
// Europe-only charts (Eurostat).
|
||||
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')));
|
||||
@@ -53,19 +187,19 @@
|
||||
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 boc = $derived(k('bis:M:CA'));
|
||||
const boj = $derived(k('bis:M:JP'));
|
||||
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));
|
||||
const usInfl = $derived(latest(yoyByDate(pts('fred:CPIAUCSL'))));
|
||||
const caInfl = $derived(latest(yoyByDate(pts('statcan:cpi:total'))));
|
||||
</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."
|
||||
content="Consumer prices (index levels and inflation by category), interest rates, bond yields, growth, unemployment, debt and trade for Canada, South Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
@@ -73,9 +207,10 @@
|
||||
<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.
|
||||
Consumer prices, central bank rates, bond yields, growth, jobs, debt and trade for Canada, South
|
||||
Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison line. Add
|
||||
other countries with the picker. Refreshed from open sources (CBS, BLS, Statistics Canada, CBS
|
||||
Curaçao, BIS, OECD, IMF, World Bank, Eurostat).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -95,11 +230,18 @@
|
||||
color="#eab308"
|
||||
/>
|
||||
<Kpi
|
||||
label="Bank of Korea"
|
||||
value={num(bok?.[1], 2)}
|
||||
label="Bank of Canada"
|
||||
value={num(boc?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(bok?.[0])}
|
||||
color="#e11d48"
|
||||
sub={period(boc?.[0])}
|
||||
color="#dc2626"
|
||||
/>
|
||||
<Kpi
|
||||
label="Bank of Japan"
|
||||
value={num(boj?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(boj?.[0])}
|
||||
color="#ec4899"
|
||||
/>
|
||||
<Kpi
|
||||
label="NL inflation (HICP)"
|
||||
@@ -123,76 +265,127 @@
|
||||
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)}
|
||||
label="Canada inflation (CPI)"
|
||||
value={num(caInfl?.[1])}
|
||||
unit="%"
|
||||
sub={period(us10Now?.[0])}
|
||||
color="#64748b"
|
||||
sub={period(caInfl?.[0])}
|
||||
color="#dc2626"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Rates and inflation</h2>
|
||||
<CountryPicker bind:selected bind:eu />
|
||||
|
||||
<h2 class="stats-section-title">Prices and rates</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Consumer prices"
|
||||
subtitle={cpiMode === 'level'
|
||||
? `index, ${baseYear} average = 100 · monthly`
|
||||
: '% change on the same month a year earlier'}
|
||||
sources={cpiSources}
|
||||
attributions={a}
|
||||
height="400px"
|
||||
wide
|
||||
note={`National CPI for the US (BLS CPI-U), Canada (Statistics Canada) and Curaçao (CBS Curaçao); BIS long series (national CPIs) for the Netherlands, Japan, South Korea, China and the added countries; EU 27 is the HICP. Each source keeps its own base year; the chart divides by the ${baseYear} average.${cpiMode === 'level' ? ' Series that do not cover the base year are left out.' : ''} ${missing(cpiLines.gaps)}`}
|
||||
option={(t) =>
|
||||
lineOption(t, cpiLines.lines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
|
||||
>
|
||||
<div class="controls">
|
||||
<Tabs.Root bind:value={cpiMode}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="level">index level</Tabs.Trigger>
|
||||
<Tabs.Trigger value="yoy">year on year %</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
{#if cpiMode === 'level'}
|
||||
<label class="select">
|
||||
base year
|
||||
<select bind:value={baseYear}>
|
||||
{#each BASE_YEARS as yr (yr)}<option value={yr}>{yr}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</StatChart>
|
||||
<StatChart
|
||||
title={`Consumer prices by category: ${nameOf(catCountry)}`}
|
||||
subtitle={cpiMode === 'level'
|
||||
? `index, ${baseYear} average = 100 · monthly`
|
||||
: '% change on the same month a year earlier'}
|
||||
sources={[CPI_BY_CATEGORY[catCountry]?.source ?? 'eurostat']}
|
||||
attributions={a}
|
||||
height="380px"
|
||||
wide
|
||||
note={`${CPI_BY_CATEGORY[catCountry]?.note ?? ''}.${catMissing.length ? ` Not published here: ${catMissing.join(', ')}.` : ''} Category definitions differ a little per source (Canada's housing is shelter; the HICP and OECD use COICOP 04, housing including utilities).`}
|
||||
option={(t) =>
|
||||
lineOption(t, catLines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
|
||||
>
|
||||
<div class="controls">
|
||||
<label class="select">
|
||||
country
|
||||
<select bind:value={catCountry}>
|
||||
{#each Object.keys(CPI_BY_CATEGORY) as c (c)}<option value={c}>{nameOf(c)}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<span class="hint">uses the index / year-on-year switch and base year above</span>
|
||||
</div>
|
||||
</StatChart>
|
||||
<StatChart
|
||||
title="Central bank policy rates"
|
||||
subtitle="% · monthly"
|
||||
subtitle="% · monthly · BIS"
|
||||
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 }
|
||||
)}
|
||||
note={`Euro members follow the ECB. ${missing(rateLines.gaps)}${rateLines.gaps.includes('CUW') ? ' Curaçao: the CBCS pegs the guilder to the US dollar and is not in the BIS policy rate set.' : ''}`}
|
||||
option={(t) => lineOption(t, rateLines.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Inflation"
|
||||
subtitle="% year on year · HICP (Europe), CPI-U (US)"
|
||||
sources={['eurostat', 'fred']}
|
||||
title="10-year government bond yields"
|
||||
subtitle="% · monthly · OECD long-term interest rates"
|
||||
sources={['oecd']}
|
||||
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 }
|
||||
)}
|
||||
note={missing(yieldLines.gaps)}
|
||||
option={(t) => lineOption(t, yieldLines.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Bond markets</h2>
|
||||
<h2 class="stats-section-title">Growth, jobs and debt</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']}
|
||||
title="Real GDP growth"
|
||||
subtitle="% change on the previous year · outcomes only, no forecasts"
|
||||
sources={growth.sources}
|
||||
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 }
|
||||
)}
|
||||
note={`IMF World Economic Outlook; CBS Curaçao (real growth, 2011→) for Curaçao. ${missing(growth.gaps)}`}
|
||||
option={(t) => lineOption(t, growth.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Unemployment rate"
|
||||
subtitle="% of the labour force · annual"
|
||||
sources={unemployment.sources}
|
||||
attributions={a}
|
||||
note={`IMF WEO; CBS Curaçao labour force survey (2014→) for Curaçao. ${missing(unemployment.gaps)}`}
|
||||
option={(t) => lineOption(t, unemployment.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Government debt"
|
||||
subtitle="general government gross debt, % of GDP · annual"
|
||||
sources={govDebt.sources}
|
||||
attributions={a}
|
||||
note={`IMF WEO; World Bank central government debt where the IMF has none. ${missing(govDebt.gaps)}`}
|
||||
option={(t) => lineOption(t, govDebt.lines, { unit: '% of GDP', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trade openness"
|
||||
subtitle="exports + imports of goods and services, % of GDP · annual"
|
||||
sources={trade.sources}
|
||||
attributions={a}
|
||||
note={missing(trade.gaps)}
|
||||
option={(t) => lineOption(t, trade.lines, { unit: '% of GDP', zoom: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Bond and stock markets</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Spreads over Germany"
|
||||
subtitle="percentage points · 10-year yield minus the German Bund"
|
||||
@@ -256,37 +449,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Growth and jobs</h2>
|
||||
<h2 class="stats-section-title">United States</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 %"
|
||||
@@ -304,7 +468,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Public finances and migration</h2>
|
||||
<h2 class="stats-section-title">Europe: public finances and migration</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Government debt"
|
||||
@@ -401,6 +565,34 @@
|
||||
/>
|
||||
</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.
|
||||
Policy rates: BIS central bank policy rates (month end). Spreads use Eurostat's EMU
|
||||
convergence-criterion yields. The country picker drives the charts in the first two sections; the
|
||||
European and US sections keep their own fixed sets.
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.select select {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user