Teaching artifact
How this was built
Africa 2036 Intelligence is a static site. No framework, no build step, no runtime dependencies, no server. Four Node scripts turn live data into JSON and HTML; the browser gets hand-authored CSS and four ES modules. The whole thing is about 70 KB of code and 66 KB of map.
Concept and creative direction
The brief could have produced a dashboard. Almost every foresight product does: cards, charts, a political map with popups, a filter sidebar. The decision that shaped everything else was to treat this as an instrument rather than a report — something you look through at a subject, whose readings change as you adjust it.
Three rules fell out of that, and they govern every visual decision:
- Uncertainty is structural, not a footnote. A disclaimer at the bottom of a confident-looking chart is not honesty. So confidence is drawn into the map itself.
- Colour carries meaning or it does not appear. The three scenario hues are the only saturated colour in the system, so a hue always answers one question: which future is this?
- Evidence and interpretation never look alike. Measurements are set in the interface face. The platform's own judgements are set in serif and marked. You should be able to tell them apart from across the room.
The signature technique: drawing uncertainty
Every country on the atlas carries a stipple overlay whose density tracks the confidence in its evidence base. A country with a complete, current, stable record renders clean. A country with thin or dated data renders visibly grainy — literally less resolved.
It is four SVG patterns and one line of assignment, and it does more work than any amount of disclaimer copy. It also makes the continent's real research geography visible at a glance: you can see, without reading anything, where the world has bothered to measure carefully and where it has not.
const GRAIN = { considered: 0, moderate: 0.28, limited: 0.55, insufficient: 0.8 };
// one pattern per level — a fine dot grid in the ground colour
const p = el('pattern', { id: `grain-${level}`, width: 4, height: 4,
patternUnits: 'userSpaceOnUse' });
p.appendChild(el('circle', { cx: 1, cy: 1, r: 0.55, fill: '#0A0D11', opacity: density }));
// then, per country, one assignment
rec.grain.style.fill = GRAIN[level] ? `url(#grain-${level})` : 'none';
Countries with no value for the active lens are not shaded pale — pale reads as "low". They are drawn as a dashed void, which reads as "absent", because that is what it is.
Equal-area cartography, by hand
Web maps almost always ship Web Mercator. Mercator inflates high-latitude landmasses and shrinks equatorial ones, which is the single most common way Africa is quietly misrepresented at a glance. A platform arguing about Africa's real weight in the world should not open by understating it.
So the geometry is projected at build time to Lambert Azimuthal Equal-Area, centred on 20°E 5°N. That is a closed-form projection — no mapping library needed, about ten lines:
function project([lon, lat]) {
const l = lon * RAD, p = lat * RAD;
const cosP = Math.cos(p), sinP = Math.sin(p), cosDl = Math.cos(l - LON0);
let k = 1 + Math.sin(LAT0) * sinP + Math.cos(LAT0) * cosP * cosDl;
k = Math.sqrt(2 / Math.max(k, 1e-9));
return [ k * cosP * Math.sin(l - LON0),
-k * (Math.cos(LAT0) * sinP - Math.sin(LAT0) * cosP * cosDl) ];
}
The source is Natural Earth 1:50m via the world-atlas TopoJSON build, used at build time
only and never shipped. Because TopoJSON stores shared arcs, the merged landmass outline and
the interior border mesh are stitched from the same lines — so no sliver gaps appear between
neighbours, which is the usual artefact when you simplify country polygons independently.
After projection, rings are simplified with Douglas–Peucker at a 0.45-unit tolerance (about 3 km at this scale) and quantised to one decimal place. The result: all 54 countries, the coastline, the internal border mesh and Western Sahara in 66 KB of JSON.
Six countries — Cabo Verde, Comoros, The Gambia, Mauritius, São Tomé and Príncipe, Seychelles — are too small to hit reliably at continental zoom. The build flags them, and the renderer gives each a ring marker and a 9-unit circular hit target. Every one of the 54 is clickable and keyboard-reachable.
The data pipeline
Four stages, each a plain Node script with no dependencies beyond topojson-client at
build time.
- fetch-indicators — pulls every series from the World Bank Indicators API, stores it verbatim with the API's own provenance fields, and is resumable: a series already on disk is not re-fetched.
- build-geometry — projects, simplifies and quantises the boundaries.
- compose — runs the foresight engine over all 54 countries and emits one atlas index plus one detail file per country.
- gen-static — renders 54 country briefs, a source register and the method page as plain HTML.
Resumability earned its keep. The API takes about 30 seconds per series regardless of how narrowly you scope the query — server-side query time dominates — so a full run is over half an hour. When the container restarted mid-run, the cache meant the rebuild cost minutes rather than restarting from zero.
Building against a live API also surfaced something a static dataset would have hidden: nine indicator
codes had been retired or renamed. The Worldwide Governance Indicators had moved behind a
GOV_WGI_ prefix, and three UNHCR displacement series had been restructured entirely.
Every one failed loudly rather than returning empty — which matters, because a silently empty
displacement series would have rendered as a continent with no displaced people. That is the worst
class of error this platform could make, and the pipeline is written to make it impossible to ship
quietly.
The versioned export
Everything this platform publishes is also published as JSON, at
/exports/v1/: the 54 countries, the indicator
register with each issuing body’s own definition, every observed value with its year, the cited
sources, the reviewed research claims with their delivery status and tier, the evidence state of
every dimension for every country, and the four vocabularies.
Nothing in it is new. Every figure, claim and source there is already on a public page. The export restates them with stable identifiers and explicit relationships, so that a program does not have to read a rendered page to get at them. Scraping is bad for both sides: it breaks whenever a layout changes, and the dangerous part is that it usually breaks by inventing data rather than by stopping.
Start with the manifest. /exports/v1/manifest.json names every file and, for each
one, its exact byte length, its SHA-256 and its record count. Fetch it first, then fetch what it
names and check what you received against what it declares. A consumer that does that can tell the
difference between a platform that has published something new and a file that arrived damaged —
which is the whole reason the hashes are there.
The manifest also carries the schema version, the edition, the build identifier, the generation and verification timestamps, the canonical origin, and the four vocabularies as values with a hash each. Values rather than counts, deliberately: a renamed vocabulary term keeps the count identical and would silently change what every past assessment meant.
Two identifiers, and neither covers everything. build is a hash of the
content-addressed JavaScript and CSS — the browser assets, and only those. It does not cover HTML,
the data payloads or the export, so two editions that differ only in data share it, and 6.2.2 and
6.2.3 do. exportSetHash covers exactly the seven data files, by path and content digest,
and can be recomputed from the served files alone. Neither is a hash of the complete served build,
and neither is described as one here or in the manifest.
The guarantees. v1 is immutable in shape: fields may be added, none removed or
retyped, and a breaking change would publish /exports/v2/ alongside it rather than
changing v1 underneath anyone. Generation is deterministic — identical source data
produces byte-identical files, manifest included, on the pinned Node version — so a hash that moves
means the data moved and nothing else. Validation regenerates the whole export from source on every
build and fails on a missing file, a duplicate identifier, a dangling reference, drift in any
vocabulary value, or output that does not reproduce.
It is read-only, and it is all of it. The export is a published artefact with no write path: there is no endpoint here that accepts anything. It contains the public evidence base and nothing else — no private research, no unpublished work, and nothing from any other system. Orvantis Intelligence runs a separate, private opportunity-intelligence product that reads this export; none of that product’s data appears here, and the reading is one-way.
The foresight engine
The hardest engineering problem here was not rendering. It was building something that produces a credible ten-year outlook for 54 countries without inventing a single number.
The move that resolves it: calibrate every scenario to the country's own realised history. Acceleration is not an outsider's fantasy of what an African economy might do — it is mean-plus-0.85-standard-deviations of what this country has actually recorded, clamped to its own 5th–95th percentile. That is reproducible by hand, comparable across all 54, and structurally impossible to inflate.
The interpretation layer works the same way. A "reading" is editorial judgement, but it may only fire when specific observed data supports it, and it must carry the values it fired on into the interface:
{
id: 'farm-employment',
when: (c) => c.v('agriemp') && c.v('agriemp').value >= 45,
text: (c) => `${pc(c.v('agriemp').value)} of employment was in agriculture in ` +
`${c.v('agriemp').year}… climate is not an environmental topic here — ` +
`it is the household income of the majority.`,
basis: ['agriemp', 'agrigdp'],
}
Every rule fails closed. No data, no reading. A country with thin evidence gets fewer readings, never invented ones — which is why the number of readings varies so much across the continent, and why that variation is itself informative.
The full method, including what the engine deliberately refuses to model, is on the method page.
Static briefs came first
The interactive atlas is an enhancement. Every country's full outlook is also a static HTML page — no scripting, a few kilobytes, prints cleanly, works on any device and any connection.
For a platform about a continent where a large share of users are on constrained connections and modest hardware, treating the lightweight version as the canonical record rather than a grudging fallback is a design position, not a technical accommodation. The rail on the atlas is also real markup — an actual list of 54 links — so keyboard and screen-reader users navigate the same structure everyone else does, not a parallel one.
Toolchain
- Authoring — hand-written HTML, CSS and ES modules. No framework, no bundler, no transpiler. Built with Claude Opus 5, directed by Hannah Kwakye; see Process for the full attribution note.
- Type — Newsreader (display), Geist (interface), Geist Mono (data and labels), self-hosted as three variable woff2 files, 192 KB total. Shared with the parent Orvantis Intelligence identity: a deliberate signal that this is an initiative of that house, not a separate venture.
- Every visual is code-drawn. No photographs, no generated imagery. The map is projected geometry, the charts are computed SVG, the stipple is an SVG pattern, the favicon is an inline data-URI. The only external inputs are public-domain boundary coordinates and open indicator data.
- Motion — the overture holds only as long as the map takes to acquire, then leaves. Under
prefers-reduced-motionthe stroke-in is skipped and the map renders resolved immediately.
What I would do next
- Curated evidence for all 54 — national development plans, budgets and funded projects, each dated and linked. The engine already merges this layer per country; it is a research task, not an engineering one.
- A real change log: record why a projection moved, preserve the superseded version, and show readers the difference — the "what changed?" capability the architecture is shaped for but does not yet populate.
- Score the 2031 paths against outturns as they arrive, publicly, without rewriting the originals.