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%
6.0 KB · 122 lines tsx
Raw Blame History
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