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%
15.0 KB · 309 lines tsx
Raw Blame History
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