Dots travelling along curved origin→destination routes, EarthTime-style, on a dark MapLibre
basemap. deck.gl ScatterplotLayer for the dots — positions and colours recomputed each
frame — over a whisper-faint PathLayer so routes too small to earn a dot don't disappear.
Colour says how far along a dot is: pale cream leaving the origin, deep brick red
arriving at the destination, through the stops in colors.ramp.stops (a 256-entry lookup
table, not a two-colour blend). The stops were fitted to the reference, whose isolated dots
sit around rgb(141,111,97) with the most saturated near rgb(128,77,62) — white appears only
where hubs pile up under additive blending.
Arc curvature has two parts. arcs.bowGain exaggerates the great circle's own deviation
from the straight chord, which gives each arc an asymmetric profile for free. arcs.bow
adds a bulge on top, because a 5° hop barely deviates from its chord and the long-way routes
(USA→Japan, drawn the long way round by stayInFrame, chord ≈ 236°) have no geodesic to
exaggerate at all.
arcs.latLimit (72°) is the ceiling that actually keeps arcs on the map: the bulge cap
limits how far an arc bows, not where it ends up, so a route already sitting at 60°N —
Canada, Russia, the Nordics, and many more of them in sawnwood and panels than in
roundwood — plus a 26° bulge would land at 86°N, outside the frame. When a path breaks the
limit its whole deviation is scaled down until the worst point sits on it, which flattens
the arc without changing its shape. The southern side is capped the same way (Chile).
arcs.bowMax (26°) caps the combined bulge, saturating smoothly rather than clipping —
capping each part separately lets them stack to twice the limit and pushes arcs off-screen.
It is set high enough that curvature really does grow with distance: USA→Japan peaks at
63.8°N, crossing Finland, while Germany→Austria bulges 2.4°. The viewport reaches 77°N
at the default camera, so the tallest arc (USA→Germany, 70.9°N) still fits.
Two-way pairs are drawn as a lens. Where both directions of a pair carry dots, the
lower-indexed direction keeps the natural arc and the other is its exact reflection about the
chord (mirrorPath), so the two can never cross and neither hides the other — USA↔Canada
peaks at +10.0° and −10.0°, minimum separation 2.3°. One-way flows keep a single arc on their
natural side; at $1M/dot, 205 pairs are two-way and the rest one-way. The decision is made
once over every year, never per year: a pair flipping sides mid-animation would be a worse
artefact than the overlap it fixes.
Dots on the same route don't share one thread — each takes its own slightly offset path, so
a corridor reads as a swarm that is narrow at both ends and widest mid-route
(arcs.spread).
Dot size is in pixels, identical for every dot in a frame, and grows with the zoom:
3 px across at the default camera, ~5 px at zoom 2.5, 7 px at zoom 4 (particles.radiusPx,
radiusZoomExp, clamped by radiusMinPx/radiusMaxPx). Sizing dots in metres looks
right at one latitude and wrong everywhere else — Mercator inflates ground distance by
1/cos(lat), so a Finnish dot came out twice the size of an equatorial one in the same frame.
Three products, chosen from the title. The title itself is the menu: roundwood (FAO
division 01), sawnwood (05) and wood-based panels (07) all ship in one payload, so switching
is instant and needs no further network request. Switching rebuilds everything derived from
the data — sorted tables, pacing, reciprocity, arc geometry — and restarts the year, because
it is a different dataset rather than a different view of one. ?product=05 picks it from a
URL.
One dot = a fixed amount of trade (default $1 M, importer-reported). A route worth $40 M carries 40 dots; magnitude is carried by dot count, not line width — which is why the map thickens as trade grows without any line getting fatter.
Each dot makes one journey per year. It departs somewhere inside the year's departure window, flies its route once, and is gone. Small flows therefore fall quiet early while the big corridors keep firing — and the year is over when the last dot lands.
One static page. No build step, no bundler, no API key, relative paths only — it runs by
double-clicking index.html and served from GitHub Pages, and it embeds as an <iframe>
in a reveal.js deck.
animated-trade-flows/
├── index.html page + styling + the CDN/vendor loader
├── js/
│ ├── config.js ⭐ every knob you might want to turn
│ ├── trips.js curved-path densification + particle timing
│ └── app.js map, layers, the two clocks, controls
├── data/
│ ├── centroids.csv ⭐ iso3 → lat, lon, macro-region (edit to fix coordinates)
│ ├── prepared-trade-data.parquet.gzip local copy of the trade data (1.0 MB)
│ └── flows.js generated payload (committed; 1.9 MB, all three products)
├── scripts/
│ ├── export_local_data.py ⭐ pipeline CSV → the local parquet (needs polars)
│ ├── prepare_data.py ⭐ local parquet → data/flows.js
│ └── vendor.sh download deck.gl + MapLibre into vendor/ for offline use
└── vendor/ created by vendor.sh; empty by default (CDN is used)
open animated-trade-flows/index.html # straight from disk, works
# or, closer to how GitHub Pages serves it:
cd animated-trade-flows && python3 -m http.server 8000 # → http://localhost:8000Dots animate as soon as the page loads. The year clock starts paused; press space or the ▶ button, drag the slider, or use ←/→. The buttons, left to right:
| Button | What it does |
|---|---|
| Slow / Medium / Fast | flight speed; switching mid-flight re-times everything in place, nothing jumps |
| Continuous / Loop / Once | the rhythm, paused and playing — see below |
| value-per-dot slider | steps a ladder ($100 k … $2.5 M, or 1 kt … 25 kt for weight); the legend follows it. The rebuild happens on release, not while dragging |
| focus dropdown | one of nine UN M49 regions (World by default) — the flows touching that region stay on the hot ramp, the rest turn cool slate. Emphasis, not a filter |
| measure dropdown | value or net weight, importer- or exporter-reported |
| ↻ | replay the year (only in Once) |
Under the scale bars sits a zoom readout: 100% is the view the page opens at, one zoom
level in reads 200%. Clicking it returns to the opening view — zoom and centre, so it also
undoes panning — and it greys out only when the camera is actually home. Hidden by ?ui=0
like the rest of the chrome.
The map runs deck.gl in controlled mode (the page owns viewState and feeds interaction
back through onViewStateChange). The uncontrolled form ignores a later initialViewState,
so a reset button had nothing to reset. The camera is handed to deck only when it changes,
not every frame. A distance scale sits under the title: like EarthTime it shows the same
round number on both bars (so the miles bar is the longer one), rather than MapLibre's
fixed-width convention.
data/prepared-trade-data.parquet.gzip (1.0 MB, committed) is the visualisation's own copy
of the trade data: all three FAO divisions, the four value columns, self-trade and
centroid-less countries already dropped, and only the flows that actually ship (the top 400
per division-year, ranked separately by each value column and unioned). It exists so the
whole animated-trade-flows/ folder can be copied into another repository and still rebuild
itself — the pipeline's results/ folder is gitignored and not part of a clone.
data/flows.js (1.9 MB, committed) is the payload the page loads: meta.products,
a nodes table shared across all three products, and flows keyed by division then year.
# only when the pipeline output changes: results/ CSV -> the local parquet
python3 animated-trade-flows/scripts/export_local_data.py
# any time: local parquet -> data/flows.js
python3 animated-trade-flows/scripts/prepare_data.pyDependency note: prepare_data.py used to be standard-library only. Reading parquet
needs a library, so both scripts now require polars (already a project dependency, see
workflow/envs/polars.yaml). Viewing the page still requires nothing at all — data/flows.js
is committed — so only regenerating the payload has this requirement.
export_local_data.py reads the pipeline's
results/network_analysis/country_lvl/intermediary/mirror_flows.csv (⭐ SOURCE_CSV), keeps
the divisions listed in ⭐ DIVISIONS, drops self-trade and countries without a centroid, and
cuts to ⭐ KEEP_PER_YEAR flows per division-year. prepare_data.py then joins
data/centroids.csv and writes window.TRADE_FLOWS; ⭐ LABELS there controls what the
title menu calls each division.
It is written to a .js file rather than .json on purpose: fetch() of a local file is
blocked under file://, a <script> tag is not. That is what makes double-click-to-open
work.
All four value columns (net_wgt_exp, primary_value_exp, net_wgt_imp,
primary_value_imp) ship in the payload, so switching value column needs no re-run.
Ranking for the per-year cut is done once per column and unioned, so the page's topN is
exact whichever column you pick.
| I want to… | Edit |
|---|---|
| add or remove an FAO division | scripts/export_local_data.py → DIVISIONS, then re-run both scripts |
| rename a product in the title menu | scripts/prepare_data.py → LABELS |
| change which product loads first | js/config.js → product.current (or ?product=05) |
| fix a country's position, or its macro-region | data/centroids.csv (then re-run the script) |
| change the value column | js/config.js → value.field |
| make the swarm denser / sparser | js/config.js → particles.dotUnit (value per dot) |
| change dot speed | js/config.js → particles.flight.medium/.fast (button in the controls, or ?speed=fast) |
| change how long a year takes | js/config.js → year.window, year.minWindow, year.pacing |
| change the rhythm | js/config.js → year.mode (continuous | loop | once) |
| change which regions the focus dropdown offers | js/config.js → focus.sets (ISO3 lists) + focus.order / focus.labels |
| change how much unfocused flows fade | js/config.js → arcs.focusDim, focus.dimColor |
| change what is measured by default | js/config.js → value.field (or ?measure=) |
| change arc curvature | js/config.js → arcs.bow (sagitta ÷ chord; reference measures 0.31–0.33) |
| stop long arcs ballooning off-screen | js/config.js → arcs.bowMax (degrees, saturating) |
| cap how wide a swarm can get | js/config.js → arcs.spreadMax (degrees) |
| change the value-per-dot choices | js/config.js → particles.dotUnits |
| change dot size | js/config.js → particles.radiusPx, radiusZoomExp and the radiusMin/MaxPx clamps |
| widen / tighten the swarm around a route | js/config.js → arcs.spread (0 = one thread) |
| change the scale-bar length | js/config.js → map.scaleTargetPx |
| change the origin→destination ramp | js/config.js → colors.ramp.from / .to |
| drop the km/mi scale bar | js/config.js → map.scaleBar: false |
| colour by origin region instead of one hue | js/config.js → colors.by: 'region' (or ?colors=region) |
| change the hue, or the region palette | js/config.js → colors.fixed, colors.region |
| restore CARTO's own land/water colours | js/config.js → map.restyle: false |
| let routes take the true short way, off the map edge | js/config.js → arcs.stayInFrame: false |
| show one fixed year, no slider | js/config.js → year.enabled: false, year.start: 2005 |
| change how arched the arcs are | js/config.js → arcs.bow (0 = pure great circle) |
The dot schedule is never driven by the year clock — it is the other way round. The year advances when the current year has finished launching its dots; pausing stops the year but leaves the dots in the air to finish their journeys.
- Dots — a
requestAnimationFrameloop recomputes every dot's position and ramp colour from the time elapsed since its year started departing. A dot is invisible before it leaves and after it lands. - Year — advances on the cadence its mode sets (above). In
continuousthat is before the previous year's dots have landed, so generations overlap and the boundary has no gap; inloop/oncethe year waits for the last arrival.
| mode | paused on one year | playing through the years |
|---|---|---|
continuous (default) |
a new cycle starts as the last dot departs — the map never falls quiet | the next year starts departing as this one's last dot leaves, so several years' dots overlap in the air and the flows never break |
loop |
replays once the last dot has landed | the year advances only after every dot has arrived |
once |
runs once, goes quiet, ↻ replays it | the year advances only after every dot has arrived |
Pacing follows the data (year.pacing: 'data'): the busiest year gets the full
year.window of departures, thinner years proportionally less, floored at
year.minWindow. Set pacing: 'fixed' if you need every year to take the same wall-clock
time for slide timing.
Each route departs on a steady beat — one dot every window ÷ its dot count — and that beat
carries across the year boundary (year.carryDepartures): the interval changes when the
route's trade changes, but the phase is not reset, so a corridor never gaps or doubles up as
the year flips. Measured on USA→CHN and USA→JPN across two boundaries, the hand-over gap is
0.75–1.13 × the interval. With particles.uniformSpeed every dot crosses the map at the same speed — flight time is
proportional to route length, measured against the fixed arcs.refLengthDeg. That reference
is fixed on purpose: normalising against the current year's longest route made a corridor's
speed change whenever some other long route entered or left the set, so old and new dots
crossed the same path at different speeds and tore the seam at every year boundary.
Everything below overrides js/config.js, so one deployed page can serve every slide.
| Parameter | Example | Effect |
|---|---|---|
autoplay |
?autoplay=1 |
start the year clock immediately |
year |
?year=1996 |
start on (or pin to) this year |
years |
?years=0 |
disable the year clock entirely |
ui |
?ui=0 |
hide all controls, legend and titles |
value |
?value=net_wgt_imp |
value column |
scale |
?scale=log |
sqrt | linear | log (underlay opacity only) |
topn |
?topn=250 |
routes per year |
dotunit |
?dotunit=2e6 |
value per dot — lower = denser swarm |
speed |
?speed=slow |
flight speed preset: slow | medium | fast |
mode |
?mode=loop |
continuous | loop | once |
focus |
?focus=eastasia |
UN M49 region: world | africa_sub | latam | northam | eastasia | seasia | southasia | europe | oceania (unknown = world) |
spreadmax |
?spreadmax=2 |
cap on swarm width, degrees |
bowmax |
?bowmax=10 |
cap on arc curvature, degrees |
latlimit |
?latlimit=65 |
highest latitude an arc may reach, north or south |
bowgain |
?bowgain=3 |
how much the geodesic's arch is exaggerated |
dotradius |
?dotradius=2 |
dot radius in pixels at the default zoom |
zoom |
?zoom=2.5 |
starting zoom |
product |
?product=05 |
FAO division: 01 roundwood | 05 sawnwood | 07 panels |
measure |
?measure=net_wgt_exp |
primary_value_imp | primary_value_exp | net_wgt_imp | net_wgt_exp |
carry |
?carry=0 |
stop carrying each route's departure beat across years |
flight |
?flight=4000 |
ms for the longest route to fly — overrides the preset |
pacing |
?pacing=fixed |
every year takes the same time instead of following the data |
window |
?window=3000 |
ms of departures for the busiest year |
fadeends |
?fadeends=0 |
share of each journey spent fading in / out |
colors |
?colors=region |
colour by origin macro-region instead of one hue |
additive |
?additive=0 |
turn off additive blending |
inframe |
?inframe=0 |
allow routes to take the short way off the map edge |
restyle |
?restyle=0 |
keep CARTO's own land/water colours |
bow |
?bow=0 |
corridor curvature |
spread |
?spread=0 |
swarm width around a corridor |
basemap |
?basemap=plain |
no tiles, flat dark background (offline-safe) |
bg |
?bg=transparent |
transparent page background |
interactive |
?interactive=0 |
lock pan/zoom |
reveal.js loads data-src when the slide comes into view, so the animation starts exactly
when the slide appears:
<section data-background-color="#080c12">
<iframe class="stretch"
data-src="animated-trade-flows/index.html?autoplay=1&year=1996&speed=fast"
width="1280" height="720"
frameborder="0" scrolling="no" allowfullscreen
style="border:0;width:100%;height:100%;background:#080c12"></iframe>
</section>Use src= instead of data-src= if you want it running before the slide is reached.
For a bare visual with no chrome, add &ui=0; for a single fixed year,
&year=2005&years=0. The page never scrolls and the overlay scales with the frame
(clamp()/vmin sizing), so it stays readable in a small iframe.
Everything is relative, so no configuration is needed:
- Commit
animated-trade-flows/(includingdata/flows.js). - Repo → Settings → Pages → Source: Deploy from a branch, branch
main, folder/ (root). - Wait for the build, then open
https://<user>.github.io/<repo>/animated-trade-flows/.
Note the repo's .gitignore excludes results/, so the source CSV is not published — only
the prepared data/flows.js is.
sh animated-trade-flows/scripts/vendor.shThis downloads the pinned deck.gl and MapLibre files into vendor/. index.html tries
vendor/ first and silently falls back to the CDN, so there is nothing to switch on.
The basemap tiles still come from CARTO over the network. For a fully offline page also
set map.basemap: 'plain' in js/config.js (or add ?basemap=plain): arcs and particles
on a flat dark background, zero network requests.
Pinned versions (edit CDN in index.html and vendor.sh together if you bump them):
deck.gl@9.0.35→vendor/deck.min.jsmaplibre-gl@4.7.1→vendor/maplibre-gl.js,vendor/maplibre-gl.css
The focus dropdown offers nine selections from the UNSD M49 geographic classification — the same grouping the UN's own trade statistics use:
| Selection | M49 code | Countries in this data |
|---|---|---|
| World | — | everything at full strength |
| Sub-Saharan Africa | 202 | 52 |
| Latin America and the Caribbean | 419 | 45 |
| Northern America | 021 | 5 |
| Eastern Asia | 030 | 7 |
| South-Eastern Asia | 035 | 11 |
| Southern Asia | 034 | 9 |
| Europe | 150 | 45 |
| Oceania | 009 | 30 |
Two things to know before reading a map:
- Russia is inside Europe. M49 files it under Eastern Europe, so there is no separate
Russia selection and
europeis no longer EU-27 + EFTA + UK but all of geographic Europe. Selecting Europe therefore lights up roughly 60 % of roundwood routes. - The nine regions do not cover the world. M49's Western Asia, Northern Africa and Central
Asia are not offered, so Türkiye, Egypt, Saudi Arabia, Israel, Morocco, Algeria and
Kazakhstan — about 3.6 % of the trade value in this data — belong to no selection and
can never be highlighted. They are still drawn, and still dimmed by every non-World
selection. Add a group to
focus.setsif you need one; nothing else has to change.
A few smaller M49 rulings that surprise people: Mexico is Latin America, not Northern America; Greenland, Bermuda and Saint Pierre and Miquelon are Northern America; Hong Kong and Macao are Eastern Asia; Cyprus and Türkiye are Western Asia, so neither is in Europe; Sudan is Northern Africa, so it is not in Sub-Saharan Africa.
focus.sets in js/config.js holds plain ISO3 lists — edit them freely. They are
deliberately not derived from the region column of centroids.csv, which carries Natural
Earth CONTINENT, a different and coarser classification. Three retired Comtrade reporters
(ANT Netherlands Antilles, SCG Serbia and Montenegro, PUS US Misc. Pacific Isds) have no
current M49 entry and are filed by successor territory so their historical flows stay
highlightable.
The old ?focus=china, ?focus=russia and ?focus=usa parameters no longer exist; an
unrecognised name now falls back to World.
The dropdown switches between all four columns that ship in data/flows.js: value or net
weight, importer- or exporter-reported. Switching rebuilds the sorted tables, the pacing, the
dot ladder, the legend and the subtitle — and deliberately restarts the year, because it is a
change of subject rather than a tweak.
The two mirror sides are never reconciled or averaged. Importer- and exporter-reported
are two different reports of the same trade; the subtitle and the legend always name which
one is on screen, and switching between them is the honest way to see how far apart they are.
Selecting a net-weight column also puts the known reliability caveat in the subtitle: for FAO
division 01 the 2000–2006 gap is nulls, not zeros (see the repo's CLAUDE.md).
| value per dot | dots in 2021 | live across overlapping years | frame rate (Metal) |
|---|---|---|---|
$1M (default) |
~19 000 | 41 447 | ~119 fps |
$500k |
~39 000 | 82 932 | ~89 fps |
$100k |
~190 000 | 357 611 | ~34 fps |
$100k runs but is visibly less smooth: every frame re-uploads position and colour buffers
for every generation still in the air. Fine for a paused year or a still; for a lecture
playthrough $500k is the comfortable floor. The weight ladder (1 kt … 25 kt) behaves the
same way.
- Mirror flows are never reconciled. Each bilateral flow is reported twice, by exporter
and by importer, and the two reports differ. This page shows one side at a time
(
value.field); it does not average them. Switching between*_expand*_impshows how large the gap is. - Net weight has known reliability gaps for division 01 in 2000–2006 (nulls, not zeros —
see the repo's
CLAUDE.md). Preferprimary_value_*for the animation; usenet_wgt_*only with that caveat stated. - Only the top ~600 flows per year ship, and a route worth less than half a
dotUnitgets no dots at all — it only shows as the faint underlay. Small flows are quiet by design; this is a story figure, not an inventory. - Routes never leave the map (
arcs.stayInFrame). A pair whose shortest path crosses the antimeridian — the United States or Chile to China across the Pacific — is drawn the long way round, eastwards over the Atlantic and Eurasia. It is the wrong ocean: the choice keeps every flow on screen rather than running off one edge and back in at the other. Setarcs.stayInFrame: false(or?inframe=0) for true great circles. - Centroids are label points, not trade ports. Generated from Natural Earth 50m admin-0
(
LABEL_X/LABEL_Y,CONTINENT), with ten historic or micro-territory codes (ANT,BES,CCK,CXR,GIB,MYT,PUS,SCG,TKL,UMI) filled in by hand. Spot-check anything you plan to point at on a slide, and editdata/centroids.csvfreely. - Macro-regions come from Natural Earth
CONTINENT, so Russia is “Europe” — which is what most of the big arcs are coloured by. Change theregioncolumn indata/centroids.csvif you want Russia split out.
Basemap © CARTO, map data © OpenStreetMap contributors. Country label points and continents from Natural Earth (public domain). Trade data: UN Comtrade, processed by this repository's Snakemake pipeline.