spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import Link from 'next/link';2import { t } from '@/i18n';3import { cn } from '@/lib/cn';4import { formatValue, isNum, ordinal } from '@/lib/format';5import { routes } from '@/lib/site';6import type { FormatSpec as Spec, Provenance } from '@/lib/types';7import { MARK } from './palette';89export interface RankedBarRow {10 id: string;11 label: string;12 flag?: string | null;13 href?: string | null;14 value: number | null;15 rank?: number | null;16 /** Secondary text shown right of the value (e.g. change); hidden on phones. */17 hint?: string | null;18 /** Observation year — pass it only when it is older than the list's reference year (freshness honesty); always visible. */19 year?: number | null;20}2122/**23 * Horizontal ranked bars rendered as HTML (server component, fully responsive, no SVG text scaling).24 * One series → one colour (slot 1); `highlightId` marks the country of interest with the accent and bold label.25 * Bars ≤ 24 px thick, 4 px rounded data-end, square at the baseline; value labelled at the tip (text tokens).26 */27export function RankedBars({28 rows,29 spec,30 highlightId,31 showRank = true,32 className,33 provenance,34 ariaLabel,35 linkRows = true,36}: {37 rows: RankedBarRow[];38 spec: Spec;39 highlightId?: string | null;40 showRank?: boolean;41 className?: string;42 provenance?: Provenance | null;43 ariaLabel?: string;44 linkRows?: boolean;45}) {46 const values = rows.map((r) => r.value).filter(isNum);47 const max = values.length ? Math.max(...values.map(Math.abs)) : 0;48 const top = rows[0];49 const summary = top ? t('chart.summary.bars', { top: top.label, value: formatValue(top.value, spec), n: rows.length }) : t('chart.noData');50 if (rows.length === 0) return <p className="text-sm text-ink-3">{t('chart.noData')}</p>;51 return (52 <div className={cn('min-w-0', className)} role="img" aria-label={ariaLabel ?? summary}>53 <ol className="divide-y divide-rule">54 {rows.map((r, i) => {55 const pct = isNum(r.value) && max > 0 ? Math.max(0, (Math.abs(r.value) / max) * 100) : 0;56 const hl = highlightId && r.id === highlightId;57 // Names wrap on phones (never clipped mid-word); they truncate only in the fixed 13 rem column on sm+.58 const label = (59 <span className={cn('flex min-w-0 items-center gap-1.5 text-sm leading-snug', hl ? 'font-semibold text-ink' : 'text-ink')}>60 {r.flag ? (61 <span aria-hidden className="text-base leading-none">62 {r.flag}63 </span>64 ) : null}65 <span className="min-w-0 sm:truncate">{r.label}</span>66 </span>67 );68 // Phones: [rank + name] [value] on line 1, bar on line 2. sm+: [rank + name] [bar] [value hint] on one line.69 return (70 <li key={r.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 py-1.5 sm:grid-cols-[minmax(0,13rem)_1fr_auto] sm:gap-y-0">71 <div className="flex min-w-0 items-center gap-2">72 {showRank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{r.rank ?? i + 1}</span> : null}73 {linkRows && r.href ? (74 <Link href={r.href} className="link-quiet min-w-0 sm:truncate">75 {label}76 </Link>77 ) : (78 label79 )}80 </div>81 <div className="col-span-2 row-start-2 h-3 min-w-0 sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-4" style={{ maxHeight: MARK.barMax }}>82 <div83 className={cn('h-full rounded-r-sm', hl ? 'bg-accent' : 'bg-series-1')}84 style={{ width: `${pct}%`, minWidth: isNum(r.value) ? 2 : 0, borderRadius: `0 ${MARK.barRadius}px ${MARK.barRadius}px 0`, opacity: hl ? 1 : 0.85 }}85 />86 </div>87 <div className="col-start-2 row-start-1 flex shrink-0 items-center justify-end gap-2 sm:col-start-3">88 <span className={cn('tnum whitespace-nowrap text-right text-sm', hl ? 'font-semibold text-ink' : 'text-ink')} style={{ minWidth: '4.5rem' }}>89 {formatValue(r.value, spec)}90 {r.year != null ? <span className="tnum ml-1 rounded-xs bg-surface-2 px-1 text-2xs font-normal text-ink-2">{r.year}</span> : null}91 </span>92 {r.hint ? <span className="tnum hidden shrink-0 text-xs text-ink-3 sm:inline">{r.hint}</span> : null}93 </div>94 </li>95 );96 })}97 </ol>98 {provenance ? (99 <p className="mt-1.5 text-2xs text-ink-2">100 <span className="text-ink-3">{t('common.source')}: </span>101 {[provenance.source_name, provenance.dataset].filter(Boolean).join(' — ')} · {provenance.series_code}102 </p>103 ) : null}104 </div>105 );106}107108/** Convenience: a country row for RankedBars from a ranking row. */109export function rankedRowFromCountry(c: { id: string; slug: string | null; name: string | null; flag: string | null }, value: number | null, rank?: number | null, hint?: string | null, year?: number | null): RankedBarRow {110 return { id: c.id, label: c.name ?? c.id, flag: c.flag, href: c.slug ? routes.country(c.slug) : null, value, rank, hint, year };111}112113/** Year to display for a row (freshness honesty): only when ≥ 2 years older than the reference year. */114export function staleYear(year: number | null | undefined, reference: number | null | undefined): number | null {115 return year != null && reference != null && reference - year >= 2 ? year : null;116}117118export { ordinal };119