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 · 93 lines tsx
Raw Blame History
1'use client';2import { ArrowDownRight, ArrowUpRight } from 'lucide-react';3import Link from 'next/link';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { fixed, formatValue } from '@/lib/format';7import { routes } from '@/lib/site';8import type { FormatSpec } from '@/lib/types';9import type { ExtremeFacet } from '@/lib/types-analytics';10import { SourceLine } from '@/components/charts/source-line';1112function changeText(row: ExtremeFacet['rows'][number], metric: ExtremeFacet['metric'], spec: FormatSpec): string {13  if (metric === 'pct' && row.delta_pct != null) return `${row.delta_pct >= 0 ? '+' : '−'}${fixed(Math.abs(row.delta_pct), 1)} %`;14  if (row.delta == null) return t('common.na');15  const sign = row.delta >= 0 ? '+' : '−';16  if (metric === 'points') return `${sign}${fixed(Math.abs(row.delta), spec.precision ?? 1)} pts`;17  return `${sign}${formatValue(Math.abs(row.delta), spec)}`;18}1920/** One extremes facet: title, direction, rows start → end with the change bar. Neutral colours (increase/decrease). */21export function ExtremeFacetBlock({ facet }: { facet: ExtremeFacet }) {22  const ind = facet.indicator;23  const spec: FormatSpec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A', name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };24  const up = facet.direction === 'up';25  const Icon = up ? ArrowUpRight : ArrowDownRight;26  const mags = facet.rows.map((r) => Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0));27  const max = Math.max(1e-9, ...mags);28  const y0 = facet.rows[0]?.year_start;29  const y1 = facet.rows[0]?.year_end;30  const payload = { indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A' as const, higher_is_better: ind.higher_is_better }, value: null, country: null, downloadHref: routes.indicatorDownload(ind.slug) };31  return (32    <section id={facet.id} className="hairline min-w-0 scroll-mt-28 pb-8 pt-6" aria-labelledby={`${facet.id}-h`}>33      <div className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-2">34        <div className="min-w-0">35          <h2 id={`${facet.id}-h`} className="display text-xl text-ink md:text-2xl">36            {facet.title}37          </h2>38          <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-ink-2">39            <span className={cn('inline-flex items-center gap-0.5 font-medium', up ? 'text-inc' : 'text-dec')}>40              <Icon size={14} aria-hidden /> {up ? t('extremes.direction.up') : t('extremes.direction.down')}41            </span>42            <span className="text-ink-3">·</span>43            <Link href={routes.indicator(ind.slug)} className="link-quiet text-ink">44              {ind.name}45            </Link>46            {y0 && y1 ? (47              <span className="tnum text-ink-3">48                · {y0}–{y1} · {t('extremes.n', { n: facet.n })}49              </span>50            ) : null}51          </p>52        </div>53        <div className="flex flex-wrap gap-x-4 text-sm">54          <Link href={routes.ranking(ind.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">55            {t('extremes.openRanking')} →56          </Link>57          <Link href={routes.explore({ indicator: ind.slug, year: y1 ?? null })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">58            {t('extremes.explore')} →59          </Link>60        </div>61      </div>62      <ol className="divide-y divide-rule">63        {facet.rows.map((r, i) => {64          const mag = Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0);65          const pct = (mag / max) * 100;66          return (67            <li key={r.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[1.5rem_minmax(0,12rem)_minmax(0,1fr)_minmax(9rem,auto)]">68              <span className="tnum text-right text-xs text-ink-3">{i + 1}</span>69              <Link href={routes.country(r.country.slug ?? r.country.id.toLowerCase())} className="link-quiet col-span-2 flex min-h-[40px] min-w-0 items-center gap-1.5 text-sm text-ink sm:col-span-1 sm:min-h-[32px]">70                <span aria-hidden>{r.country.flag}</span>71                <span className="truncate">{r.country.name}</span>72              </Link>73              <div className="col-start-2 row-start-2 h-2.5 min-w-0 self-center sm:col-start-3 sm:row-start-1 sm:h-3.5" aria-hidden>74                <div className={cn('h-full rounded-r-sm', up ? 'bg-inc' : 'bg-dec')} style={{ width: `${Math.max(1.5, pct)}%`, opacity: 0.85 }} />75              </div>76              <div className="tnum col-start-3 row-start-2 text-right text-sm sm:col-start-4 sm:row-start-1">77                <span className={cn('font-semibold', up ? 'text-inc' : 'text-dec')}>{changeText(r, facet.metric, spec)}</span>78                <span className="block text-2xs text-ink-3">79                  {r.formatted_start ?? formatValue(r.value_start, spec)} <span aria-hidden>→</span> <span className="sr-only">{t('extremes.to')}</span> {r.formatted_end ?? formatValue(r.value_end, spec)}80                  {r.year_start && r.year_end && (r.year_start !== y0 || r.year_end !== y1) ? ` (${r.year_start}–${r.year_end})` : ''}81                </span>82              </div>83            </li>84          );85        })}86      </ol>87      <div className="mt-2">88        <SourceLine provenance={facet.provenance} payload={payload} />89      </div>90    </section>91  );92}93