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