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 { displayValue, formatPeriod, ordinal } from '@/lib/format';7import type { MetricValue } from '@/lib/types';8import { pointsFromSpark } from '@/components/charts/scales';9import { Sparkline } from '@/components/charts/sparkline';10import { ChangeChip } from './change-chip';11import { RankBadge } from './rank-badge';12import { useProvenance, type ProvenancePayload } from './provenance-context';1314export interface MetricCountry {15 id: string;16 slug: string | null;17 name: string;18 flag?: string | null;19}2021/** Build the provenance-sheet payload for a MetricValue (also used by topic rows and chart source lines). */22export function payloadFor(m: MetricValue, country?: MetricCountry | null, extra?: { name?: string | null; methodology?: string | null; description?: string | null }): ProvenancePayload {23 return {24 indicator: { slug: m.indicator, name: extra?.name ?? m.indicator_name ?? m.indicator, format: m.format, unit: m.unit, unit_short: m.unit_short, frequency: m.frequency, higher_is_better: m.higher_is_better, methodology: extra?.methodology ?? null, description: extra?.description ?? null },25 value: m.has_data ? { value: m.value, formatted: m.formatted, period: m.period, year: m.year, unit: m.unit, is_estimate: m.is_estimate, is_forecast: m.is_forecast, status: m.status, provenance: m.provenance } : null,26 country: country ?? null,27 };28}2930/**31 * Percentile of the latest value within the country's own history (share of earlier points strictly below it).32 * Null with fewer than 5 earlier points. 100 = highest ever, 0 = lowest ever.33 */34export function ownHistoryPercentile(m: MetricValue): number | null {35 const pts = m.sparkline.filter((p) => typeof p[1] === 'number') as Array<[number, number]>;36 if (pts.length < 6 || m.value == null) return null;37 const earlier = pts.slice(0, -1).map((p) => p[1]);38 const below = earlier.filter((v) => v < m.value!).length;39 return Math.round((below / earlier.length) * 100);40}4142/**43 * Headline metric module: label (link) + sparkline, big value, period, change chip, ranks (world · region),44 * percentile of own history. Click on the value → provenance sheet. 1 px separators, no card. Heights are45 * reserved so the grid does not shift while data loads.46 */47export function Metric({ metric, country, regionName, href, className, size = 'md', showOwnPercentile = true }: { metric: MetricValue; country?: MetricCountry | null; regionName?: string | null; href?: string | null; className?: string; size?: 'sm' | 'md' | 'lg'; showOwnPercentile?: boolean }) {48 const { open } = useProvenance();49 const m = metric;50 const hasValue = m.has_data && m.value != null;51 const dir = m.change?.abs == null ? null : m.change.abs > 0 ? 'up' : m.change.abs < 0 ? 'down' : 'flat';52 const points = pointsFromSpark(m.sparkline);53 const pct = showOwnPercentile && hasValue ? ownHistoryPercentile(m) : null;54 const firstYear = points[0]?.year;55 return (56 <div className={cn('flex min-w-0 flex-col gap-1 border-t border-rule py-3', className)} id={m.indicator}>57 <div className="flex items-start justify-between gap-2">58 {href ? (59 <Link href={href} className="group/l -my-1 flex min-h-[44px] min-w-0 items-center gap-0.5 py-1 text-xs font-medium leading-snug text-ink-2 hover:text-accent md:min-h-[32px]" aria-label={t('metric.open', { name: m.indicator_name ?? m.indicator })}>60 <span className="line-clamp-2">{m.indicator_name ?? m.indicator}</span>61 <ChevronRight size={13} aria-hidden className="shrink-0 text-ink-3 group-hover/l:text-accent" />62 </Link>63 ) : (64 <div className="line-clamp-2 min-w-0 py-1 text-xs font-medium leading-snug text-ink-2">{m.indicator_name ?? m.indicator}</div>65 )}66 {points.length >= 2 ? <Sparkline points={points} width={72} height={22} direction={dir} className="mt-0.5 shrink-0 opacity-90" ariaLabel={firstYear ? t('metric.sparkAria', { name: m.indicator_name ?? m.indicator, y0: firstYear, y1: m.year ?? '' }) : undefined} /> : <span className="inline-block h-[22px] w-[72px] shrink-0" aria-hidden />}67 </div>68 <button type="button" onClick={() => open(payloadFor(m, country))} className="group -mx-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2 focus-visible:bg-surface-2" aria-label={t('common.openProvenance')}>69 <span className={cn('pnum font-semibold leading-none text-ink', size === 'lg' ? 'text-3xl md:text-4xl' : size === 'sm' ? 'text-xl' : 'text-2xl md:text-[1.75rem]')}>70 {hasValue ? displayValue(m.value, m, m.formatted) : <span className="text-ink-3">{t('common.noData')}</span>}71 </span>72 <span className="mt-1 flex min-h-[1.1rem] flex-wrap items-baseline gap-x-2 text-xs text-ink-3">73 {hasValue ? <span className="tnum">{formatPeriod(m.period, m.frequency)}</span> : null}74 {m.is_estimate ? <span>{t('common.estimate')}</span> : null}75 <ChangeChip change={m.change} spec={m} prevPeriod={m.prev?.period} />76 </span>77 </button>78 <div className="min-h-[1.1rem] min-w-0 truncate">79 <RankBadge rank={m} regionName={regionName} />80 </div>81 {pct != null && firstYear ? (82 <div className="tnum min-h-[1.1rem] flex items-center gap-1.5 text-2xs text-ink-3" title={t('metric.ownPercentileHint', { y0: firstYear })}>83 <span className="inline-flex h-1 w-12 overflow-hidden rounded-xs bg-surface-2" aria-hidden>84 <span className="h-full bg-ink-3" style={{ width: `${pct}%` }} />85 </span>86 {t('metric.ownPercentile', { p: ordinal(pct), y0: firstYear })}87 </div>88 ) : null}89 </div>90 );91}9293/** Responsive editorial grid for Metrics: swipeable strip on phones (2 visible), 3–4 columns on desktop. */94export function MetricGrid({ children, className, cols = 4 }: { children: React.ReactNode; className?: string; cols?: 3 | 4 }) {95 return <div className={cn('grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-3', cols === 4 && 'xl:grid-cols-4', className)}>{children}</div>;96}97