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%
4.6 KB · 91 lines tsx
Raw Blame History
1'use client';2import Link from 'next/link';3import { useEffect, useState } from 'react';4import { t } from '@/i18n';5import { clientExplore } from '@/lib/client-api-explore';6import { cn } from '@/lib/cn';7import { fixed } from '@/lib/format';8import { routes } from '@/lib/site';9import type { SimilarResponse, SimilarityMode } from '@/lib/types';10import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';1112const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social'];1314/** Pick a country → peers per mode from `/countries/{id}/similar?mode=`. Mode tabs, score bars (0–100). */15export function SimilarityPlayground({ initial }: { initial?: PickedEntity | null }) {16  const [country, setCountry] = useState<PickedEntity | null>(initial ?? null);17  const [mode, setMode] = useState<SimilarityMode>('overall');18  const [data, setData] = useState<Record<string, SimilarResponse | null>>({});19  const [loading, setLoading] = useState(false);2021  useEffect(() => {22    if (!country) return;23    const key = `${country.id}:${mode}`;24    if (data[key] !== undefined) return;25    const ctrl = new AbortController();26    setLoading(true);27    clientExplore28      .similar(country.id, mode, 10, ctrl.signal)29      .then((r) => setData((d) => ({ ...d, [key]: r })))30      .catch(() => setData((d) => ({ ...d, [key]: null })))31      .finally(() => {32        if (!ctrl.signal.aborted) setLoading(false);33      });34    return () => ctrl.abort();35  }, [country, mode, data]);3637  const current = country ? data[`${country.id}:${mode}`] : undefined;38  const peers = current?.peers ?? [];3940  return (41    <div className="min-w-0">42      <div className="flex flex-wrap items-center gap-2">43        <EntityPicker type="country" placeholder={t('explore.similar.pick')} onPick={setCountry} className="w-full max-w-sm" />44        {country ? (45          <Link href={routes.country(country.slug)} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-ink md:min-h-[36px]">46            <span aria-hidden className="text-lg leading-none">{country.flag}</span>47            <span className="font-medium">{country.name}</span>48            <span className="text-ink-3">→</span>49          </Link>50        ) : null}51      </div>52      {country ? (53        <>54          <div role="tablist" aria-label={t('explore.similar.peers', { name: country.name })} className="scrollbar-none -mx-4 mt-3 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0">55            {MODES.map((m) => (56              <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')}>57                {t(`country.similar.mode.${m}` as const)}58              </button>59            ))}60          </div>61          <div className={cn('mt-3 min-h-[240px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}>62            {current === null || (current && peers.length === 0) ? (63              <p className="py-4 text-sm text-ink-3">{t('explore.similar.none', { name: country.name })}</p>64            ) : (65              <ol className="divide-y divide-rule">66                {peers.map((p) => (67                  <li key={p.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_3rem] items-center gap-x-2 py-1 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,14rem)_3rem] sm:py-1.5">68                    <span className="tnum text-xs text-ink-3">{p.rank}</span>69                    <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">70                      <span aria-hidden className="text-base leading-none">{p.country.flag}</span>71                      <span className="truncate">{p.country.name}</span>72                    </Link>73                    <div className="col-span-full mb-1 h-2 rounded-xs bg-surface-2 sm:col-span-1 sm:mb-0" aria-hidden>74                      <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />75                    </div>76                    <span className="tnum text-right text-sm font-medium text-ink" aria-label={`${t('explore.similar.score')} ${fixed(p.score ?? 0, 0)}`}>77                      {p.score != null ? fixed(p.score, 0) : t('common.na')}78                    </span>79                  </li>80                ))}81              </ol>82            )}83          </div>84        </>85      ) : (86        <p className="mt-4 text-sm text-ink-3">{t('explore.similar.empty')}</p>87      )}88    </div>89  );90}91