SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
22.3 KB · 291 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { t } from '@/i18n';4import { API_URL } from '@/lib/api';5import { API_VERSION, SITE_URL, routes } from '@/lib/site';6import { Section } from '@/components/data/section';7import { CodeBlock } from '@/components/explore/copy-button';8import { PageHeader } from '@/components/explore/page-header';9import { FooterCredits } from '@/components/layout/site-footer';10import { EndpointExplorer, type ExplorerEndpoint } from '@/components/platform/endpoint-explorer';1112export const revalidate = 3600;1314export const metadata: Metadata = {15  title: t('apiPage.title'),16  description: t('apiPage.sub'),17  alternates: { canonical: routes.api() },18};1920const BASE = `${SITE_URL}/api/v1`;2122type Row = [string, string, string]; // endpoint, description, group key23const ENDPOINTS: Row[] = [24  ['GET /health', t('apiPage.ep.health'), 'reference'],25  ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries'), 'countries'],26  ['GET /countries/{id}', t('apiPage.ep.country'), 'countries'],27  ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic'), 'countries'],28  ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries'), 'countries'],29  ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges'), 'countries'],30  ['GET /countries/{id}/events', t('apiPage.ep.countryEvents'), 'countries'],31  ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar'), 'countries'],32  ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights'), 'countries'],33  ['GET /countries/{id}/dna?reference=', t('apiPage.ep.countryDna'), 'countries'],34  ['GET /countries/{id}/story', t('apiPage.ep.story'), 'analytics'],35  ['GET /countries/{id}/quality', t('apiPage.ep.countryQuality'), 'analytics'],36  ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload'), 'countries'],37  ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators'), 'indicators'],38  ['GET /indicators/{slug}', t('apiPage.ep.indicator'), 'indicators'],39  ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap'), 'indicators'],40  ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend'), 'indicators'],41  ['GET /indicators/{slug}/frames?from=&to=', t('apiPage.ep.frames'), 'analytics'],42  ['GET /indicators/{slug}/distribution?year=&highlight=', t('apiPage.ep.distribution'), 'analytics'],43  ['GET /indicators/{slug}/related?limit=', t('apiPage.ep.related'), 'analytics'],44  ['GET /indicators/{slug}/quality', t('apiPage.ep.indicatorQuality'), 'analytics'],45  ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload'), 'indicators'],46  ['GET /series?country=&indicator=', t('apiPage.ep.series'), 'indicators'],47  ['GET /rankings?topic=', t('apiPage.ep.rankings'), 'rankings'],48  ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking'), 'rankings'],49  ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory'), 'rankings'],50  ['GET /rankings/{indicator}/race?from=&to=&top=', t('apiPage.ep.race'), 'analytics'],51  ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare'), 'rankings'],52  ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot'), 'rankings'],53  ['GET /compare/download.csv|json?countries=&indicators=', t('apiPage.ep.download'), 'rankings'],54  ['GET /regions?kind=', t('apiPage.ep.regions'), 'rankings'],55  ['GET /regions/{slug}?indicator=', t('apiPage.ep.region'), 'rankings'],56  ['GET /regions/compare?a=&b=', t('apiPage.ep.regionsCompare'), 'analytics'],57  ['GET /pulse', t('apiPage.ep.pulse'), 'analytics'],58  ['GET /movers?window=&category=&kind=', t('apiPage.ep.movers'), 'analytics'],59  ['GET /extremes?window=&topic=', t('apiPage.ep.extremes'), 'analytics'],60  ['GET /scatter?x=&y=&size=&year=', t('apiPage.ep.scatter'), 'analytics'],61  ['GET /trajectory?x=&y=&size=&from=&to=', t('apiPage.ep.trajectory'), 'analytics'],62  ['GET /finder?f=slug:op:value&mode=', t('apiPage.ep.finder'), 'analytics'],63  ['GET /peers?y=&x=&year=', t('apiPage.ep.peers'), 'analytics'],64  ['GET /search?q=&type=', t('apiPage.ep.search'), 'reference'],65  ['GET /home', t('apiPage.ep.home'), 'reference'],66  ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes'), 'reference'],67  ['GET /updates', t('apiPage.ep.updates'), 'analytics'],68  ['GET /sources', t('apiPage.ep.sources'), 'reference'],69  ['GET /sources/{id}', t('apiPage.ep.source'), 'reference'],70  ['GET /methodology', t('apiPage.ep.methodology'), 'reference'],71];72const GROUP_ORDER = ['countries', 'indicators', 'rankings', 'analytics', 'reference'] as const;7374const EXPLORER: ExplorerEndpoint[] = [75  { id: 'country', group: 'Countries', template: '/countries/{id}', summary: t('apiPage.ep.country'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },76  { id: 'series', group: 'Countries', template: '/countries/{id}/series/{indicator}', summary: t('apiPage.ep.countrySeries'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'CAN' }, { name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'from', in: 'query', description: 'First year.', example: '2000' }, { name: 'to', in: 'query', description: 'Last year.' }, { name: 'include_alt', in: 'query', description: 'Also return values from lower-priority sources.', options: ['false', 'true'] }] },77  { id: 'similar', group: 'Countries', template: '/countries/{id}/similar', summary: t('apiPage.ep.countrySimilar'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }, { name: 'mode', in: 'query', description: 'Similarity mode.', options: ['overall', 'economic', 'demographic', 'energy', 'social'], example: 'overall' }, { name: 'limit', in: 'query', description: 'Peers returned (≤ 50).', example: '8' }] },78  { id: 'story', group: 'Countries', template: '/countries/{id}/story', summary: t('apiPage.ep.story'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },79  { id: 'quality', group: 'Countries', template: '/countries/{id}/quality', summary: t('apiPage.ep.countryQuality'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },80  { id: 'indicator', group: 'Indicators', template: '/indicators/{slug}', summary: t('apiPage.ep.indicator'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }] },81  { id: 'map', group: 'Indicators', template: '/indicators/{slug}/map', summary: t('apiPage.ep.indicatorMap'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'year', in: 'query', description: 'Year (default: latest year with ≥ 50 countries).', example: '2023' }, { name: 'nearest', in: 'query', description: 'Use each country’s latest value within 3 years.', options: ['false', 'true'] }] },82  { id: 'trend', group: 'Indicators', template: '/indicators/{slug}/trend', summary: t('apiPage.ep.indicatorTrend'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'group', in: 'query', description: 'Group slug (world, oecd, g7, europe-central-asia…).', example: 'oecd' }] },83  { id: 'frames', group: 'Indicators', template: '/indicators/{slug}/frames', summary: t('apiPage.ep.frames'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita-ppp' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }, { name: 'to', in: 'query', description: 'Last year.' }] },84  { id: 'distribution', group: 'Indicators', template: '/indicators/{slug}/distribution', summary: t('apiPage.ep.distribution'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'highlight', in: 'query', description: 'Country to place on the distribution (ISO3).', example: 'CAN' }, { name: 'year', in: 'query', description: 'Year.' }] },85  { id: 'related', group: 'Indicators', template: '/indicators/{slug}/related', summary: t('apiPage.ep.related'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'limit', in: 'query', description: 'Rows.', example: '12' }] },86  { id: 'ranking', group: 'Rankings & comparisons', template: '/rankings/{indicator}', summary: t('apiPage.ep.ranking'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'year', in: 'query', description: 'Ranking year (nearest available).' }, { name: 'group', in: 'query', description: 'Group slug (default world).', example: 'world' }, { name: 'sort', in: 'query', description: 'Direction.', options: ['desc', 'asc'] }, { name: 'limit', in: 'query', description: 'Rows (≤ 300).', example: '10' }] },87  { id: 'race', group: 'Rankings & comparisons', template: '/rankings/{indicator}/race', summary: t('apiPage.ep.race'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'from', in: 'query', description: 'First year.', example: '1960' }, { name: 'top', in: 'query', description: 'Top N per year.', example: '10' }] },88  { id: 'compare', group: 'Rankings & comparisons', template: '/compare', summary: t('apiPage.ep.compare'), params: [{ name: 'countries', in: 'query', description: 'Comma-separated ISO3 or slugs (2–8).', example: 'CAN,USA,FRA', required: true }, { name: 'indicators', in: 'query', description: 'Comma-separated slugs (1–8).', example: 'gdp-per-capita', required: true }, { name: 'mode', in: 'query', description: 'Transformation.', options: ['absolute', 'per-capita', 'index100', 'pct'] }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },89  { id: 'regions-compare', group: 'Rankings & comparisons', template: '/regions/compare', summary: t('apiPage.ep.regionsCompare'), params: [{ name: 'a', in: 'query', description: 'Group slug.', example: 'g7', required: true }, { name: 'b', in: 'query', description: 'Group slug.', example: 'brics', required: true }] },90  { id: 'pulse', group: 'Analytics (1.1)', template: '/pulse', summary: t('apiPage.ep.pulse'), params: [] },91  { id: 'movers', group: 'Analytics (1.1)', template: '/movers', summary: t('apiPage.ep.movers'), params: [{ name: 'window', in: 'query', description: 'Years.', options: ['1', '5', '10'], example: '1' }, { name: 'category', in: 'query', description: 'Category.', options: ['all', 'economic', 'demographic', 'health', 'energy', 'climate', 'digital', 'housing', 'labor'] }, { name: 'kind', in: 'query', description: 'Kind filter.', options: ['all', 'improvement', 'deterioration', 'increase', 'decrease', 'record', 'reversal', 'acceleration', 'structural'] }, { name: 'limit', in: 'query', description: 'Rows.', example: '20' }] },92  { id: 'extremes', group: 'Analytics (1.1)', template: '/extremes', summary: t('apiPage.ep.extremes'), params: [{ name: 'window', in: 'query', description: 'Window.', options: ['1', '5', '10', '25', 'since1990'], example: '10' }, { name: 'topic', in: 'query', description: 'Topic id filter.' }] },93  { id: 'scatter', group: 'Analytics (1.1)', template: '/scatter', summary: t('apiPage.ep.scatter'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator (or none).', example: 'population' }, { name: 'year', in: 'query', description: 'Year.' }, { name: 'group', in: 'query', description: 'Group slug.', example: 'world' }] },94  { id: 'trajectory', group: 'Analytics (1.1)', template: '/trajectory', summary: t('apiPage.ep.trajectory'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator.', example: 'population' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },95  { id: 'finder', group: 'Analytics (1.1)', template: '/finder', summary: t('apiPage.ep.finder'), params: [{ name: 'f', in: 'query', description: 'Filter slug:op:value (ops gt gte lt lte eq between a..b). One filter here; the API accepts several f= parameters.', example: 'gdp-per-capita:gt:40000', required: true }, { name: 'mode', in: 'query', description: 'Combine filters.', options: ['and', 'or'] }, { name: 'region', in: 'query', description: 'Group slug.' }, { name: 'limit', in: 'query', description: 'Rows (≤ 218).', example: '50' }] },96  { id: 'peers', group: 'Analytics (1.1)', template: '/peers', summary: t('apiPage.ep.peers'), params: [{ name: 'y', in: 'query', description: 'Outcome indicator.', example: 'life-expectancy' }, { name: 'x', in: 'query', description: 'Explanatory indicator.', example: 'gdp-per-capita-ppp' }, { name: 'method', in: 'query', description: 'Fit.', options: ['theil-sen', 'ols'] }] },97  { id: 'search', group: 'Reference', template: '/search', summary: t('apiPage.ep.search'), params: [{ name: 'q', in: 'query', description: 'Query (try "compare canada usa" or "rank gdp").', example: 'canada gdp', required: true }, { name: 'limit', in: 'query', description: 'Hits.', example: '8' }] },98  { id: 'changes', group: 'Reference', template: '/changes', summary: t('apiPage.ep.changes'), params: [{ name: 'kind', in: 'query', description: 'Change kind.', options: ['yoy_jump', 'yoy_drop', 'record_high', 'record_low', 'n_year_high', 'n_year_low', 'sign_flip', 'accelerating', 'decelerating', 'structural_break', 'trend_reversal', 'volatility_spike'] }, { name: 'topic', in: 'query', description: 'Topic id.' }, { name: 'min_severity', in: 'query', description: '0–1.', example: '0.7' }, { name: 'limit', in: 'query', description: 'Rows.', example: '10' }] },99  { id: 'updates', group: 'Reference', template: '/updates', summary: t('apiPage.ep.updates'), params: [] },100  { id: 'health', group: 'Reference', template: '/health', summary: t('apiPage.ep.health'), params: [] },101];102103const NEW_IN_11 = ['pulse', 'movers', 'extremes', 'scatter', 'trajectory', 'finder', 'peers', 'related', 'distribution', 'frames', 'indicatorQuality', 'race', 'regionsCompare', 'story', 'countryQuality', 'updates'] as const;104const NEW_PATHS: Record<(typeof NEW_IN_11)[number], string> = {105  pulse: '/pulse',106  movers: '/movers',107  extremes: '/extremes',108  scatter: '/scatter',109  trajectory: '/trajectory',110  finder: '/finder',111  peers: '/peers',112  related: '/indicators/{slug}/related',113  distribution: '/indicators/{slug}/distribution',114  frames: '/indicators/{slug}/frames',115  indicatorQuality: '/indicators/{slug}/quality',116  race: '/rankings/{indicator}/race',117  regionsCompare: '/regions/compare',118  story: '/countries/{id}/story',119  countryQuality: '/countries/{id}/quality',120  updates: '/updates',121};122123const PROVENANCE_FIELDS: Array<[string, string]> = [124  ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'],125  ['dataset, series_code', 'Dataset and series inside the source (WDI NY.GDP.PCAP.CD, WEO NGDPDPC, OWID co2_per_capita…).'],126  ['retrieved_at', 'When the pipeline fetched the payload (UTC).'],127  ['source_updated_at', 'Last update advertised by the source; null when it publishes none.'],128  ['url', 'Deep link to the series at the source, with the country when the portal supports it.'],129  ['transform', 'Expression applied at normalisation (e.g. x*1e9), null if none.'],130  ['licence', 'Licence of the source for this series.'],131];132133async function liveVersion(): Promise<string | null> {134  try {135    const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1/health`, { headers: { accept: 'application/json' }, next: { revalidate: 600 } });136    if (!res.ok) return null;137    const j = (await res.json()) as { run_id?: string | null };138    return j.run_id ?? null;139  } catch {140    return null;141  }142}143144export default async function ApiPage() {145  const runId = await liveVersion();146  return (147    <>148      <PageHeader149        title={t('apiPage.title')}150        lede={t('apiPage.sub')}151        eyebrow={152          <span className="inline-flex items-center gap-2">153            <span className="badge border-accent/40 bg-accent-soft text-accent">{t('apiPage.version', { v: API_VERSION })}</span>154            {runId ? <span className="tnum normal-case tracking-normal">{t('site.footer.build', { run: runId })}</span> : null}155          </span>156        }157      />158      <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>159      <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3">160        <div>161          <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.base')}</dt>162          <dd className="mt-1 break-all font-mono text-ink">{BASE}</dd>163        </div>164        <div>165          <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.swagger')}</dt>166          <dd className="mt-1">167            <a href={routes.apiSwagger()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">168              {routes.apiSwagger()}169            </a>170          </dd>171        </div>172        <div>173          <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.openapi')}</dt>174          <dd className="mt-1">175            <a href={routes.apiOpenapi()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">176              {routes.apiOpenapi()}177            </a>178          </dd>179        </div>180      </dl>181182      <Section id="explorer" title={t('apiPage.explorer.title')} subtitle={t('apiPage.explorer.sub')} className="border-t-0">183        <EndpointExplorer endpoints={EXPLORER} base={BASE} />184      </Section>185186      <Section id="new" title={t('apiPage.new.title')} subtitle={t('apiPage.new.sub')}>187        <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">188          {NEW_IN_11.map((k) => (189            <li key={k} className="border-t border-rule py-2.5">190              <code className="block break-all font-mono text-xs text-ink">GET {NEW_PATHS[k]}</code>191              <span className="mt-0.5 block text-sm text-ink-2">{t(`apiPage.ep.${k}` as 'apiPage.ep.pulse')}</span>192            </li>193          ))}194        </ul>195      </Section>196197      <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')}>198        {GROUP_ORDER.map((g) => (199          <div key={g} className="mb-6">200            <h3 className="eyebrow mb-1">{t(`apiPage.group.${g}` as 'apiPage.group.countries')}</h3>201            <table className="w-full border-collapse text-sm">202              <thead className="sr-only">203                <tr>204                  <th scope="col">{t('apiPage.endpoint')}</th>205                  <th scope="col">{t('apiPage.describes')}</th>206                </tr>207              </thead>208              <tbody className="divide-y divide-rule border-y border-rule">209                {ENDPOINTS.filter((e) => e[2] === g).map(([ep, desc]) => (210                  <tr key={ep}>211                    <td className="py-2 pr-3 align-top md:w-[46%]">212                      <code className="break-all font-mono text-xs text-ink">{ep}</code>213                    </td>214                    <td className="py-2 align-top text-ink-2">{desc}</td>215                  </tr>216                ))}217              </tbody>218            </table>219          </div>220        ))}221      </Section>222223      <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>224        <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.provenance.text')}</p>225        <CodeBlock226          className="mt-3 max-w-3xl"227          lang="json"228          code={JSON.stringify(229            {230              value: 55697.66,231              period: '2025-01-01',232              year: 2025,233              formatted: 'US$55.7k',234              status: 'imported',235              provenance: { source: 'worldbank', source_name: 'World Bank', dataset: 'WDI', series_code: 'NY.GDP.PCAP.CD', retrieved_at: '2026-09-11T06:49:30Z', source_updated_at: '2026-07-13T00:00:00Z', url: 'https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA', transform: null, licence: 'CC BY 4.0' },236            },237            null,238            2,239          )}240        />241        <table className="mt-4 w-full max-w-3xl border-collapse text-sm">242          <thead>243            <tr className="border-b border-rule text-left text-xs text-ink-3">244              <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.field')}</th>245              <th scope="col" className="py-1.5 font-medium">{t('apiPage.meaning')}</th>246            </tr>247          </thead>248          <tbody className="divide-y divide-rule">249            {PROVENANCE_FIELDS.map(([f, d]) => (250              <tr key={f}>251                <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{f}</td>252                <td className="py-2 align-top text-ink-2">{d}</td>253              </tr>254            ))}255          </tbody>256        </table>257      </Section>258259      <div className="grid gap-x-10 lg:grid-cols-2">260        <Section id="limits" title={t('apiPage.limits.title')}>261          <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.limits.text')}</p>262        </Section>263        <Section id="errors" title={t('apiPage.errors.title')}>264          <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.errors.text')}</p>265        </Section>266      </div>267268      <Section id="licence" title={t('apiPage.licence.title')}>269        <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.licence.text')}</p>270        <p className="mt-2 text-sm">271          <Link href={routes.sources()} className="text-accent hover:underline">272            {t('sources.title')} →273          </Link>274          <span className="mx-2 text-ink-3">·</span>275          <Link href={routes.methodology()} className="text-accent hover:underline">276            {t('method.title')} →277          </Link>278          <span className="mx-2 text-ink-3">·</span>279          <Link href={routes.download()} className="text-accent hover:underline">280            {t('download.title')} →281          </Link>282        </p>283        <div className="mt-6 text-sm text-ink-2">284          <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span>285          <FooterCredits className="mt-1 inline text-xs" />286        </div>287      </Section>288    </>289  );290}291