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%
13.2 KB · 217 lines tsx
Raw Blame History
1'use client';2import { ChevronRight } from 'lucide-react';3import Link from 'next/link';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { COMPARE_TOPIC_TABS, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state';7import { displayValue, formatChange, isNum, ordinal } from '@/lib/format';8import { routes } from '@/lib/site';9import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare';10import type { MetricValue } from '@/lib/types';11import { seriesVar } from '@/components/charts/palette';12import { payloadFor } from '@/components/data/metric';13import { useProvenance } from '@/components/data/provenance-context';1415/** Cell text for the current value mode: absolute value, world percentile (from rank / n) or 10-year change. */16export function cellText(m: MetricValue, mode: CompareState['mode']): { text: string; sub: string | null } {17  if (mode === 'percentile') {18    if (isNum(m.rank_world) && isNum(m.n_world) && m.n_world > 1) return { text: t('compare.cell.percentile', { p: ordinal(Math.round((1 - (m.rank_world - 1) / (m.n_world - 1)) * 100)) }), sub: `${m.rank_world}/${m.n_world}` };19    return { text: t('common.na'), sub: null };20  }21  if (mode === 'change') {22    const c = m.change_10y ? formatChange(m.change_10y.abs, m.change_10y.pct, m) : null;23    return c ? { text: m.change_10y?.formatted ?? c.text, sub: t('compare.cell.change10y') } : { text: t('common.na'), sub: null };24  }25  return { text: displayValue(m.value, m, m.formatted), sub: null };26}2728/** Where a snapshot indicator's chart lives: its topic tab when it is one of the compare tabs, else the custom tab. */29export function chartHrefFor(slugs: string[], state: CompareState, indicator: { slug: string; topic: string | null }): string {30  const tab = (COMPARE_TOPIC_TABS as readonly string[]).includes(indicator.topic ?? '') ? (indicator.topic as CompareTab) : 'custom';31  const indicators = tab === 'custom' ? Array.from(new Set([indicator.slug, ...state.indicators])).slice(0, 6) : state.indicators;32  return `${routes.compare(...slugs)}${compareQuery({ ...state, tab, indicator: indicator.slug, indicators })}`;33}3435/**36 * Compact latest-values table used inside a collapsed subtopic block (no charts): one row per indicator,37 * one cell per country (value · year, best highlighted). Indicator name → its chart (hero). Any cell →38 * provenance. Phones: value cells wrap under the indicator name.39 */40export function SnapshotCompact({ rows, countries, slugs, state }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; slugs: string[]; state: CompareState }) {41  const { open } = useProvenance();42  const openCell = (m: MetricValue, c: CountryLite, name: string) => open(payloadFor(m, { id: c.id, slug: c.slug, name: c.name, flag: c.flag }, { name }));43  if (rows.length === 0) return null;44  const latestYear = Math.max(0, ...rows.flatMap((r) => Object.values(r.values).map((v) => v.year ?? 0)));45  return (46    <ul className="divide-y divide-rule border-b border-rule text-sm" aria-label={t('group.latestCol')}>47      {rows.map((r) => {48        const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;49        return (50          <li key={r.indicator.slug} className="grid gap-x-4 gap-y-1 py-1.5 md:grid-cols-[minmax(0,14rem)_1fr] lg:grid-cols-[minmax(0,18rem)_1fr]">51            <Link href={chartHrefFor(slugs, state, r.indicator)} className="group/i inline-flex min-h-[32px] min-w-0 items-center gap-1 text-ink hover:text-accent" aria-label={t('compare.snapshot.openChart', { name })}>52              <span className="truncate">{r.indicator.name ?? name}</span>53              <ChevronRight size={12} aria-hidden className="shrink-0 text-ink-3 group-hover/i:text-accent" />54            </Link>55            <ul className="flex flex-wrap gap-x-4 gap-y-0.5">56              {countries.map((c, i) => {57                const m = r.values[c.id];58                const has = !!m && m.has_data && isNum(m.value);59                const best = r.best === c.id;60                const stale = has && m.year != null && latestYear - m.year >= 2;61                return (62                  <li key={c.id}>63                    <button type="button" disabled={!has} onClick={() => has && openCell(m, c, r.indicator.name ?? name)} className="inline-flex min-h-[32px] items-center gap-1.5 rounded-sm px-1 hover:bg-surface-2 disabled:hover:bg-transparent" aria-label={has ? `${c.name}: ${displayValue(m.value, m, m.formatted)} (${m.year ?? ''}). ${t('common.openProvenance')}` : `${c.name}: ${t('common.noData')}`}>64                      <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />65                      <span aria-hidden>{c.flag}</span>66                      <span className={cn('tnum', has ? (best ? 'font-semibold text-ink' : 'text-ink') : 'text-ink-3')}>{has ? displayValue(m.value, m, m.formatted) : t('common.noData')}</span>67                      {has && m.year != null ? <span className={cn('tnum text-2xs', stale ? 'rounded-xs bg-surface-2 px-1 text-ink-2' : 'text-ink-3')}>{m.year}</span> : null}68                    </button>69                  </li>70                );71              })}72            </ul>73          </li>74        );75      })}76    </ul>77  );78}7980/**81 * Snapshot comparison: countries as columns on md+ (value · year · rank chip per cell, best value highlighted);82 * on phones each indicator is a block with one horizontal mini bar per country — no horizontal overflow.83 * Any cell → provenance sheet; the indicator name → that indicator's chart.84 */85export function SnapshotTable({ rows, countries, slugs, state }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; slugs: string[]; state: CompareState }) {86  const { open } = useProvenance();87  const visible = rows.filter((r) => countries.some((c) => r.values[c.id]?.has_data));88  if (visible.length === 0) return <p className="py-6 text-sm text-ink-3">{t('compare.snapshot.empty')}</p>;8990  const openCell = (m: MetricValue, c: CountryLite, name: string) => open(payloadFor(m, { id: c.id, slug: c.slug, name: c.name, flag: c.flag }, { name }));9192  return (93    <div className="min-w-0">94      {/* Desktop table */}95      <table className="hidden w-full border-collapse text-sm md:table">96        <caption className="sr-only">{t('compare.snapshot.title')}</caption>97        <thead>98          <tr className="border-b border-rule text-left text-xs text-ink-3">99            <th scope="col" className="w-[30%] py-2 pr-3 font-medium">100              {t('compare.snapshot.indicatorCol')}101            </th>102            {countries.map((c, i) => (103              <th key={c.id} scope="col" className="py-2 pr-3 text-right font-medium">104                <Link href={routes.country(c.slug)} className="link-quiet inline-flex max-w-full items-center gap-1.5">105                  <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />106                  <span aria-hidden>{c.flag}</span>107                  <span className="truncate text-ink">{c.name}</span>108                </Link>109              </th>110            ))}111          </tr>112        </thead>113        <tbody className="divide-y divide-rule">114          {visible.map((r) => {115            const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;116            const hib = r.indicator.higher_is_better;117            return (118              <tr key={r.indicator.slug} className="hover:bg-surface-2/50">119                <th scope="row" className="py-2 pr-3 text-left align-top font-normal">120                  <Link href={chartHrefFor(slugs, state, r.indicator)} className="group/i inline-flex min-h-[32px] items-center gap-1 text-sm font-medium text-ink hover:text-accent" aria-label={t('compare.snapshot.openChart', { name })}>121                    <span>{r.indicator.name ?? name}</span>122                    <ChevronRight size={13} aria-hidden className="shrink-0 text-ink-3 group-hover/i:text-accent" />123                  </Link>124                  <div className="text-2xs text-ink-3">125                    {r.indicator.unit}126                    {hib != null ? <span> · {hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}</span> : null}127                  </div>128                </th>129                {countries.map((c) => {130                  const m = r.values[c.id];131                  const best = r.best === c.id;132                  if (!m || !m.has_data)133                    return (134                      <td key={c.id} className="py-2 pr-3 text-right align-top text-ink-3">135                        {t('common.noData')}136                      </td>137                    );138                  return (139                    <td key={c.id} className={cn('py-1 pr-1 text-right align-top', best && 'bg-accent-soft/60')}>140                      <button type="button" onClick={() => openCell(m, c, r.indicator.name ?? name)} className="group/c -my-0 flex min-h-[44px] w-full flex-col items-end rounded-sm px-2 py-1 text-right hover:bg-surface-2" aria-label={t('common.openProvenance')}>141                        <span className={cn('tnum text-base leading-tight', best ? 'font-semibold text-ink' : 'text-ink')}>{cellText(m, state.mode).text}</span>142                        <span className="tnum mt-0.5 flex items-center gap-1.5 text-2xs text-ink-3">143                          <span>{cellText(m, state.mode).sub ?? m.year ?? ''}</span>144                          {isNum(m.rank_world) && isNum(m.n_world) ? (145                            <span className={cn('rounded-xs px-1 py-px', best ? 'bg-accent text-accent-ink' : 'bg-surface-2 text-ink-2')} title={t('compare.snapshot.rankOf', { rank: ordinal(m.rank_world), n: m.n_world })}>146                              {t('compare.snapshot.rank', { rank: m.rank_world })}147                            </span>148                          ) : null}149                        </span>150                      </button>151                    </td>152                  );153                })}154              </tr>155            );156          })}157        </tbody>158      </table>159160      {/* Phone: one block per indicator, one bar per country */}161      <ul className="divide-y divide-rule md:hidden">162        {visible.map((r) => {163          const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;164          const vals = countries.map((c) => r.values[c.id]?.value).filter(isNum);165          const max = vals.length ? Math.max(...vals.map(Math.abs)) : 0;166          return (167            <li key={r.indicator.slug} className="py-3">168              <Link href={chartHrefFor(slugs, state, r.indicator)} className="group/i flex min-h-[44px] items-center justify-between gap-2" aria-label={t('compare.snapshot.openChart', { name })}>169                <span className="min-w-0">170                  <span className="block text-sm font-medium text-ink group-hover/i:text-accent">{r.indicator.name ?? name}</span>171                  <span className="block truncate text-2xs text-ink-3">{r.indicator.unit}</span>172                </span>173                <span className="inline-flex shrink-0 items-center gap-0.5 text-2xs text-accent">174                  {t('compare.snapshot.showChart')}175                  <ChevronRight size={12} aria-hidden />176                </span>177              </Link>178              <ol className="mt-1 space-y-0.5">179                {countries.map((c, i) => {180                  const m = r.values[c.id];181                  const best = r.best === c.id;182                  const has = !!m && m.has_data && isNum(m.value);183                  const pct = has && max > 0 ? Math.max(1.5, (Math.abs(m.value!) / max) * 100) : 0;184                  return (185                    <li key={c.id}>186                      <button187                        type="button"188                        disabled={!has}189                        onClick={() => has && openCell(m, c, r.indicator.name ?? name)}190                        className="grid min-h-[44px] w-full grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-sm text-left hover:bg-surface-2 disabled:hover:bg-transparent"191                        aria-label={has ? `${c.name}: ${displayValue(m.value, m, m.formatted)} (${m.year ?? ''}). ${t('common.openProvenance')}` : `${c.name}: ${t('common.noData')}`}192                      >193                        <span aria-hidden className="text-base leading-none">194                          {c.flag}195                        </span>196                        <span className="min-w-0">197                          <span className="h-2 w-full overflow-hidden rounded-xs bg-surface-2" aria-hidden style={{ display: 'block' }}>198                            <span className="block h-full rounded-r-xs" style={{ width: `${pct}%`, background: seriesVar(i), opacity: best ? 1 : 0.8 }} />199                          </span>200                        </span>201                        <span className="tnum flex items-baseline gap-1.5 text-right">202                          <span className={cn('text-sm', best ? 'font-semibold text-ink' : 'text-ink')}>{has ? cellText(m, state.mode).text : t('common.noData')}</span>203                          <span className="text-2xs text-ink-3">{has ? cellText(m, state.mode).sub ?? m.year : ''}</span>204                        </span>205                      </button>206                    </li>207                  );208                })}209              </ol>210            </li>211          );212        })}213      </ul>214    </div>215  );216}217