spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale';3import { useCallback, useMemo, useState, type PointerEvent } from 'react';4import { t } from '@/i18n';5import { formatTick, formatValue } from '@/lib/format';6import { WB_REGIONS } from '@/lib/regions';7import type { FormatSpec as Spec } from '@/lib/types';8import { ChartFrame, type TableData } from './chart-frame';9import { CHART, seriesVar } from './palette';10import { ChartTooltip, TooltipRow } from './tooltip';11import { useMeasure } from './use-measure';1213export interface BubblePoint {14 id: string;15 label: string;16 flag?: string | null;17 x: number | null;18 y: number | null;19 size?: number | null;20 /** Colour key (World Bank region id); fixed slot per region so colour follows the entity. */21 region?: string | null;22 yearX?: number | null;23 yearY?: number | null;24}2526export interface BubbleFit {27 /** y = intercept + slope · f(x) where f = log10 when `logX`. In display units. */28 slope: number;29 intercept: number;30 logX: boolean;31 logY: boolean;32 label?: string;33}3435/** Log-axis ticks: powers of ten; 2× and 5× steps are added only when fewer than three decades are visible. */36export function logTicks(domain: [number, number], n: number): number[] {37 const [lo, hi] = domain;38 if (!(lo > 0) || !(hi > lo)) return [];39 const inRange = (v: number) => v >= lo && v <= hi;40 const decades: number[] = [];41 for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) decades.push(Math.pow(10, e));42 const shown = decades.filter(inRange);43 if (shown.length >= Math.min(3, n)) return shown;44 const extra = decades.flatMap((d) => [d, 2 * d, 5 * d]).filter(inRange);45 return Array.from(new Set(extra)).sort((a, b) => a - b);46}4748/** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */49export function regionColor(region: string | null | undefined): string {50 const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase());51 return seriesVar(i < 0 ? 7 : i);52}5354/**55 * Bubble / scatter chart for the analytical views (scatter, trajectories, peers). Fixed domains keep the56 * axes stable while a year slider animates positions (CSS transitions on translate/r). Colour = region,57 * size = a third indicator (sqrt scale), optional fitted line, trails for the selected countries, labels for58 * highlighted + the largest bubbles only, 24 px nearest-hit hover, tap to select on touch, table toggle.59 */60export function BubbleChart({61 points,62 xSpec,63 ySpec,64 sizeSpec,65 xDomain,66 yDomain,67 sizeDomain,68 logX = false,69 logY = false,70 fit = null,71 highlight = [],72 trails = {},73 onSelect,74 height = 420,75 labelCount = 6,76 animate = true,77 title,78 subtitle,79 className,80 defaultWidth = 800,81 legend = true,82 yearLabel,83}: {84 points: BubblePoint[];85 xSpec: Spec;86 ySpec: Spec;87 sizeSpec?: Spec | null;88 xDomain?: [number, number] | null;89 yDomain?: [number, number] | null;90 sizeDomain?: [number, number] | null;91 logX?: boolean;92 logY?: boolean;93 fit?: BubbleFit | null;94 highlight?: string[];95 /** id → earlier positions (oldest first) drawn as a faint path behind the bubble. */96 trails?: Record<string, Array<{ x: number; y: number }>>;97 onSelect?: (id: string | null) => void;98 height?: number;99 labelCount?: number;100 animate?: boolean;101 title?: React.ReactNode;102 subtitle?: React.ReactNode;103 className?: string;104 defaultWidth?: number;105 legend?: boolean;106 /** Big faded year printed behind the plot (trajectories). */107 yearLabel?: string | number | null;108}) {109 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);110 const [hover, setHover] = useState<string | null>(null);111 const m = { top: 16, right: 20, bottom: 40, left: 56 };112 const hl = useMemo(() => new Set(highlight), [highlight]);113114 const model = useMemo(() => {115 const clean = points.filter((p) => p.x != null && p.y != null && Number.isFinite(p.x) && Number.isFinite(p.y) && (!logX || p.x! > 0) && (!logY || p.y! > 0)) as Array<BubblePoint & { x: number; y: number }>;116 const innerW = Math.max(10, width - m.left - m.right);117 const innerH = Math.max(10, height - m.top - m.bottom);118 const xs = clean.map((p) => p.x);119 const ys = clean.map((p) => p.y);120 const xd = xDomain ?? [Math.min(...xs), Math.max(...xs)];121 const yd = yDomain ?? [Math.min(...ys), Math.max(...ys)];122 const pad = (d: [number, number], log: boolean): [number, number] => {123 if (!Number.isFinite(d[0]) || !Number.isFinite(d[1])) return [0, 1];124 if (log) return [d[0] / 1.15, d[1] * 1.15];125 const span = d[1] - d[0] || Math.abs(d[0]) || 1;126 return [d[0] - span * 0.05, d[1] + span * 0.05];127 };128 const x = logX ? scaleLog().domain(pad(xd, true)).range([0, innerW]) : scaleLinear().domain(pad(xd, false)).range([0, innerW]).nice();129 const y = logY ? scaleLog().domain(pad(yd, true)).range([innerH, 0]) : scaleLinear().domain(pad(yd, false)).range([innerH, 0]).nice();130 const sizes = clean.map((p) => p.size ?? null).filter((v): v is number => v != null && v > 0);131 const sd = sizeDomain ?? (sizes.length ? [0, Math.max(...sizes)] : null);132 const r = sd ? scaleSqrt().domain([0, sd[1]]).range([3, Math.max(14, Math.min(34, innerW / 22))]) : null;133 const radius = (p: BubblePoint) => (r && p.size != null && p.size > 0 ? r(p.size) : 5);134 const labelled = new Set<string>(clean.filter((p) => hl.has(p.id)).map((p) => p.id));135 const bySize = [...clean].sort((a, b) => (b.size ?? 0) - (a.size ?? 0));136 for (const p of bySize) {137 if (labelled.size >= labelCount + hl.size) break;138 labelled.add(p.id);139 }140 const xTicks = (logX ? logTicks(x.domain() as [number, number], Math.max(3, Math.floor(innerW / 110))) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!);141 const yTicks = (logY ? logTicks(y.domain() as [number, number], 5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!);142 let fitPath: string | null = null;143 if (fit) {144 const [x0, x1] = x.domain() as [number, number];145 const steps = 40;146 const pts: string[] = [];147 for (let i = 0; i <= steps; i++) {148 const xv = logX ? x0 * Math.pow(x1 / x0, i / steps) : x0 + ((x1 - x0) * i) / steps;149 const fx = fit.logX ? Math.log10(xv) : xv;150 let yv = fit.intercept + fit.slope * fx;151 if (fit.logY) yv = Math.pow(10, yv);152 if (!Number.isFinite(yv) || (logY && yv <= 0)) continue;153 const py = y(yv);154 if (py < -innerH || py > innerH * 2) continue;155 pts.push(`${pts.length ? 'L' : 'M'}${x(xv).toFixed(1)},${py.toFixed(1)}`);156 }157 fitPath = pts.length > 1 ? pts.join(' ') : null;158 }159 // Draw small bubbles on top of large ones so nothing is hidden.160 const ordered = [...clean].sort((a, b) => radius(b) - radius(a));161 return { clean, ordered, x, y, radius, innerW, innerH, labelled, xTicks, yTicks, fitPath, r, sd };162 // eslint-disable-next-line react-hooks/exhaustive-deps163 }, [points, width, height, logX, logY, xDomain?.[0], xDomain?.[1], yDomain?.[0], yDomain?.[1], sizeDomain?.[0], sizeDomain?.[1], hl, labelCount, fit?.slope, fit?.intercept, fit?.logX, fit?.logY]);164165 const onMove = useCallback(166 (e: PointerEvent<SVGRectElement>) => {167 const rect = e.currentTarget.getBoundingClientRect();168 const px = e.clientX - rect.left;169 const py = e.clientY - rect.top;170 let best: string | null = null;171 let bd = 26 * 26;172 for (const p of model.clean) {173 const dx = model.x(p.x) - px;174 const dy = model.y(p.y) - py;175 const rr = model.radius(p);176 const d = Math.max(0, Math.sqrt(dx * dx + dy * dy) - rr);177 if (d * d < bd) {178 bd = d * d;179 best = p.id;180 }181 }182 setHover(best);183 },184 [model],185 );186187 const hp = hover ? model.clean.find((p) => p.id === hover) ?? null : null;188 const summary = t('chart.summary.scatter', { x: xSpec.name ?? 'x', y: ySpec.name ?? 'y', n: model.clean.length });189 const table: TableData = useMemo(190 () => ({191 columns: [{ key: 'label', label: t('common.country') }, { key: 'x', label: xSpec.name ?? 'x', numeric: true }, { key: 'y', label: ySpec.name ?? 'y', numeric: true }, ...(sizeSpec ? [{ key: 's', label: sizeSpec.name ?? 'size', numeric: true }] : [])],192 rows: model.clean.map((p) => ({ label: p.label, x: formatValue(p.x, xSpec), y: formatValue(p.y, ySpec), s: sizeSpec ? formatValue(p.size ?? null, sizeSpec) : '' })),193 }),194 [model.clean, xSpec, ySpec, sizeSpec],195 );196 const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]);197 const trans = animate ? '[transition:transform_380ms_cubic-bezier(0.2,0.8,0.2,1),r_380ms_ease]' : '';198 const px = (v: number) => `${Math.round(v * 100) / 100}px`;199200 return (201 <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}>202 <div ref={ref} className="relative w-full select-none" style={{ height }}>203 {model.clean.length === 0 ? (204 <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>205 ) : (206 <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>207 <title>{typeof title === 'string' ? title : `${ySpec.name ?? ''} vs ${xSpec.name ?? ''}`}</title>208 <desc>{summary}</desc>209 <g transform={`translate(${m.left},${m.top})`}>210 {yearLabel != null ? (211 <text x={model.innerW - 8} y={model.innerH - 12} textAnchor="end" className="display" style={{ fontSize: Math.min(120, model.innerH / 2.6), fill: 'var(--rule)', fontWeight: 600 }} aria-hidden>212 {yearLabel}213 </text>214 ) : null}215 <g className="grid">216 {model.yTicks.map((tk) => (217 <line key={`y${tk}`} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />218 ))}219 {model.xTicks.map((tk) => (220 <line key={`x${tk}`} x1={model.x(tk)} x2={model.x(tk)} y1={0} y2={model.innerH} strokeDasharray="2 4" />221 ))}222 </g>223 <g className="axis">224 <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />225 {model.xTicks.map((tk) => (226 <text key={tk} x={model.x(tk)} y={model.innerH + 16} textAnchor="middle">227 {formatTick(tk, xSpec)}228 </text>229 ))}230 {model.yTicks.map((tk) => (231 <text key={tk} x={-8} y={model.y(tk)} dy="0.32em" textAnchor="end">232 {formatTick(tk, ySpec)}233 </text>234 ))}235 <text x={model.innerW} y={model.innerH + 32} textAnchor="end" className="label label-strong">236 {xSpec.name}237 {logX ? ` (${t('common.log')})` : ''} →238 </text>239 <text x={6} y={-4} textAnchor="start" className="label label-strong">240 ↑ {ySpec.name}241 {logY ? ` (${t('common.log')})` : ''}242 </text>243 </g>244 {model.fitPath ? <path d={model.fitPath} fill="none" stroke={CHART.ink3} strokeWidth={1.5} strokeDasharray="6 4" opacity={0.8} /> : null}245 {Object.entries(trails).map(([id, pts]) => {246 if (pts.length < 2) return null;247 const d = pts248 .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!logX || p.x > 0) && (!logY || p.y > 0))249 .map((p, i) => `${i ? 'L' : 'M'}${model.x(p.x).toFixed(1)},${model.y(p.y).toFixed(1)}`)250 .join(' ');251 const pt = model.clean.find((p) => p.id === id);252 return <path key={`trail-${id}`} d={d} fill="none" stroke={regionColor(pt?.region)} strokeWidth={1.5} opacity={0.55} strokeLinejoin="round" />;253 })}254 {model.ordered.map((p) => {255 const isH = hl.has(p.id);256 const isHover = hover === p.id;257 const rr = model.radius(p);258 return (259 <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}>260 <circle r={Math.round(rr * 100) / 100} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} className={trans} />261 </g>262 );263 })}264 {model.ordered265 .filter((p) => model.labelled.has(p.id) || hover === p.id)266 .map((p) => {267 const rr = model.radius(p);268 return (269 <text key={`l-${p.id}`} x={Math.round((model.x(p.x) + rr + 4) * 100) / 100} y={Math.round(model.y(p.y) * 100) / 100} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''} ${trans}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>270 {p.label}271 </text>272 );273 })}274 <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} onClick={() => onSelect?.(hover)} />275 </g>276 </svg>277 )}278 {hp ? (279 <ChartTooltip x={m.left + model.x(hp.x)} y={m.top + model.y(hp.y) - model.radius(hp)} width={width}>280 <div className="mb-0.5 font-medium text-ink">281 {hp.flag ? <span aria-hidden>{hp.flag} </span> : null}282 {hp.label}283 </div>284 <TooltipRow label={xSpec.name ?? 'x'} value={`${formatValue(hp.x, xSpec)}${hp.yearX ? ` · ${hp.yearX}` : ''}`} />285 <TooltipRow label={ySpec.name ?? 'y'} value={`${formatValue(hp.y, ySpec)}${hp.yearY ? ` · ${hp.yearY}` : ''}`} />286 {sizeSpec && hp.size != null ? <TooltipRow label={sizeSpec.name ?? 'size'} value={formatValue(hp.size, sizeSpec)} muted /> : null}287 </ChartTooltip>288 ) : null}289 </div>290 {legend && regionsPresent.length > 1 ? (291 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2" aria-label={t('common.legend')}>292 {regionsPresent.map((rg) => (293 <li key={rg.id} className="inline-flex items-center gap-1.5">294 <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: regionColor(rg.id) }} />295 {rg.short}296 </li>297 ))}298 {sizeSpec && model.sd ? (299 <li className="ml-auto inline-flex items-center gap-1.5 text-ink-3">300 <span aria-hidden className="inline-block h-3.5 w-3.5 rounded-full border border-rule-strong" />301 {t('chart.bubble.size', { name: sizeSpec.name ?? '' })}302 </li>303 ) : null}304 </ul>305 ) : null}306 </ChartFrame>307 );308}309