'use client'; import { ChevronDown } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { t, tOpt } from '@/i18n'; import { clientApi } from '@/lib/client-api'; import { cn } from '@/lib/cn'; import { compact, fixed, formatValue } from '@/lib/format'; import { routes } from '@/lib/site'; import type { Contributions, FormatSpec, SimilarResponse, SimilarityMode } from '@/lib/types'; const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social']; export type Closeness = 'very' | 'similar' | 'moderate' | 'different'; /** |z_a − z_b| → qualitative label. */ export function closeness(za: number | null | undefined, zb: number | null | undefined): Closeness | null { if (za == null || zb == null) return null; const d = Math.abs(za - zb); return d < 0.25 ? 'very' : d < 0.75 ? 'similar' : d < 1.5 ? 'moderate' : 'different'; } /** * "Countries like {name}": mode tabs (from the API), peers with a similarity bar (0–100), and a "Why similar?" * expander turning contributions into qualitative labels with both raw values. Peers link to the pairwise * comparison. Other modes are fetched on demand and cached. */ export function SimilarPanel({ countryId, countrySlug, countryName, initial, formats = {} }: { countryId: string; countrySlug: string | null; countryName: string; initial: SimilarResponse | null; formats?: Record }) { const [mode, setMode] = useState('overall'); const [data, setData] = useState>>({ overall: initial }); const [loading, setLoading] = useState(false); const [openPeer, setOpenPeer] = useState(null); const available = new Set((initial?.modes ?? MODES) as SimilarityMode[]); useEffect(() => { if (data[mode] !== undefined) return; const ctrl = new AbortController(); setLoading(true); clientApi .countrySimilar(countryId, mode, ctrl.signal) .then((r) => setData((d) => ({ ...d, [mode]: r }))) .catch(() => setData((d) => ({ ...d, [mode]: null }))) .finally(() => setLoading(false)); return () => ctrl.abort(); }, [mode, countryId, data]); const current = data[mode]; const peers = current?.peers ?? []; return (
{MODES.filter((m) => available.has(m) || m === 'overall').map((m) => ( ))}
{current === null || (current && peers.length === 0) ? (

{t('country.similar.none')}

) : (
    {peers.slice(0, 8).map((p) => { const key = `${mode}-${p.country.id}`; const open = openPeer === key; const contribs = parseContribs(p.contributions); return (
  1. {p.rank} {p.country.flag} {p.country.name}
    {p.score != null ? `${fixed(p.score, 0)} %` : t('common.na')} {contribs.length ? ( ) : ( )}
    {open ? (
    {t('country.similar.whySimilar')}
    {countrySlug && p.country.slug ? ( {t('country.similar.compareWith', { a: countryName, b: p.country.name ?? p.country.id })} → ) : null}
      {contribs.map((c) => { const spec = formats[c.indicator.split('/')[0] ?? ''] ?? null; const label = closeness(c.z_a, c.z_b); const fmt = (v: number | null) => (v == null ? t('common.na') : spec && !c.indicator.includes('/') ? formatValue(v, spec) : compact(v)); return (
    • {spec?.name ?? c.indicator.replace(/-/g, ' ')} {c.value_a != null && c.value_b != null ? ( {fmt(c.value_a)} vs {fmt(c.value_b)} ) : null} {label ? tOpt(`country.similar.close.${label}`, label) : ''}
    • ); })}
    ) : null}
  2. ); })}
)}
); } export 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 }> { if (!raw) return []; let obj: Contributions; try { obj = typeof raw === 'string' ? (JSON.parse(raw) as Contributions) : raw; } catch { return []; } return Object.entries(obj) .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 })) .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 last }