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.2 KB · 106 lines tsx
Raw Blame History
1'use client';2import Link from 'next/link';3import { useEffect, useMemo, useState } from 'react';4import { t } from '@/i18n';5import { clientApi } from '@/lib/client-api';6import { routes } from '@/lib/site';7import type { DNAResponse, DnaDimension } from '@/lib/types';8import { DNA_DIMS, DnaRadial } from '@/components/charts/dna-radial';9import { Segmented } from '@/components/controls/indicator-select';10import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';1112type RefKind = 'world' | 'region' | 'income' | 'country';1314/**15 * Country DNA 2.0: the fingerprint with a reference profile — World (50 on every axis), the region's median,16 * income peers' median or another country — fetched from `/countries/{id}/dna?reference=`. The table below lists17 * the nine percentile dimensions with the reference values. Descriptive, never a score.18 */19export function DnaPanel({ countryId, name, initial }: { countryId: string; name: string; initial: DNAResponse | null }) {20  const [kind, setKind] = useState<RefKind>('world');21  const [peer, setPeer] = useState<PickedEntity | null>(null);22  const [cache, setCache] = useState<Record<string, DNAResponse | null>>({});23  const [loading, setLoading] = useState(false);24  const refKey = kind === 'country' ? (peer ? `country:${peer.id}` : null) : kind === 'world' ? null : kind;2526  useEffect(() => {27    if (!refKey || cache[refKey] !== undefined) return;28    const ctrl = new AbortController();29    setLoading(true);30    const reference = refKey.startsWith('country:') ? refKey.slice(8) : refKey;31    clientApi32      .countryDna(countryId, reference, ctrl.signal)33      .then((r) => setCache((c) => ({ ...c, [refKey]: r })))34      .catch(() => setCache((c) => ({ ...c, [refKey]: null })))35      .finally(() => {36        if (!ctrl.signal.aborted) setLoading(false);37      });38    return () => ctrl.abort();39  }, [refKey, countryId, cache]);4041  const base = initial;42  const withRef = refKey ? cache[refKey] : null;43  const reference: Record<string, number | null> | null = useMemo(() => {44    if (kind === 'world') return Object.fromEntries(DNA_DIMS.map((d) => [d, 50]));45    return withRef?.reference?.dims ?? null;46  }, [kind, withRef]);47  const refLabel = kind === 'world' ? t('country.dna.ref.worldLabel') : withRef?.reference?.label ?? (kind === 'country' ? peer?.name ?? null : null);4849  if (!base) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>;50  return (51    <div className="min-w-0">52      <div className="flex flex-wrap items-center gap-2">53        <Segmented<RefKind> value={kind} onChange={setKind} label={t('country.dna.ref')} size="sm" options={[{ value: 'world', label: t('country.dna.ref.world') }, { value: 'region', label: t('country.dna.ref.region') }, { value: 'income', label: t('country.dna.ref.income') }, { value: 'country', label: t('country.dna.ref.country') }]} />54        {kind === 'country' ? <EntityPicker type="country" placeholder={t('country.dna.ref.pick')} onPick={setPeer} exclude={[countryId]} keepValue className="w-56" size="sm" /> : null}55      </div>56      <div className={loading ? 'mt-3 opacity-60 transition-opacity' : 'mt-3'} aria-busy={loading}>57        <DnaRadial dna={base} name={name} size={360} reference={kind === 'country' && !peer ? null : reference} referenceLabel={refLabel} />58      </div>59      <p className="mt-2 text-center text-xs text-ink-3">60        {t('country.dna.notScore')}61        {base.year_ref ? <span className="tnum"> · {base.year_ref}</span> : null}62      </p>63      <table className="mt-3 w-full border-collapse text-xs">64        <caption className="sr-only">{t('country.dna.title')}</caption>65        <thead>66          <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">67            <th scope="col" className="py-1 pr-2 font-medium">68              {t('country.dna.dimension')}69            </th>70            <th scope="col" className="py-1 pr-2 text-right font-medium">71              {name}72            </th>73            {reference && (kind !== 'country' || peer) ? (74              <th scope="col" className="py-1 text-right font-medium">75                {refLabel}76              </th>77            ) : null}78          </tr>79        </thead>80        <tbody className="divide-y divide-rule">81          {DNA_DIMS.map((d: DnaDimension) => {82            const v = base.dims[d];83            const r = reference?.[d];84            const row = base.dimensions.find((x) => x.id === d);85            return (86              <tr key={d}>87                <td className="py-1 pr-2 text-ink-2">88                  {row?.indicator ? (89                    <Link href={routes.indicator(row.indicator)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0">90                      {t(`country.dna.${d}` as const)}91                    </Link>92                  ) : (93                    t(`country.dna.${d}` as const)94                  )}95                </td>96                <td className="tnum py-1 pr-2 text-right font-medium text-ink">{v != null ? Math.round(v) : '—'}</td>97                {reference && (kind !== 'country' || peer) ? <td className="tnum py-1 text-right text-ink-2">{r != null ? Math.round(r) : '—'}</td> : null}98              </tr>99            );100          })}101        </tbody>102      </table>103    </div>104  );105}106