← Examples
Wikidata · SPARQL · categorical · zoomable

Which side of the road

Wikidata knows which side of the road every country drives on, and will answer a SPARQL query about it from the browser. The result keys on ISO 3166-1 alpha-2 — the same code mapjson uses as its country gid — so the join is a lookup, and the map draws the shape of the British Empire more clearly than most maps of the British Empire.

querying Wikidata…

scroll to zoom, drag to pan — dots give way to real coastlines as you go in

API Calls

// Wikidata Query Service — SPARQL over HTTP GET, CORS-enabled, no key
https://query.wikidata.org/sparql?format=json&query=…

  SELECT ?iso2 ?sideLabel WHERE {
    ?c wdt:P297 ?iso2 .                          # ISO 3166-1 alpha-2
    ?c p:P1622 ?st . ?st ps:P1622 ?side .        # driving side
    FILTER NOT EXISTS { ?st pq:P582 ?end }       # drop statements that have ended
    SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
  }

// geometry + population — mapjson
https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium&properties=iso2,name,population

Counting people, not countries

By country the split looks lopsided; by population it is much closer, because India, Indonesia, Pakistan, Japan and the United Kingdom are all on the left. The share under the map is computed from mapjson's own population property, so the page joins two sources and needs no third.

Querying Wikidata from a page

The query service accepts a plain GET with format=json and sends Access-Control-Allow-Origin: *, so no proxy or key is involved. Two details make the difference between a clean map and a messy one. Asking for p:/ps: rather than the truthy wdt: shortcut exposes the statement itself, so FILTER NOT EXISTS { ?st pq:P582 ?end } can drop rules that have since been repealed — otherwise Sweden appears twice, once for the left-hand traffic it abandoned in 1967. And the query deliberately does not restrict to sovereign states: Greenland, Hong Kong and Puerto Rico carry their own code and their own rule, and excluding them punches grey holes in the map. Six features stay grey because nobody has a rule to record — Antarctica, two disputed territories, and three uninhabited islands.

Keep queries cheap. The public endpoint has a timeout, and aggregate queries across large classes will hit it — this one returns a couple of hundred rows and comes back in well under a second.

Islands you would otherwise lose

An equal-area world map is brutal to small places. Martinique is about 1,100 km², which at this size projects to roughly one pixel — filled correctly, and invisible. So are Malta, Singapore, Mauritius and most of the eastern Caribbean, which is exactly the part of the map where the left-hand rule is most concentrated.

Anything whose projected area falls under six square pixels therefore gets a dot at its centroid in its own colour, on top of the fill. The cut-off is measured with path.area() on the projected shape rather than from square kilometres, so it tracks whatever projection and canvas size the page happens to use — 72 territories qualify here. It is the standard fix for this problem, and the alternative is a map that quietly tells you the Caribbean has no opinion about which side of the road to drive on.

Zooming settles it properly. Scroll or drag and the map scales up to 60x, and each dot disappears at exactly the point its own country grows big enough to see: at scale k a shape covers its area times k², so the marker retires once that clears the same six-pixel bar that put it there. Zoom into the eastern Caribbean and you get real coastlines, each in its own colour. The zoom transforms the drawn group rather than reprojecting the geometry, so panning stays smooth, and strokes and dots are divided by k to hold their weight on screen. The legend sits outside the zoomed group and stays put.

