spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { useEffect, useMemo, useState } from 'react';3import { t } from '@/i18n';4import { clientExplore } from '@/lib/client-api-explore';5import { cn } from '@/lib/cn';6import type { FormatSpec } from '@/lib/types';7import type { TrendResponse } from '@/lib/types-explore';8import { LineChart, type LineSeries } from '@/components/charts/line-chart';9import type { SeriesPoint } from '@/components/charts/scales';10import { EmptyState } from '@/components/data/empty-state';11import type { ProvenancePayload } from '@/components/data/provenance-context';1213export interface TrendGroupOption {14 id: string;15 label: string;16}1718/** Fixed group choices: World, organisations, World Bank regions, income groups (ids = group slugs). */19export const TREND_GROUPS: TrendGroupOption[] = [20 { id: 'world', label: 'World' },21 { id: 'oecd', label: 'OECD' },22 { id: 'european-union', label: 'European Union' },23 { id: 'g7', label: 'G7' },24 { id: 'g20', label: 'G20' },25 { id: 'brics', label: 'BRICS' },26 { id: 'north-america', label: 'North America' },27 { id: 'latin-america-caribbean', label: 'Latin America & Caribbean' },28 { id: 'europe-central-asia', label: 'Europe & Central Asia' },29 { id: 'middle-east-north-africa', label: 'Middle East & North Africa' },30 { id: 'south-asia', label: 'South Asia' },31 { id: 'east-asia-pacific', label: 'East Asia & Pacific' },32 { id: 'sub-saharan-africa', label: 'Sub-Saharan Africa' },33 { id: 'high-income', label: 'High income' },34 { id: 'upper-middle-income', label: 'Upper middle income' },35 { id: 'lower-middle-income', label: 'Lower middle income' },36 { id: 'low-income', label: 'Low income' },37];3839function pts(points: TrendResponse['points'], key: 'median' | 'mean' | 'weighted_mean' | 'sum'): SeriesPoint[] {40 return points.filter((p) => p[key] != null).map((p) => ({ period: `${p.year}-01-01`, year: p.year, value: p[key] }));41}4243/**44 * Aggregate trend for a group: the API's `preferred` aggregate (sum / population-weighted mean / median) as45 * the first series, the median as a second series when it differs. Group selector fetches other groups46 * client-side (`/indicators/{slug}/trend?group=`) and caches them.47 */48export function IndicatorTrend({ slug, initial, spec, payload }: { slug: string; initial: TrendResponse | null; spec: FormatSpec; payload: ProvenancePayload | null }) {49 const [group, setGroup] = useState('world');50 const [cache, setCache] = useState<Record<string, TrendResponse | null>>({ world: initial });51 const [loading, setLoading] = useState(false);5253 useEffect(() => {54 if (cache[group] !== undefined) return;55 const ctrl = new AbortController();56 setLoading(true);57 clientExplore58 .indicatorTrend(slug, group, ctrl.signal)59 .then((r) => setCache((c) => ({ ...c, [group]: r })))60 .catch(() => setCache((c) => ({ ...c, [group]: null })))61 .finally(() => {62 if (!ctrl.signal.aborted) setLoading(false);63 });64 return () => ctrl.abort();65 }, [group, slug, cache]);6667 const data = cache[group];68 const series: LineSeries[] = useMemo(() => {69 if (!data) return [];70 const pref = (data.preferred as 'sum' | 'weighted_mean' | 'median') ?? 'median';71 const out: LineSeries[] = [];72 const prefPts = pts(data.points, pref);73 if (prefPts.length) out.push({ id: pref, name: t(`indicator.trend.${pref}` as 'indicator.trend.median'), points: prefPts, colorIndex: 0 });74 if (pref !== 'median' && pref !== 'sum') {75 const med = pts(data.points, 'median');76 if (med.length) out.push({ id: 'median', name: t('indicator.trend.median'), points: med, colorIndex: 1 });77 }78 return out;79 }, [data]);8081 const ns = data?.points.map((p) => p.n) ?? [];82 const groupLabel = TREND_GROUPS.find((g) => g.id === group)?.label ?? data?.group.name ?? group;8384 return (85 <div className="min-w-0">86 <div className="mb-3 flex flex-wrap items-center gap-2">87 <label className="inline-flex h-11 items-center gap-2 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">88 <span className="text-xs font-medium">{t('indicator.trend.group')}</span>89 <select value={group} onChange={(e) => setGroup(e.target.value)} className="bg-transparent pr-1 text-ink outline-none" aria-label={t('indicator.trend.group')}>90 {TREND_GROUPS.map((g) => (91 <option key={g.id} value={g.id}>92 {g.label}93 </option>94 ))}95 </select>96 </label>97 <ul className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0" aria-hidden>98 {TREND_GROUPS.slice(0, 6).map((g) => (99 <li key={g.id} className="shrink-0">100 <button type="button" tabIndex={-1} onClick={() => setGroup(g.id)} className={cn('inline-flex h-11 items-center rounded-sm px-2.5 text-xs md:h-9', group === g.id ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>101 {g.label}102 </button>103 </li>104 ))}105 </ul>106 {ns.length ? <span className="tnum ml-auto text-2xs text-ink-3">{t('indicator.trend.n', { min: Math.min(...ns), max: Math.max(...ns) })}</span> : null}107 </div>108 <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 300 }}>109 {data === null || (data && series.length === 0) ? (110 <EmptyState compact title={t('indicator.trend.none', { group: groupLabel })} />111 ) : data ? (112 <LineChart series={series} spec={spec} subject={`${groupLabel} ${(spec.name ?? '').toLowerCase()}`} height={260} title={`${groupLabel} · ${spec.name ?? ''}`} subtitle={data.weights ? t('indicator.trend.weighted_mean') : t(`indicator.trend.${data.preferred}` as 'indicator.trend.median')} provenance={data.provenance[0] ?? null} payload={payload} defaultWidth={720} endLabels={false} />113 ) : (114 <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 260 }}>115 {t('common.loading')}116 </div>117 )}118 </div>119 </div>120 );121}122