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%
8.3 KB · 146 lines tsx
Raw Blame History
1'use client';2import { ChevronDown } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useState } from 'react';5import { t, tOpt } from '@/i18n';6import { clientApi } from '@/lib/client-api';7import { cn } from '@/lib/cn';8import { compact, fixed, formatValue } from '@/lib/format';9import { routes } from '@/lib/site';10import type { Contributions, FormatSpec, SimilarResponse, SimilarityMode } from '@/lib/types';1112const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social'];1314export type Closeness = 'very' | 'similar' | 'moderate' | 'different';1516/** |z_a − z_b| → qualitative label. */17export function closeness(za: number | null | undefined, zb: number | null | undefined): Closeness | null {18  if (za == null || zb == null) return null;19  const d = Math.abs(za - zb);20  return d < 0.25 ? 'very' : d < 0.75 ? 'similar' : d < 1.5 ? 'moderate' : 'different';21}2223/**24 * "Countries like {name}": mode tabs (from the API), peers with a similarity bar (0–100), and a "Why similar?"25 * expander turning contributions into qualitative labels with both raw values. Peers link to the pairwise26 * comparison. Other modes are fetched on demand and cached.27 */28export function SimilarPanel({ countryId, countrySlug, countryName, initial, formats = {} }: { countryId: string; countrySlug: string | null; countryName: string; initial: SimilarResponse | null; formats?: Record<string, FormatSpec> }) {29  const [mode, setMode] = useState<SimilarityMode>('overall');30  const [data, setData] = useState<Partial<Record<SimilarityMode, SimilarResponse | null>>>({ overall: initial });31  const [loading, setLoading] = useState(false);32  const [openPeer, setOpenPeer] = useState<string | null>(null);33  const available = new Set((initial?.modes ?? MODES) as SimilarityMode[]);3435  useEffect(() => {36    if (data[mode] !== undefined) return;37    const ctrl = new AbortController();38    setLoading(true);39    clientApi40      .countrySimilar(countryId, mode, ctrl.signal)41      .then((r) => setData((d) => ({ ...d, [mode]: r })))42      .catch(() => setData((d) => ({ ...d, [mode]: null })))43      .finally(() => setLoading(false));44    return () => ctrl.abort();45  }, [mode, countryId, data]);4647  const current = data[mode];48  const peers = current?.peers ?? [];4950  return (51    <div>52      <div role="tablist" aria-label={t('country.similar.title', { name: countryName })} className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0">53        {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => (54          <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-11 shrink-0 items-center rounded-sm px-3 text-sm md:h-9', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>55            {t(`country.similar.mode.${m}` as const)}56          </button>57        ))}58      </div>59      <div className={cn('mt-3 min-h-[200px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}>60        {current === null || (current && peers.length === 0) ? (61          <p className="py-4 text-sm text-ink-3">{t('country.similar.none')}</p>62        ) : (63          <ol className="divide-y divide-rule">64            {peers.slice(0, 8).map((p) => {65              const key = `${mode}-${p.country.id}`;66              const open = openPeer === key;67              const contribs = parseContribs(p.contributions);68              return (69                <li key={p.country.id} className="py-1 sm:py-1.5">70                  <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_3.5rem_2rem] items-center gap-x-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,12rem)_3.5rem_2rem]">71                    <span className="tnum text-xs text-ink-3">{p.rank}</span>72                    <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-1.5 text-sm sm:min-h-0">73                      <span aria-hidden className="text-base leading-none">74                        {p.country.flag}75                      </span>76                      <span className="truncate">{p.country.name}</span>77                    </Link>78                    <div className="col-span-full row-start-2 mb-1 h-2 rounded-xs bg-surface-2 sm:col-span-1 sm:row-start-auto sm:mb-0" aria-hidden>79                      <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />80                    </div>81                    <span className="tnum text-right text-sm font-medium text-ink" aria-label={t('country.similar.score', { score: fixed(p.score ?? 0, 0) })}>82                      {p.score != null ? `${fixed(p.score, 0)} %` : t('common.na')}83                    </span>84                    {contribs.length ? (85                      <button type="button" onClick={() => setOpenPeer(open ? null : key)} aria-expanded={open} className="tap -mr-2 grid place-items-center text-ink-3 hover:text-ink" aria-label={t('country.similar.whySimilar')}>86                        <ChevronDown size={16} aria-hidden className={cn('transition-transform', open && 'rotate-180')} />87                      </button>88                    ) : (89                      <span />90                    )}91                  </div>92                  {open ? (93                    <div className="mt-2 rounded-sm bg-surface-2/60 px-3 py-2 text-xs text-ink-2">94                      <div className="flex flex-wrap items-baseline justify-between gap-2">95                        <div className="eyebrow">{t('country.similar.whySimilar')}</div>96                        {countrySlug && p.country.slug ? (97                          <Link href={routes.compare(countrySlug, p.country.slug)} className="text-accent hover:underline">98                            {t('country.similar.compareWith', { a: countryName, b: p.country.name ?? p.country.id })} →99                          </Link>100                        ) : null}101                      </div>102                      <ul className="mt-1 grid gap-x-6 gap-y-1 sm:grid-cols-2">103                        {contribs.map((c) => {104                          const spec = formats[c.indicator.split('/')[0] ?? ''] ?? null;105                          const label = closeness(c.z_a, c.z_b);106                          const fmt = (v: number | null) => (v == null ? t('common.na') : spec && !c.indicator.includes('/') ? formatValue(v, spec) : compact(v));107                          return (108                            <li key={c.indicator} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-2">109                              <span className="min-w-0">110                                <span className="block truncate text-ink">{spec?.name ?? c.indicator.replace(/-/g, ' ')}</span>111                                {c.value_a != null && c.value_b != null ? (112                                  <span className="tnum block text-ink-3">113                                    {fmt(c.value_a)} <span aria-hidden>vs</span> {fmt(c.value_b)}114                                  </span>115                                ) : null}116                              </span>117                              <span className={cn('shrink-0 text-right', label === 'very' || label === 'similar' ? 'text-accent' : label === 'different' ? 'text-dec' : 'text-ink-2')}>{label ? tOpt(`country.similar.close.${label}`, label) : ''}</span>118                            </li>119                          );120                        })}121                      </ul>122                    </div>123                  ) : null}124                </li>125              );126            })}127          </ol>128        )}129      </div>130    </div>131  );132}133134export function parseContribs(raw: Contributions | string | null): Array<{ indicator: string; z_a: number | null; z_b: number | null; value_a: number | null; value_b: number | null; contribution: number }> {135  if (!raw) return [];136  let obj: Contributions;137  try {138    obj = typeof raw === 'string' ? (JSON.parse(raw) as Contributions) : raw;139  } catch {140    return [];141  }142  return Object.entries(obj)143    .map(([indicator, c]) => ({ indicator, z_a: c.z_a ?? null, z_b: c.z_b ?? null, value_a: c.value_a ?? null, value_b: c.value_b ?? null, contribution: c.contribution ?? 0 }))144    .sort((a, b) => Math.abs((a.z_a ?? 0) - (a.z_b ?? 0)) - Math.abs((b.z_a ?? 0) - (b.z_b ?? 0))); // most similar first, most different last145}146