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