Skip to content

Maps

A world choropleth with no map library, no projection code at runtime, and no attribution requirement.

Licence and provenance

The geometry is Natural Earth 1:110m Admin-0, which is public domain (CC0) and requires no attribution. That is the only licence posture that works for a template which gets resold: an attribution requirement would travel to every buyer and every buyer's client.

It arrives via the world-atlas package (ISC), which is a devDependency — nothing from it ships. What ships is src/lib/geo/world-110m.ts, generated and committed.

Why the geometry is generated, not computed

A choropleth's geometry never changes, so projecting it in the browser on every page load is repeated work for the same answer. tools/build-world-map.mjs projects Natural Earth into a fixed 1000×489 viewBox once, at authoring time.

node tools/build-world-map.mjs
# world map: 176 countries -> src/lib/geo/world-110m.ts (83KB raw)

The projection is Miller cylindrical, not plate carrée. Plate carrée is linear in latitude and so not conformal — east-west distance is exaggerated by 1/cos(lat), 31% too wide at 40°N, which made the United States render visibly squat. Mercator would be conformal but reaches 1.55:1 once Greenland is included, far too tall for a dashboard card; Miller lands at ~2:1.

Two things are deliberately dropped. Antarcticais excluded: on any cylindrical projection its polygon runs to the pole across every longitude and renders as a band rather than a landmass. And the vertical extent is cropped to the latitudes land actually occupies, so no ocean pads the box — with Antarctica in, the southern fifth was empty and every inhabited landmass was squeezed above it.

Rings are also split where they cross the antimeridian. GeoJSON keeps longitude in [−180, 180], so Russia stores consecutive points at 179.4 and −179.6 — projected straight, that is a segment spanning the entire map. Russia drew two such bands and Fiji a third.

Coordinates are rounded to zero decimal places, which was measured rather than guessed: 0 places gives 30KB gzipped, 1 gives 46KB and 2 gives 59KB. One viewBox unit is a full pixel only when the map is rendered at its full 1000px width, and a dashboard card gives it 600–900 — so the extra places buy sub-pixel accuracy nobody can see.

react-simple-maps was rejected for pulling d3-geo and topojson (~60KB gz) to re-derive a fixed result, and jsvectormap for being imperative and DOM-mutating.

Using it

import dynamic from "next/dynamic";

// Always behind next/dynamic: the geometry lands in its own lazy chunk, so
// pages without a map pay nothing for it.
const WorldMap = dynamic(
  () => import("@/components/charts/world-map").then((m) => m.WorldMap),
  { ssr: false },
);

<WorldMap
  data={[{ id: "826", value: 4820 }, { id: "036", value: 1240 }]}
  label="orders"
  format={(v) => v.toLocaleString("en-US")}
/>

The trap: ids are zero-padded

Country ids are ISO 3166-1 numeric as zero-padded three-character strings — Australia is "036", not "36". Twenty-one countries are affected.

Getting it wrong is silent: an unmatched id renders in the base colour and reports “no data”, which is indistinguishable from a genuine gap in the dataset. So the most likely mistake produces a map that looks entirely plausible and is wrong. WorldMap warns about unmatched ids in development for exactly that reason.

Three things it does on purpose

  • The map does not mirror under RTL. Everything else in this template flips — that is what the logical properties are for — but a mirrored world map is simply a wrong map. Only the chrome around it follows the writing direction.
  • Colour is not the only encoding. An sr-only table of the same values precedes the SVG. That table is the accessible representation, not a fallback: it carries every number, which a choropleth cannot.
  • Unknown countries are absent, not zero. A country with no datum takes the muted base colour. Filling it as the lowest value would claim a measurement nobody took.

See it in use on /logistics.