spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { t } from '@/i18n';2import { cn } from '@/lib/cn';3import { isNum } from '@/lib/format';4import type { DNAResponse, DnaDimension } from '@/lib/types';5import { CHART } from './palette';67export const DNA_DIMS: DnaDimension[] = ['income', 'demographics', 'urbanization', 'trade', 'energy', 'emissions', 'innovation', 'education', 'public_spending'];89/**10 * Radial "fingerprint": 9 dimensions, 0–100 percentile rank, drawn as a filled polygon on a 100-radius ring11 * with 25/50/75 guide rings and labelled spokes. Optional dashed `reference` polygon (world = 50, a region's12 * median, another country) for comparison. Server-renderable SVG in a square viewBox.13 */14export function DnaRadial({ dna, name, size = 320, className, compact = false, reference, referenceLabel }: { dna: Pick<DNAResponse, 'dims' | 'year_ref'> | null; name: string; size?: number; className?: string; compact?: boolean; reference?: Record<string, number | null> | null; referenceLabel?: string | null }) {15 const dims = DNA_DIMS.map((d) => ({ key: d, label: t(`country.dna.${d}` as const), value: dna?.dims?.[d] ?? null, ref: reference?.[d] ?? null }));16 const present = dims.filter((d) => isNum(d.value));17 if (!dna || present.length === 0) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>;1819 const R = 100;20 const pad = compact ? 16 : 100;21 const cx = R + pad;22 const cy = R + pad;23 const W = 2 * (R + pad);24 const angle = (i: number) => -Math.PI / 2 + (i / dims.length) * 2 * Math.PI;25 const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))];26 const poly = dims.map((d, i) => pt(i, isNum(d.value) ? (d.value / 100) * R : 0));27 const refPoly = reference ? dims.map((d, i) => pt(i, isNum(d.ref) ? (d.ref / 100) * R : 0)) : null;28 const summary = t('chart.summary.dna', { name, dims: present.map((d) => `${d.label} ${Math.round(d.value as number)}`).join(', ') });2930 return (31 <figure className={cn('min-w-0', className)}>32 <svg viewBox={`0 0 ${W} ${W}`} width="100%" style={{ maxWidth: size, aspectRatio: '1 / 1', margin: '0 auto', overflow: 'visible' }} role="img" aria-label={summary} className="ca-chart">33 <title>{t('country.dna.title')}</title>34 <desc>{summary}</desc>35 {[25, 50, 75, 100].map((r) => (36 <circle key={r} cx={cx} cy={cy} r={(r / 100) * R} fill="none" stroke={CHART.rule} strokeWidth={r === 100 ? 1 : 0.75} />37 ))}38 {dims.map((_, i) => {39 const [x, y] = pt(i, R);40 return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke={CHART.rule} strokeWidth={0.75} />;41 })}42 {refPoly ? <polygon points={refPoly.map((p) => p.join(',')).join(' ')} fill={CHART.ink3} fillOpacity={0.08} stroke={CHART.ink2} strokeWidth={1.5} strokeDasharray="4 3" strokeLinejoin="round" /> : null}43 <polygon points={poly.map((p) => p.join(',')).join(' ')} fill={CHART.accent} fillOpacity={0.16} stroke={CHART.accent} strokeWidth={2} strokeLinejoin="round" />44 {poly.map(([x, y], i) => (45 <circle key={i} cx={x} cy={y} r={isNum(dims[i]!.value) ? 4 : 0} fill={CHART.accent} stroke={CHART.surface} strokeWidth={2} />46 ))}47 {!compact48 ? dims.map((d, i) => {49 const [x, y] = pt(i, R + 16);50 const a = angle(i);51 const anchor = Math.abs(Math.cos(a)) < 0.2 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';52 return (53 <text key={d.key} x={x} y={y} textAnchor={anchor} dy="0.32em" className={isNum(d.value) ? 'label' : ''} style={{ fontSize: 11, fill: isNum(d.value) ? CHART.ink2 : CHART.ink3 }}>54 {d.label}55 {isNum(d.value) ? ` ${Math.round(d.value)}` : ' —'}56 {reference && isNum(d.ref) ? <tspan style={{ fill: CHART.ink3 }}>{` · ${Math.round(d.ref)}`}</tspan> : null}57 </text>58 );59 })60 : null}61 </svg>62 <figcaption className={cn('mt-2 text-2xs text-ink-2', compact ? 'grid grid-cols-3 gap-x-3 gap-y-1' : 'flex flex-wrap items-center justify-center gap-x-4 gap-y-1')}>63 {compact ? (64 dims.map((d) => (65 <span key={d.key} className="flex justify-between gap-1">66 <span className="truncate">{d.label}</span>67 <span className="tnum text-ink">{isNum(d.value) ? Math.round(d.value) : '—'}</span>68 </span>69 ))70 ) : (71 <>72 <span className="inline-flex items-center gap-1.5">73 <span aria-hidden className="inline-block h-0.5 w-4 rounded-full" style={{ background: CHART.accent }} /> {name}74 </span>75 {reference && referenceLabel ? (76 <span className="inline-flex items-center gap-1.5">77 <span aria-hidden className="inline-block h-0.5 w-4 rounded-full border-t-2 border-dashed" style={{ borderColor: CHART.ink2 }} /> {referenceLabel}78 </span>79 ) : null}80 </>81 )}82 </figcaption>83 </figure>84 );85}86