spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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