Code

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Which side of the road</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
  body { margin: 0; background: #efece6; font: 13px 'IBM Plex Mono', ui-monospace, monospace; color: #3b3a35; }
  #map-wrap { position: relative; }
  #map-wrap svg { width: 100%; display: block; }
  .tip { position: absolute; background: #26251f; color: #f4f1ea; font-size: 11px; padding: 5px 9px;
         pointer-events: none; opacity: 0; white-space: nowrap; line-height: 1.6; }
  .controls { display: flex; align-items: center; gap: 14px; padding: 12px 14px; }
  .controls button { font: inherit; padding: 5px 16px; border: 1px solid #3b3a35; background: #fff; cursor: pointer; }
  .controls span { color: #6b6a63; }
</style>
</head>
<body>
<div id="map-wrap"><div class="tip" id="tip"></div></div>
<div class="controls">
  <button id="reset">reset</button>
  <span>scroll to zoom, drag to pan</span>
</div>
<script>
// Anything with an ISO 3166-1 alpha-2 code (P297) and a side of the road
// (P1622). Deliberately not restricted to sovereign states: dependencies like
// Greenland, Hong Kong and Puerto Rico have their own code and their own rule,
// and leaving them out puts grey holes in the map. The FILTER drops statements
// carrying an end date, so Sweden counts once — as the right-hand country it
// became in 1967, not also as the left-hand one it stopped being.
const SPARQL = `
  SELECT ?iso2 ?sideLabel WHERE {
    ?c wdt:P297 ?iso2 .
    ?c p:P1622 ?st . ?st ps:P1622 ?side .
    FILTER NOT EXISTS { ?st pq:P582 ?end }
    SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
  }`;
const WIKIDATA = "https://query.wikidata.org/sparql?format=json&query=" + encodeURIComponent(SPARQL);

// population comes from mapjson itself, so the share below needs no third source.
const GEO = "https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium"
          + "&properties=iso2,name,population&format=geojson";

const COLOR = { left: "#b0402f", right: "#2f6f8f" };
const NO_DATA = "#d9d5cc";
// Below this projected size a country is smaller than a couple of pixels and
// its fill is invisible, however correct it is. Martinique lands at about one.
const MIN_AREA = 6;

(async function () {
  const [wd, world] = await Promise.all([d3.json(WIKIDATA), d3.json(GEO)]);
  const side = new Map(wd.results.bindings.map(r => [r.iso2.value, r.sideLabel.value]));

  const W = 1200, H = 620;
  const svg = d3.select("#map-wrap").append("svg").attr("viewBox", "0 0 " + W + " " + H);
  const projection = d3.geoEqualEarth().rotate([-11, 0]);
  const path = d3.geoPath(projection);
  projection.fitExtent([[10, 10], [W - 10, H - 60]], world);
  // Tooltip coordinates are measured against #map-wrap explicitly. On SVG
  // children Chrome reports the pointer offset relative to the hovered shape's
  // own box, which lands the tooltip somewhere different for every country.
  const wrap = document.getElementById("map-wrap");
  const tip = d3.select("#tip");

  const fmtPop = d3.format(",.3~s");

  const hover = (e, d) => {
    const s = side.get(d.properties.iso2);
    const pop = d.properties.population;
    const [mx, my] = d3.pointer(e, wrap);
    tip.style("opacity", 1)
       .style("left", (mx + 14) + "px")
       .style("top", (my - 6) + "px")
       .html("<b>" + d.properties.name + "</b><br>"
           + (s ? "drives on the " + s : "no data")
           + (pop ? "<br>" + fmtPop(pop) + " people" : ""));
  };
  const away = () => tip.style("opacity", 0);

  // Everything that should zoom goes in one group; the legend stays outside it
  // so it keeps its size and place while the map moves underneath.
  const gZoom = svg.append("g");

  const shapes = gZoom.append("g").selectAll("path").data(world.features).join("path")
    .attr("d", path)
    .attr("fill", d => COLOR[side.get(d.properties.iso2)] || NO_DATA)
    .attr("stroke", "#efece6").attr("stroke-width", 0.4)
    .on("mousemove", hover).on("mouseout", away);

  // Small islands and enclaves — Martinique, Malta, Singapore, the whole
  // eastern Caribbean — carry a rule but occupy about a pixel on a world map,
  // so the fill alone drops them. Anything under MIN_AREA also gets a dot at
  // its centroid, which is the only way the Caribbean reads as left-hand at
  // this scale. path.area() measures the shape as projected, so the cut-off
  // follows the drawing rather than a guess about square kilometres.
  const dotted = world.features.filter(d =>
    side.has(d.properties.iso2) && path.area(d) < MIN_AREA);

  const dotArea = new Map(dotted.map(d => [d, path.area(d)]));

  const dots = gZoom.append("g").selectAll("circle").data(dotted).join("circle")
    .attr("cx", d => path.centroid(d)[0])
    .attr("cy", d => path.centroid(d)[1])
    .attr("r", 2.8)
    .attr("fill", d => COLOR[side.get(d.properties.iso2)])
    .attr("stroke", "#efece6").attr("stroke-width", 0.7)
    .on("mousemove", hover).on("mouseout", away);

  // Zoom transforms the group rather than reprojecting, so panning stays cheap.
  // Strokes and dots are divided by k to hold their size on screen, and each
  // dot retires the moment its own country is finally big enough to see: at
  // scale k the shape covers area x k^2, so the marker has done its job.
  const zoom = d3.zoom().scaleExtent([1, 60]).on("zoom", (e) => {
    const k = e.transform.k;
    gZoom.attr("transform", e.transform);
    shapes.attr("stroke-width", 0.4 / k);
    dots.attr("r", 2.8 / k).attr("stroke-width", 0.7 / k)
        .attr("display", d => (dotArea.get(d) * k * k >= MIN_AREA ? "none" : null));
  });

  svg.call(zoom).style("cursor", "grab")
     .on("mousedown.cursor", () => svg.style("cursor", "grabbing"))
     .on("mouseup.cursor", () => svg.style("cursor", "grab"));

  const resetBtn = document.getElementById("reset");
  if (resetBtn) resetBtn.onclick = () => svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);

  // Tally by people as well as by country. The country count reads as a small
  // minority until you notice India, Indonesia, Japan and Pakistan are in it.
  const tally = { left: { n: 0, pop: 0 }, right: { n: 0, pop: 0 } };
  for (const f of world.features) {
    const s = side.get(f.properties.iso2);
    if (!s || !tally[s]) continue;
    tally[s].n++;
    tally[s].pop += f.properties.population || 0;
  }
  const totalPop = tally.left.pop + tally.right.pop;
  const pct = (s) => Math.round((tally[s].pop / totalPop) * 100);

  const key = svg.append("g").attr("transform", "translate(34," + (H - 42) + ")");
  ["left", "right"].forEach((s, i) => {
    const row = key.append("g").attr("transform", "translate(" + i * 300 + ",0)");
    row.append("rect").attr("width", 14).attr("height", 14).attr("y", -11).attr("fill", COLOR[s]);
    row.append("text").attr("x", 22).attr("font-size", 12).attr("fill", "#4a4a45")
      .attr("font-family", "'IBM Plex Mono', ui-monospace, monospace")
      .text("drives on the " + s + " — " + tally[s].n + " countries, " + pct(s) + "% of people");
  });

  const live = document.getElementById("live");
  if (live) live.innerHTML = "<b>" + tally.left.n + "</b> countries drive on the left — but "
    + "<b>" + pct("left") + "%</b> of the world's people do, on mapjson's population figures.";
})();
</script>
</body>
</html>