Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Animated roundwood trade flows

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)

Run it

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:8000

Dots 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.

The data files

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.

Regenerating the data

# 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.py

Dependency 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.

What to edit

I want to… Edit
add or remove an FAO division scripts/export_local_data.pyDIVISIONS, then re-run both scripts
rename a product in the title menu scripts/prepare_data.pyLABELS
change which product loads first js/config.jsproduct.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.jsvalue.field
make the swarm denser / sparser js/config.jsparticles.dotUnit (value per dot)
change dot speed js/config.jsparticles.flight.medium/.fast (button in the controls, or ?speed=fast)
change how long a year takes js/config.jsyear.window, year.minWindow, year.pacing
change the rhythm js/config.jsyear.mode (continuous | loop | once)
change which regions the focus dropdown offers js/config.jsfocus.sets (ISO3 lists) + focus.order / focus.labels
change how much unfocused flows fade js/config.jsarcs.focusDim, focus.dimColor
change what is measured by default js/config.jsvalue.field (or ?measure=)
change arc curvature js/config.jsarcs.bow (sagitta ÷ chord; reference measures 0.31–0.33)
stop long arcs ballooning off-screen js/config.jsarcs.bowMax (degrees, saturating)
cap how wide a swarm can get js/config.jsarcs.spreadMax (degrees)
change the value-per-dot choices js/config.jsparticles.dotUnits
change dot size js/config.jsparticles.radiusPx, radiusZoomExp and the radiusMin/MaxPx clamps
widen / tighten the swarm around a route js/config.jsarcs.spread (0 = one thread)
change the scale-bar length js/config.jsmap.scaleTargetPx
change the origin→destination ramp js/config.jscolors.ramp.from / .to
drop the km/mi scale bar js/config.jsmap.scaleBar: false
colour by origin region instead of one hue js/config.jscolors.by: 'region' (or ?colors=region)
change the hue, or the region palette js/config.jscolors.fixed, colors.region
restore CARTO's own land/water colours js/config.jsmap.restyle: false
let routes take the true short way, off the map edge js/config.jsarcs.stayInFrame: false
show one fixed year, no slider js/config.jsyear.enabled: false, year.start: 2005
change how arched the arcs are js/config.jsarcs.bow (0 = pure great circle)

How the two clocks relate

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 requestAnimationFrame loop 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 continuous that is before the previous year's dots have landed, so generations overlap and the boundary has no gap; in loop/once the year waits for the last arrival.

The three modes

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.

URL parameters

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

Embedding in a slide deck

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&amp;year=1996&amp;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 &amp;ui=0; for a single fixed year, &amp;year=2005&amp;years=0. The page never scrolls and the overlay scales with the frame (clamp()/vmin sizing), so it stays readable in a small iframe.

GitHub Pages

Everything is relative, so no configuration is needed:

  1. Commit animated-trade-flows/ (including data/flows.js).
  2. Repo → Settings → PagesSource: Deploy from a branch, branch main, folder / (root).
  3. 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.

Running without wifi (lecture mode)

sh animated-trade-flows/scripts/vendor.sh

This 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.35vendor/deck.min.js
  • maplibre-gl@4.7.1vendor/maplibre-gl.js, vendor/maplibre-gl.css

Focus regions

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 europe is 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.sets if 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.

What is measured

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).

Choosing the dot unit

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.

Data caveats

  • 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 *_exp and *_imp shows 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). Prefer primary_value_* for the animation; use net_wgt_* only with that caveat stated.
  • Only the top ~600 flows per year ship, and a route worth less than half a dotUnit gets 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. Set arcs.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 edit data/centroids.csv freely.
  • Macro-regions come from Natural Earth CONTINENT, so Russia is “Europe” — which is what most of the big arcs are coloured by. Change the region column in data/centroids.csv if you want Russia split out.

Attribution

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.

About

Animated bilateral trade flows for roundwood, sawnwood and wood-based panels, 1996–2023 (UN Comtrade)

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages