'use client'; import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale'; import { useCallback, useMemo, useState, type PointerEvent } from 'react'; import { t } from '@/i18n'; import { formatTick, formatValue } from '@/lib/format'; import { WB_REGIONS } from '@/lib/regions'; import type { FormatSpec as Spec } from '@/lib/types'; import { ChartFrame, type TableData } from './chart-frame'; import { CHART, seriesVar } from './palette'; import { ChartTooltip, TooltipRow } from './tooltip'; import { useMeasure } from './use-measure'; export interface BubblePoint { id: string; label: string; flag?: string | null; x: number | null; y: number | null; size?: number | null; /** Colour key (World Bank region id); fixed slot per region so colour follows the entity. */ region?: string | null; yearX?: number | null; yearY?: number | null; } export interface BubbleFit { /** y = intercept + slope · f(x) where f = log10 when `logX`. In display units. */ slope: number; intercept: number; logX: boolean; logY: boolean; label?: string; } /** Log-axis ticks: powers of ten; 2× and 5× steps are added only when fewer than three decades are visible. */ export function logTicks(domain: [number, number], n: number): number[] { const [lo, hi] = domain; if (!(lo > 0) || !(hi > lo)) return []; const inRange = (v: number) => v >= lo && v <= hi; const decades: number[] = []; for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) decades.push(Math.pow(10, e)); const shown = decades.filter(inRange); if (shown.length >= Math.min(3, n)) return shown; const extra = decades.flatMap((d) => [d, 2 * d, 5 * d]).filter(inRange); return Array.from(new Set(extra)).sort((a, b) => a - b); } /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */ export function regionColor(region: string | null | undefined): string { const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase()); return seriesVar(i < 0 ? 7 : i); } /** * Bubble / scatter chart for the analytical views (scatter, trajectories, peers). Fixed domains keep the * axes stable while a year slider animates positions (CSS transitions on translate/r). Colour = region, * size = a third indicator (sqrt scale), optional fitted line, trails for the selected countries, labels for * highlighted + the largest bubbles only, 24 px nearest-hit hover, tap to select on touch, table toggle. */ export function BubbleChart({ points, xSpec, ySpec, sizeSpec, xDomain, yDomain, sizeDomain, logX = false, logY = false, fit = null, highlight = [], trails = {}, onSelect, height = 420, labelCount = 6, animate = true, title, subtitle, className, defaultWidth = 800, legend = true, yearLabel, }: { points: BubblePoint[]; xSpec: Spec; ySpec: Spec; sizeSpec?: Spec | null; xDomain?: [number, number] | null; yDomain?: [number, number] | null; sizeDomain?: [number, number] | null; logX?: boolean; logY?: boolean; fit?: BubbleFit | null; highlight?: string[]; /** id → earlier positions (oldest first) drawn as a faint path behind the bubble. */ trails?: Record>; onSelect?: (id: string | null) => void; height?: number; labelCount?: number; animate?: boolean; title?: React.ReactNode; subtitle?: React.ReactNode; className?: string; defaultWidth?: number; legend?: boolean; /** Big faded year printed behind the plot (trajectories). */ yearLabel?: string | number | null; }) { const { ref, width } = useMeasure(defaultWidth); const [hover, setHover] = useState(null); const m = { top: 16, right: 20, bottom: 40, left: 56 }; const hl = useMemo(() => new Set(highlight), [highlight]); const model = useMemo(() => { 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; const innerW = Math.max(10, width - m.left - m.right); const innerH = Math.max(10, height - m.top - m.bottom); const xs = clean.map((p) => p.x); const ys = clean.map((p) => p.y); const xd = xDomain ?? [Math.min(...xs), Math.max(...xs)]; const yd = yDomain ?? [Math.min(...ys), Math.max(...ys)]; const pad = (d: [number, number], log: boolean): [number, number] => { if (!Number.isFinite(d[0]) || !Number.isFinite(d[1])) return [0, 1]; if (log) return [d[0] / 1.15, d[1] * 1.15]; const span = d[1] - d[0] || Math.abs(d[0]) || 1; return [d[0] - span * 0.05, d[1] + span * 0.05]; }; const x = logX ? scaleLog().domain(pad(xd, true)).range([0, innerW]) : scaleLinear().domain(pad(xd, false)).range([0, innerW]).nice(); const y = logY ? scaleLog().domain(pad(yd, true)).range([innerH, 0]) : scaleLinear().domain(pad(yd, false)).range([innerH, 0]).nice(); const sizes = clean.map((p) => p.size ?? null).filter((v): v is number => v != null && v > 0); const sd = sizeDomain ?? (sizes.length ? [0, Math.max(...sizes)] : null); const r = sd ? scaleSqrt().domain([0, sd[1]]).range([3, Math.max(14, Math.min(34, innerW / 22))]) : null; const radius = (p: BubblePoint) => (r && p.size != null && p.size > 0 ? r(p.size) : 5); const labelled = new Set(clean.filter((p) => hl.has(p.id)).map((p) => p.id)); const bySize = [...clean].sort((a, b) => (b.size ?? 0) - (a.size ?? 0)); for (const p of bySize) { if (labelled.size >= labelCount + hl.size) break; labelled.add(p.id); } const xTicks = (logX ? logTicks(x.domain() as [number, number], Math.max(3, Math.floor(innerW / 110))) : (x as ReturnType).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!); const yTicks = (logY ? logTicks(y.domain() as [number, number], 5) : (y as ReturnType).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!); let fitPath: string | null = null; if (fit) { const [x0, x1] = x.domain() as [number, number]; const steps = 40; const pts: string[] = []; for (let i = 0; i <= steps; i++) { const xv = logX ? x0 * Math.pow(x1 / x0, i / steps) : x0 + ((x1 - x0) * i) / steps; const fx = fit.logX ? Math.log10(xv) : xv; let yv = fit.intercept + fit.slope * fx; if (fit.logY) yv = Math.pow(10, yv); if (!Number.isFinite(yv) || (logY && yv <= 0)) continue; const py = y(yv); if (py < -innerH || py > innerH * 2) continue; pts.push(`${pts.length ? 'L' : 'M'}${x(xv).toFixed(1)},${py.toFixed(1)}`); } fitPath = pts.length > 1 ? pts.join(' ') : null; } // Draw small bubbles on top of large ones so nothing is hidden. const ordered = [...clean].sort((a, b) => radius(b) - radius(a)); return { clean, ordered, x, y, radius, innerW, innerH, labelled, xTicks, yTicks, fitPath, r, sd }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [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]); const onMove = useCallback( (e: PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; let best: string | null = null; let bd = 26 * 26; for (const p of model.clean) { const dx = model.x(p.x) - px; const dy = model.y(p.y) - py; const rr = model.radius(p); const d = Math.max(0, Math.sqrt(dx * dx + dy * dy) - rr); if (d * d < bd) { bd = d * d; best = p.id; } } setHover(best); }, [model], ); const hp = hover ? model.clean.find((p) => p.id === hover) ?? null : null; const summary = t('chart.summary.scatter', { x: xSpec.name ?? 'x', y: ySpec.name ?? 'y', n: model.clean.length }); const table: TableData = useMemo( () => ({ 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 }] : [])], 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) : '' })), }), [model.clean, xSpec, ySpec, sizeSpec], ); const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]); const trans = animate ? '[transition:transform_380ms_cubic-bezier(0.2,0.8,0.2,1),r_380ms_ease]' : ''; const px = (v: number) => `${Math.round(v * 100) / 100}px`; return (
{model.clean.length === 0 ? (
{t('chart.noData')}
) : ( {typeof title === 'string' ? title : `${ySpec.name ?? ''} vs ${xSpec.name ?? ''}`} {summary} {yearLabel != null ? ( {yearLabel} ) : null} {model.yTicks.map((tk) => ( ))} {model.xTicks.map((tk) => ( ))} {model.xTicks.map((tk) => ( {formatTick(tk, xSpec)} ))} {model.yTicks.map((tk) => ( {formatTick(tk, ySpec)} ))} {xSpec.name} {logX ? ` (${t('common.log')})` : ''} → ↑ {ySpec.name} {logY ? ` (${t('common.log')})` : ''} {model.fitPath ? : null} {Object.entries(trails).map(([id, pts]) => { if (pts.length < 2) return null; const d = pts .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!logX || p.x > 0) && (!logY || p.y > 0)) .map((p, i) => `${i ? 'L' : 'M'}${model.x(p.x).toFixed(1)},${model.y(p.y).toFixed(1)}`) .join(' '); const pt = model.clean.find((p) => p.id === id); return ; })} {model.ordered.map((p) => { const isH = hl.has(p.id); const isHover = hover === p.id; const rr = model.radius(p); return ( ); })} {model.ordered .filter((p) => model.labelled.has(p.id) || hover === p.id) .map((p) => { const rr = model.radius(p); return ( {p.label} ); })} setHover(null)} onClick={() => onSelect?.(hover)} /> )} {hp ? (
{hp.flag ? {hp.flag} : null} {hp.label}
{sizeSpec && hp.size != null ? : null}
) : null}
{legend && regionsPresent.length > 1 ? (
    {regionsPresent.map((rg) => (
  • {rg.short}
  • ))} {sizeSpec && model.sd ? (
  • {t('chart.bubble.size', { name: sizeSpec.name ?? '' })}
  • ) : null}
) : null}
); }