spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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