/** * Data stories — declarative templates. A story is a slug, a title, a standfirst and an ordered list of blocks * bound to indicators. Blocks are rendered by `components/stories/*` from live API payloads; `text` blocks are * template sentences whose variables are computed server-side from the SAME payloads (see * components/stories/resolve.ts). A sentence whose variable cannot be computed is omitted — never guessed. * * Adding a story = adding an entry to STORIES. No code elsewhere. */ export type VarSpec = /** Aggregate trend value (world or a group) at the first / last year or a given year. */ | { type: 'trend'; indicator: string; group?: string; year: 'first' | 'last' | number; stat?: 'preferred' | 'median' | 'mean' | 'weighted_mean' | 'sum'; format?: 'value' | 'year' | 'n' } /** Number of countries whose value in `year` (latest when omitted) satisfies op threshold. */ | { type: 'mapCount'; indicator: string; year?: number; op: 'gte' | 'lte' | 'gt' | 'lt'; threshold: number; format?: 'n' | 'total' | 'pct' | 'year' } /** Top-ranked country (name or value) for an indicator in a year (latest when omitted). */ | { type: 'rankTop'; indicator: string; year?: number; pos?: number; sort?: 'asc' | 'desc'; format?: 'name' | 'value' | 'year' } /** One country's value at the first / last year of its series (or a given year). */ | { type: 'country'; country: string; indicator: string; year: 'first' | 'last' | number; format?: 'value' | 'year' | 'name' } /** Share (%) of a group aggregate in the world aggregate (sum indicators) at a year. */ | { type: 'share'; indicator: string; group: string; year: 'first' | 'last' | number; format?: 'pct' | 'year' }; export interface TextParagraph { /** `{var}` placeholders resolved from `vars`. */ template: string; vars: Record; } export type StoryBlock = | { kind: 'text'; paragraphs: TextParagraph[] } | { kind: 'map'; indicator: string; years: number[]; title?: string; note?: string } | { kind: 'trend'; indicator: string; groups?: string[]; title?: string; log?: boolean; from?: number } | { kind: 'lines'; indicator: string; countries: string[]; title?: string; from?: number; log?: boolean } | { kind: 'ranked'; indicator: string; year?: number; top: number; sort?: 'asc' | 'desc'; title?: string } | { kind: 'shares'; indicator: string; groups: string[]; title?: string; from?: number }; export interface Story { slug: string; title: string; standfirst: string; /** ISO date of first publication (stories are regenerated from data on every request). */ published: string; topics: string[]; blocks: StoryBlock[]; } const WB_REGIONS = ['east-asia-pacific', 'europe-central-asia', 'north-america', 'latin-america-caribbean', 'middle-east-north-africa', 'south-asia', 'sub-saharan-africa']; export const STORIES: readonly Story[] = [ { slug: 'the-world-is-getting-older', title: 'The world is getting older', standfirst: 'Median age has climbed on every continent, the share of people over 65 has more than doubled since 1960, and fertility has fallen below replacement in most rich countries.', published: '2026-09-11', topics: ['population'], blocks: [ { kind: 'text', paragraphs: [ { template: 'In {y0}, the population-weighted median age of the world was {v0}. By {y1} it had risen to {v1}.', vars: { y0: { type: 'trend', indicator: 'median-age', year: 'first', format: 'year' }, v0: { type: 'trend', indicator: 'median-age', year: 'first' }, y1: { type: 'trend', indicator: 'median-age', year: 'last', format: 'year' }, v1: { type: 'trend', indicator: 'median-age', year: 'last' }, }, }, { template: 'The oldest population in {y} is {name}, with a median age of {v}.', vars: { y: { type: 'rankTop', indicator: 'median-age', format: 'year' }, name: { type: 'rankTop', indicator: 'median-age', format: 'name' }, v: { type: 'rankTop', indicator: 'median-age', format: 'value' }, }, }, ], }, { kind: 'map', indicator: 'median-age', years: [1960, 1980, 2000, 2023], title: 'Median age, decade by decade' }, { kind: 'text', paragraphs: [ { template: 'People aged 65 and over were {v0} of the world population in {y0}; in {y1} they were {v1}.', vars: { v0: { type: 'trend', indicator: 'population-65-plus-share', year: 'first' }, y0: { type: 'trend', indicator: 'population-65-plus-share', year: 'first', format: 'year' }, v1: { type: 'trend', indicator: 'population-65-plus-share', year: 'last' }, y1: { type: 'trend', indicator: 'population-65-plus-share', year: 'last', format: 'year' }, }, }, ], }, { kind: 'trend', indicator: 'population-65-plus-share', groups: ['world', 'high-income', 'low-income'], title: 'Share of population aged 65+' }, { kind: 'lines', indicator: 'fertility-rate', countries: ['JPN', 'ITA', 'KOR', 'CHN', 'IND', 'NGA'], title: 'Fertility rate, births per woman' }, { kind: 'ranked', indicator: 'median-age', top: 10, title: 'Oldest populations' }, ], }, { slug: 'rise-of-renewable-electricity', title: 'The rise of renewable electricity', standfirst: 'Renewables were a hydro story for decades. Since 2010 solar and wind have changed the shape of electricity systems on every continent.', published: '2026-09-11', topics: ['energy'], blocks: [ { kind: 'text', paragraphs: [ { template: 'Weighted by population, renewables supplied {v0} of electricity in {y0} and {v1} in {y1}.', vars: { v0: { type: 'trend', indicator: 'renewable-electricity-share', year: 'first' }, y0: { type: 'trend', indicator: 'renewable-electricity-share', year: 'first', format: 'year' }, v1: { type: 'trend', indicator: 'renewable-electricity-share', year: 'last' }, y1: { type: 'trend', indicator: 'renewable-electricity-share', year: 'last', format: 'year' }, }, }, { template: '{n} of {total} countries with data generated more than half of their electricity from renewables in {y}.', vars: { n: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'n' }, total: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'total' }, y: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'year' }, }, }, ], }, { kind: 'map', indicator: 'renewable-electricity-share', years: [1990, 2005, 2015, 2025], title: 'Share of electricity from renewables' }, { kind: 'trend', indicator: 'renewable-electricity-share', groups: ['world', 'european-union', 'east-asia-pacific'], title: 'Renewable share of electricity' }, { kind: 'lines', indicator: 'solar-generation', countries: ['CHN', 'USA', 'IND', 'DEU', 'JPN', 'BRA'], from: 2000, title: 'Solar electricity generation' }, { kind: 'lines', indicator: 'wind-generation', countries: ['CHN', 'USA', 'DEU', 'IND', 'BRA', 'GBR'], from: 2000, title: 'Wind electricity generation' }, { kind: 'ranked', indicator: 'renewable-electricity-share', top: 12, title: 'Highest renewable shares' }, ], }, { slug: 'internet-adoption-since-1990', title: 'Internet adoption since 1990', standfirst: 'From a laboratory network to the default way most people read, pay and talk: three decades of one of the fastest diffusions of a technology ever measured.', published: '2026-09-11', topics: ['digital'], blocks: [ { kind: 'text', paragraphs: [ { template: 'In {y0}, {v0} of the world population used the Internet. In {y1} the population-weighted share was {v1}.', vars: { y0: { type: 'trend', indicator: 'internet-users', year: 2000, format: 'year' }, v0: { type: 'trend', indicator: 'internet-users', year: 2000 }, y1: { type: 'trend', indicator: 'internet-users', year: 2023, format: 'year' }, v1: { type: 'trend', indicator: 'internet-users', year: 2023 }, }, }, { template: '{n} of {total} countries with data had at least nine users in ten in {y}.', vars: { n: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'n' }, total: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'total' }, y: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'year' }, }, }, ], }, { kind: 'map', indicator: 'internet-users', years: [1995, 2005, 2015, 2023], title: 'Internet users, % of population' }, { kind: 'trend', indicator: 'internet-users', groups: ['world', 'high-income', 'lower-middle-income', 'low-income'], title: 'Internet users by income group' }, { kind: 'lines', indicator: 'internet-users', countries: ['KOR', 'USA', 'BRA', 'CHN', 'IND', 'NGA'], title: 'Six adoption curves' }, { kind: 'ranked', indicator: 'internet-users', year: 2023, top: 10, title: 'Most connected countries' }, ], }, { slug: 'shifting-centre-of-the-world-economy', title: 'The shifting centre of the world economy', standfirst: 'Summing the GDP of every country by region shows the world economy tilting toward East Asia and the Pacific since 1990, while North America holds and Europe recedes in share.', published: '2026-09-11', topics: ['economy'], blocks: [ { kind: 'text', paragraphs: [ { template: 'East Asia & Pacific produced {s0} of the world total in {y0} and {s1} in {y1}.', vars: { s0: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 1990 }, y0: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 1990, format: 'year' }, s1: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 'last' }, y1: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 'last', format: 'year' }, }, }, { template: 'Europe & Central Asia went from {s0} to {s1} over the same period; North America from {n0} to {n1}.', vars: { s0: { type: 'share', indicator: 'gdp', group: 'europe-central-asia', year: 1990 }, s1: { type: 'share', indicator: 'gdp', group: 'europe-central-asia', year: 'last' }, n0: { type: 'share', indicator: 'gdp', group: 'north-america', year: 1990 }, n1: { type: 'share', indicator: 'gdp', group: 'north-america', year: 'last' }, }, }, ], }, { kind: 'shares', indicator: 'gdp', groups: WB_REGIONS, from: 1970, title: 'Share of world GDP by World Bank region' }, { kind: 'trend', indicator: 'gdp', groups: ['east-asia-pacific', 'north-america', 'europe-central-asia', 'south-asia'], from: 1970, log: true, title: 'GDP by region, current US$' }, { kind: 'map', indicator: 'gdp-per-capita-ppp', years: [1990, 2005, 2025], title: 'GDP per capita (PPP)' }, { kind: 'ranked', indicator: 'gdp', top: 12, title: 'Largest economies' }, ], }, { slug: 'global-fertility-collapse', title: 'Global fertility collapse', standfirst: 'In 1960 the average woman had about five children. Today most of humanity lives in countries below the replacement rate of 2.1.', published: '2026-09-11', topics: ['population'], blocks: [ { kind: 'text', paragraphs: [ { template: 'The population-weighted fertility rate fell from {v0} births per woman in {y0} to {v1} in {y1}.', vars: { v0: { type: 'trend', indicator: 'fertility-rate', year: 'first' }, y0: { type: 'trend', indicator: 'fertility-rate', year: 'first', format: 'year' }, v1: { type: 'trend', indicator: 'fertility-rate', year: 'last' }, y1: { type: 'trend', indicator: 'fertility-rate', year: 'last', format: 'year' }, }, }, { template: '{n0} countries were below the 2.1 replacement rate in {y0}; {n1} of {total} were in {y1}.', vars: { n0: { type: 'mapCount', indicator: 'fertility-rate', year: 1990, op: 'lt', threshold: 2.1, format: 'n' }, y0: { type: 'mapCount', indicator: 'fertility-rate', year: 1990, op: 'lt', threshold: 2.1, format: 'year' }, n1: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'n' }, total: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'total' }, y1: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'year' }, }, }, ], }, { kind: 'map', indicator: 'fertility-rate', years: [1960, 1980, 2000, 2024], title: 'Births per woman' }, { kind: 'trend', indicator: 'fertility-rate', groups: ['world', 'sub-saharan-africa', 'east-asia-pacific', 'europe-central-asia'], title: 'Fertility by region' }, { kind: 'lines', indicator: 'fertility-rate', countries: ['KOR', 'CHN', 'IRN', 'BRA', 'BGD', 'NER'], title: 'Six trajectories' }, { kind: 'ranked', indicator: 'fertility-rate', top: 10, sort: 'asc', title: 'Lowest fertility rates' }, ], }, { slug: 'fifty-years-of-life-expectancy', title: '50 years of life expectancy', standfirst: 'Half a century of gains on every continent — interrupted by AIDS, transitions and a pandemic, but rarely reversed for long.', published: '2026-09-11', topics: ['health'], blocks: [ { kind: 'text', paragraphs: [ { template: 'World life expectancy at birth, weighted by population, was {v0} in {y0} and {v1} in {y1}.', vars: { v0: { type: 'trend', indicator: 'life-expectancy', year: 1974 }, y0: { type: 'trend', indicator: 'life-expectancy', year: 1974, format: 'year' }, v1: { type: 'trend', indicator: 'life-expectancy', year: 'last' }, y1: { type: 'trend', indicator: 'life-expectancy', year: 'last', format: 'year' }, }, }, { template: '{n} of {total} countries with data reached 80 years or more in {y}; the highest is {name} at {v}.', vars: { n: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'n' }, total: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'total' }, y: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'year' }, name: { type: 'rankTop', indicator: 'life-expectancy', format: 'name' }, v: { type: 'rankTop', indicator: 'life-expectancy', format: 'value' }, }, }, ], }, { kind: 'map', indicator: 'life-expectancy', years: [1974, 1990, 2005, 2024], title: 'Life expectancy at birth' }, { kind: 'trend', indicator: 'life-expectancy', groups: ['world', 'sub-saharan-africa', 'south-asia', 'high-income'], from: 1974, title: 'Life expectancy by group' }, { kind: 'lines', indicator: 'life-expectancy', countries: ['JPN', 'KOR', 'CHN', 'IND', 'NGA', 'USA'], from: 1974, title: 'Six countries, fifty years' }, { kind: 'ranked', indicator: 'life-expectancy', top: 10, title: 'Longest lives' }, ], }, ]; export function storyBySlug(slug: string): Story | undefined { return STORIES.find((s) => s.slug === slug); } /** Distinct indicator slugs a story draws on (for metadata, JSON-LD and the "indicators used" list). */ export function storyIndicators(story: Story): string[] { const out: string[] = []; for (const b of story.blocks) { if (b.kind === 'text') { for (const p of b.paragraphs) for (const v of Object.values(p.vars)) if (!out.includes(v.indicator)) out.push(v.indicator); } else if (!out.includes(b.indicator)) out.push(b.indicator); } return out; } export function storyChartCount(story: Story): number { return story.blocks.filter((b) => b.kind !== 'text').length; }