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',
|
||||
|
||||
Reference in New Issue
Block a user