spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowDown, ArrowUp } from 'lucide-react';3import Link from 'next/link';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { displayValue, isNum, ordinal } from '@/lib/format';7import { routes } from '@/lib/site';8import type { MetricValue } from '@/lib/types';9import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare';10import { seriesVar } from '@/components/charts/palette';11import { payloadFor } from '@/components/data/metric';12import { useProvenance } from '@/components/data/provenance-context';13import { chartHrefFor } from './snapshot-table';14import type { CompareState } from '@/lib/compare-state';1516/**17 * Head-to-head view for exactly two countries: the indicator in the middle, one value per side, mirrored bars18 * proportional to the larger value, the year under each value, and a thin world-percentile track with both19 * countries marked (from rank / n). No "winner" language — a small glyph shows the declared direction only.20 */21export function HeadToHead({ rows, countries, slugs, state }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; slugs: string[]; state: CompareState }) {22 const { open } = useProvenance();23 const [a, b] = countries;24 if (!a || !b) return null;25 const visible = rows.filter((r) => r.values[a.id]?.has_data || r.values[b.id]?.has_data);26 if (!visible.length) return <p className="py-6 text-sm text-ink-3">{t('compare.snapshot.empty')}</p>;27 const openCell = (m: MetricValue, c: CountryLite, name: string) => open(payloadFor(m, { id: c.id, slug: c.slug, name: c.name, flag: c.flag }, { name }));2829 return (30 <div className="min-w-0">31 <div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3 border-b border-rule pb-3">32 {[a, b].map((c, i) => (33 <Link key={c.id} href={routes.country(c.slug)} className={cn('link-quiet flex min-w-0 items-center gap-2', i === 1 && 'flex-row-reverse text-right')}>34 <span aria-hidden className="text-3xl leading-none md:text-4xl">35 {c.flag}36 </span>37 <span className="min-w-0">38 <span className="display block truncate text-xl leading-tight text-ink md:text-2xl">{c.name}</span>39 <span className="mt-0.5 inline-flex items-center gap-1.5 text-2xs text-ink-3">40 <span aria-hidden className="inline-block h-2 w-2 rounded-full" style={{ background: seriesVar(i) }} />41 {c.id}42 </span>43 </span>44 </Link>45 ))}46 <span className="display col-start-2 row-start-1 self-center text-base text-ink-3 md:text-lg">{t('compare.vs')}</span>47 </div>48 <ol className="divide-y divide-rule">49 {visible.map((r) => {50 const ma = r.values[a.id];51 const mb = r.values[b.id];52 const va = ma?.has_data && isNum(ma.value) ? ma.value : null;53 const vb = mb?.has_data && isNum(mb.value) ? mb.value : null;54 const max = Math.max(Math.abs(va ?? 0), Math.abs(vb ?? 0)) || 1;55 const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;56 const hib = r.indicator.higher_is_better;57 const pctOf = (m: MetricValue | undefined) => (m && isNum(m.rank_world) && isNum(m.n_world) && m.n_world > 1 ? (1 - (m.rank_world - 1) / (m.n_world - 1)) * 100 : null);58 const pa = pctOf(ma);59 const pb = pctOf(mb);60 return (61 <li key={r.indicator.slug} className="py-3">62 <div className="mb-1 flex items-center justify-center gap-1.5 text-center">63 <Link href={chartHrefFor(slugs, state, r.indicator)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-sm font-semibold text-ink">64 {name}65 </Link>66 {hib != null ? (67 <span className="inline-flex items-center text-2xs text-ink-3" title={hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}>68 {hib ? <ArrowUp size={11} aria-hidden /> : <ArrowDown size={11} aria-hidden />}69 <span className="sr-only">{hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}</span>70 </span>71 ) : null}72 <span className="text-2xs text-ink-3">{r.indicator.unit}</span>73 </div>74 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-3">75 <Side m={ma} c={a} i={0} max={max} align="right" onOpen={() => ma && openCell(ma, a, r.indicator.name ?? name)} />76 <Side m={mb} c={b} i={1} max={max} align="left" onOpen={() => mb && openCell(mb, b, r.indicator.name ?? name)} />77 </div>78 {pa != null || pb != null ? (79 <div className="mx-auto mt-2 max-w-md">80 <div className="relative h-1.5 rounded-xs bg-surface-2" aria-hidden>81 {pa != null ? <span className="absolute top-1/2 h-3 w-1 -translate-x-1/2 -translate-y-1/2 rounded-xs" style={{ left: `${pa}%`, background: seriesVar(0) }} /> : null}82 {pb != null ? <span className="absolute top-1/2 h-3 w-1 -translate-x-1/2 -translate-y-1/2 rounded-xs" style={{ left: `${pb}%`, background: seriesVar(1) }} /> : null}83 </div>84 <div className="tnum mt-0.5 flex justify-between text-2xs text-ink-3">85 <span>{t('compare.h2h.pctLow')}</span>86 <span className="sr-only">87 {a.name}: {pa != null ? ordinal(Math.round(pa)) : t('common.na')} · {b.name}: {pb != null ? ordinal(Math.round(pb)) : t('common.na')}88 </span>89 <span>{t('compare.h2h.pctTrack', { n: ma?.n_world ?? mb?.n_world ?? '' })}</span>90 <span>{t('compare.h2h.pctHigh')}</span>91 </div>92 </div>93 ) : null}94 </li>95 );96 })}97 </ol>98 <p className="mt-3 text-xs text-ink-3">{t('compare.h2h.note')}</p>99 </div>100 );101}102103function Side({ m, c, i, max, align, onOpen }: { m: MetricValue | undefined; c: CountryLite; i: number; max: number; align: 'left' | 'right'; onOpen: () => void }) {104 const has = !!m && m.has_data && isNum(m.value);105 const pct = has ? Math.max(1.5, (Math.abs(m.value!) / max) * 100) : 0;106 return (107 <button type="button" disabled={!has} onClick={onOpen} className={cn('flex min-h-[44px] min-w-0 flex-col justify-center rounded-sm px-1 hover:bg-surface-2 disabled:hover:bg-transparent', align === 'right' ? 'items-end text-right' : 'items-start text-left')} aria-label={has ? `${c.name}: ${displayValue(m!.value, m!, m!.formatted)} (${m!.year ?? ''}). ${t('common.openProvenance')}` : `${c.name}: ${t('common.noData')}`}>108 <span className={cn('tnum text-lg font-semibold leading-tight md:text-xl', has ? 'text-ink' : 'text-ink-3')}>{has ? displayValue(m!.value, m!, m!.formatted) : t('common.noData')}</span>109 <span className={cn('mt-1 flex h-2 w-full', align === 'right' ? 'justify-end' : 'justify-start')} aria-hidden>110 <span className={cn('block h-full', align === 'right' ? 'rounded-l-xs' : 'rounded-r-xs')} style={{ width: `${pct}%`, background: seriesVar(i), opacity: 0.85 }} />111 </span>112 <span className="tnum mt-0.5 text-2xs text-ink-3">113 {has ? m!.year : ''}114 {has && isNum(m!.rank_world) && isNum(m!.n_world) ? ` · ${t('compare.snapshot.rank', { rank: m!.rank_world })} / ${m!.n_world}` : ''}115 </span>116 </button>117 );118}119