| 1 | import React, { useState, useEffect, useRef, useMemo, useCallback } from "react" |
| 2 | import { BubbleChart, CategoryColorProvider } from "semiotic" |
| 3 | |
| 4 | // ── Data (8 countries x 8 decades) ────────────────────────────────── |
| 5 | const RAW = [ |
| 6 | { country: "United States", continent: "Americas", year: 1950, gdp: 15000, life: 68, pop: 152 }, |
| 7 | { country: "China", continent: "Asia", year: 1950, gdp: 450, life: 41, pop: 552 }, |
| 8 | // ... see full source for all 64 rows |
| 9 | ] |
| 10 | |
| 11 | const CONTINENT_COLORS = { |
| 12 | "Americas": "#5DA5DA", "Asia": "#F15854", |
| 13 | "Africa": "#B276B2", "Europe": "#FAA43A", |
| 14 | } |
| 15 | |
| 16 | // Each step: year, highlights, narration text, and callout annotations |
| 17 | const STEPS = [ |
| 18 | { |
| 19 | year: 1950, highlight: [], |
| 20 | title: "1950: The Great Divide", |
| 21 | narration: "Rich countries upper-right, everyone else lower-left...", |
| 22 | callouts: [ |
| 23 | { country: "Sweden", label: "Rich, long-lived", dx: -10, dy: -30 }, |
| 24 | { country: "Nigeria", label: "Poor, short-lived", dx: 10, dy: 20 }, |
| 25 | ], |
| 26 | }, |
| 27 | // ... 7 steps total (see full source) |
| 28 | ] |
| 29 | |
| 30 | function buildTrail(country, upToYear) { |
| 31 | return RAW.filter(d => d.country === country && d.year <= upToYear) |
| 32 | } |
| 33 | |
| 34 | export default function RoslingBubbleChart({ width = 800 }) { |
| 35 | const [stepIdx, setStepIdx] = useState(0) |
| 36 | const [playing, setPlaying] = useState(false) |
| 37 | const step = STEPS[stepIdx] |
| 38 | const currentData = RAW.filter(d => d.year === step.year) |
| 39 | |
| 40 | // Three layers of annotations on a single chart: |
| 41 | const annotations = useMemo(() => { |
| 42 | const result = [] |
| 43 | |
| 44 | // 1. Country name labels (widget annotations at data coordinates) |
| 45 | for (const d of currentData) { |
| 46 | if (step.highlight.length === 0 || step.highlight.includes(d.country)) { |
| 47 | result.push({ |
| 48 | type: "widget", gdp: d.gdp, life: d.life, dx: 15, dy: -6, |
| 49 | content: <span style={{ fontSize: 11, fontWeight: 600, |
| 50 | color: CONTINENT_COLORS[d.continent] }}>{d.country}</span> |
| 51 | }) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // 2. Callout cards (editorial notes anchored to bubbles) |
| 56 | for (const c of (step.callouts || [])) { |
| 57 | const d = currentData.find(r => r.country === c.country) |
| 58 | if (!d) continue |
| 59 | result.push({ |
| 60 | type: "widget", gdp: d.gdp, life: d.life, dx: c.dx, dy: c.dy, |
| 61 | content: ( |
| 62 | <div style={{ |
| 63 | background: "var(--surface-2)", border: "1px solid var(--surface-3)", |
| 64 | borderRadius: 4, padding: "4px 8px", fontSize: 10, whiteSpace: "pre-line", |
| 65 | color: "var(--text-primary)", boxShadow: "0 1px 4px rgba(0,0,0,0.15)", |
| 66 | }}>{c.label}</div> |
| 67 | ), |
| 68 | }) |
| 69 | } |
| 70 | |
| 71 | // 3. Trail year markers (small dots with year labels along the path) |
| 72 | for (const country of step.highlight) { |
| 73 | const trail = buildTrail(country, step.year) |
| 74 | for (const d of trail) { |
| 75 | if (d.year === step.year) continue |
| 76 | result.push({ |
| 77 | type: "widget", gdp: d.gdp, life: d.life, dx: 0, dy: -10, |
| 78 | content: <span style={{ fontSize: 8, color: "var(--text-secondary)" }}>{d.year}</span> |
| 79 | }) |
| 80 | } |
| 81 | } |
| 82 | return result |
| 83 | }, [currentData, step]) |
| 84 | |
| 85 | // Trail lines drawn via canvasPreRenderers (dashed path through GDP/life space) |
| 86 | const canvasPreRenderers = useMemo(() => { |
| 87 | if (step.highlight.length === 0) return undefined |
| 88 | return [(ctx, nodes, scales) => { |
| 89 | if (!scales?.xScale || !scales?.yScale) return |
| 90 | for (const country of step.highlight) { |
| 91 | const trail = buildTrail(country, step.year) |
| 92 | if (trail.length < 2) continue |
| 93 | const color = CONTINENT_COLORS[trail[0].continent] |
| 94 | ctx.beginPath() |
| 95 | ctx.moveTo(scales.x(trail[0].gdp), scales.y(trail[0].life)) |
| 96 | for (let i = 1; i < trail.length; i++) |
| 97 | ctx.lineTo(scales.x(trail[i].gdp), scales.y(trail[i].life)) |
| 98 | ctx.strokeStyle = color; ctx.lineWidth = 2 |
| 99 | ctx.globalAlpha = 0.4; ctx.setLineDash([4, 3]); ctx.stroke() |
| 100 | ctx.setLineDash([]) |
| 101 | // Small dots at each historical position |
| 102 | for (let i = 0; i < trail.length - 1; i++) { |
| 103 | ctx.beginPath() |
| 104 | ctx.arc(scales.x(trail[i].gdp), scales.y(trail[i].life), 3, 0, Math.PI * 2) |
| 105 | ctx.fillStyle = color; ctx.globalAlpha = 0.5; ctx.fill() |
| 106 | } |
| 107 | } |
| 108 | }] |
| 109 | }, [step]) |
| 110 | |
| 111 | // Dim non-highlighted bubbles |
| 112 | const pointStyle = (d) => ({ |
| 113 | fill: CONTINENT_COLORS[d.continent], |
| 114 | fillOpacity: step.highlight.length === 0 || step.highlight.includes(d.country) |
| 115 | ? 0.75 : 0.12, |
| 116 | stroke: (step.highlight.length === 0 || step.highlight.includes(d.country)) |
| 117 | ? "var(--surface-0)" : "transparent", |
| 118 | }) |
| 119 | |
| 120 | return ( |
| 121 | <div> |
| 122 | {/* Step controls + narration card + BubbleChart with all annotations */} |
| 123 | <CategoryColorProvider colors={CONTINENT_COLORS}> |
| 124 | <BubbleChart |
| 125 | data={currentData} xAccessor="gdp" yAccessor="life" sizeBy="pop" |
| 126 | sizeRange={[6, 50]} colorBy="continent" showLegend |
| 127 | xLabel="GDP per Capita (PPP)" yLabel="Life Expectancy" |
| 128 | width={width} height={500} |
| 129 | annotations={annotations} |
| 130 | frameProps={{ pointStyle, xScaleType: "log", |
| 131 | xExtent: [300, 70000], yExtent: [30, 90], |
| 132 | ...(canvasPreRenderers && { canvasPreRenderers }) }} |
| 133 | /> |
| 134 | </CategoryColorProvider> |
| 135 | </div> |
| 136 | ) |
| 137 | } |