← Examples
World Bank · two indicators · linked scatter + zoomable map

Wealth and health

Plot income against life expectancy and you get the Preston curve: steep at the poorest end, flattening once a country is comfortable. The scatter is the chart; the map is the index. Hover a circle to find that country on the map, or hover the map to find its circle — both are coloured by the continent property mapjson returns, so the two views always agree.

fetching three World Bank indicators…

scroll to zoom the map, drag to pan — the scatter stays put

API Calls

// which World Bank codes are countries rather than aggregates
https://api.worldbank.org/v2/country?format=json&per_page=400

// three indicators for 2023 — income, life expectancy, population
https://api.worldbank.org/v2/country/all/indicator/NY.GDP.PCAP.CD?format=json&per_page=400&date=2023
https://api.worldbank.org/v2/country/all/indicator/SP.DYN.LE00.IN?format=json&per_page=400&date=2023
https://api.worldbank.org/v2/country/all/indicator/SP.POP.TOTL?format=json&per_page=400&date=2023

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

One SVG, two views

The map and the scatter live in a single <svg> rather than two, which keeps the linked hover to one selection on each side and means the save button exports both together as one image.

Colour comes from mapjson rather than a lookup table in the page: asking for properties=continent returns the continent alongside the geometry, so a circle and its country are drawn from the same value. Three island nations — Maldives, Mauritius and Seychelles — come back as Seven seas (open ocean), which is how Natural Earth files them; they keep that value here rather than being reassigned to a landmass.

The same aggregate-code collision described on the life expectancy example applies to all three indicators, and is handled the same way.

Zooming half a drawing

Sharing one <svg> makes zoom the interesting part: the map has to scale while the chart underneath holds still. The map therefore sits in its own group with a clipPath at its lower edge, and the zoom behaviour binds to that group rather than to the svg — so a scroll over the scatter scrolls the page as usual, and zoomed geometry is cut off instead of sliding across the axes. A transparent rectangle behind the countries catches gestures over open ocean, and translateExtent stops the map being dragged out of its own frame.

Countries too small to see on the locator — about 44 of them — carry a dot in their continent colour that highlights with the rest of the linked hover, and gives way to the real coastline as you zoom in.

Code

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Wealth and health, 2023</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 map</button>
  <span>scroll to zoom the map, drag to pan — the scatter stays put</span>
</div>
<script>
const YEAR = 2023;
const wbSeries = (ind) => "https://api.worldbank.org/v2/country/all/indicator/" + ind
                        + "?format=json&per_page=400&date=" + YEAR;
const WB_COUNTRIES = "https://api.worldbank.org/v2/country?format=json&per_page=400";
const GEO = "https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=medium"
          + "&properties=iso2,name,continent&format=geojson";

// Continent comes from mapjson, so the scatter and the map are coloured off the
// same field and a dot always matches its country.
const CONTINENT = {
  "Africa": "#c0562f", "Asia": "#2f6f8f", "Europe": "#4a7c3f",
  "North America": "#8a5aa8", "South America": "#c8952f", "Oceania": "#3f8f85",
  // Natural Earth files Maldives, Mauritius and Seychelles here, not under a
  // landmass. Kept as its own key rather than reassigned, so the colour on the
  // map always matches the field mapjson actually returns.
  "Seven seas (open ocean)": "#8b8a82",
};
const LABEL = { "Seven seas (open ocean)": "Seven seas" };
// A country under this projected size is about a pixel on the locator map, so
// it gets a dot in its continent colour until zoom makes the shape legible.
const MIN_AREA = 6;

// The World Bank returns regional and income aggregates alongside countries,
// and eleven of their aggregate codes collide with mapjson gids for tiny
// territories ("XD" = High income to them, Dhekelia to us). region.id "NA"
// marks an aggregate.
async function realCountryCodes() {
  const [, rows] = await d3.json(WB_COUNTRIES);
  return new Set(rows.filter(r => r.region.id !== "NA").map(r => r.iso2Code));
}

const series = (json, codes) => {
  const out = new Map();
  for (const r of json[1]) {
    if (r.value != null && codes.has(r.country.id)) out.set(r.country.id, r.value);
  }
  return out;
};

(async function () {
  const codes = await realCountryCodes();
  const [gdpJson, lifeJson, popJson, world] = await Promise.all([
    d3.json(wbSeries("NY.GDP.PCAP.CD")),
    d3.json(wbSeries("SP.DYN.LE00.IN")),
    d3.json(wbSeries("SP.POP.TOTL")),
    d3.json(GEO),
  ]);
  const gdp = series(gdpJson, codes), life = series(lifeJson, codes), pop = series(popJson, codes);

  const meta = new Map(world.features.map(f => [f.properties.iso2, f.properties]));
  const points = [...gdp.keys()]
    .filter(iso => life.has(iso) && pop.has(iso) && meta.has(iso))
    .map(iso => ({
      iso, gdp: gdp.get(iso), life: life.get(iso), pop: pop.get(iso),
      name: meta.get(iso).name,
      color: CONTINENT[meta.get(iso).continent] || "#8b8a82",
    }));

  const W = 1200, MAP_H = 520, PLOT_H = 430, H = MAP_H + PLOT_H;
  const M = { top: 26, right: 30, bottom: 52, left: 62 };

  const svg = d3.select("#map-wrap").append("svg").attr("viewBox", "0 0 " + W + " " + H);
  // 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");

  // ── map: a locator, not a choropleth. Fills are the continent colour held
  // back to a wash so the scatter stays the subject.
  const projection = d3.geoEqualEarth().rotate([-11, 0]);
  const path = d3.geoPath(projection);
  projection.fitExtent([[10, 10], [W - 10, MAP_H - 10]], world);

  const byIso = new Map(points.map(p => [p.iso, p]));

  // The map zooms; the scatter below it must not move, so the map lives in its
  // own clipped group and the zoom behaviour is bound to that group alone.
  // Zoomed geometry is clipped at the map's lower edge instead of sliding over
  // the chart.
  svg.append("defs").append("clipPath").attr("id", "mapclip")
    .append("rect").attr("width", W).attr("height", MAP_H);

  const gMap = svg.append("g").attr("clip-path", "url(#mapclip)");
  // Transparent catcher so the gesture works over empty ocean too, not just
  // when the pointer happens to be on a country.
  gMap.append("rect").attr("width", W).attr("height", MAP_H)
    .attr("fill", "none").attr("pointer-events", "all");
  const gZoom = gMap.append("g");

  const shapes = gZoom.append("g").selectAll("path").data(world.features).join("path")
    .attr("d", path)
    .attr("fill", d => CONTINENT[d.properties.continent] || "#c9c6bd")
    .attr("fill-opacity", d => (byIso.has(d.properties.iso2) ? 0.28 : 0.1))
    .attr("stroke", "#efece6").attr("stroke-width", 0.4)
    .style("cursor", "default");

  const tiny = world.features.filter(d =>
    byIso.has(d.properties.iso2) && path.area(d) < MIN_AREA);
  const tinyArea = new Map(tiny.map(d => [d, path.area(d)]));

  const pins = gZoom.append("g").selectAll("circle").data(tiny).join("circle")
    .attr("cx", d => path.centroid(d)[0])
    .attr("cy", d => path.centroid(d)[1])
    .attr("r", 2.8)
    .attr("fill", d => CONTINENT[d.properties.continent] || "#8b8a82")
    .attr("fill-opacity", 0.85)
    .attr("stroke", "#efece6").attr("stroke-width", 0.7);

  // ── scatter
  const g = svg.append("g").attr("transform", "translate(0," + MAP_H + ")");
  const x = d3.scaleLog().domain([200, 300000]).range([M.left, W - M.right]);
  const y = d3.scaleLinear().domain([50, 90]).range([PLOT_H - M.bottom, M.top]);
  const r = d3.scaleSqrt().domain([0, d3.max(points, p => p.pop)]).range([2, 34]);

  g.append("g").attr("transform", "translate(0," + (PLOT_H - M.bottom) + ")")
    .call(d3.axisBottom(x).tickValues([500, 1000, 5000, 10000, 50000, 100000])
      .tickFormat(d3.format("$,.0f")));
  g.append("g").attr("transform", "translate(" + M.left + ",0)")
    .call(d3.axisLeft(y).ticks(5).tickFormat(d => d + " yrs"));
  g.selectAll(".domain, .tick line").attr("stroke", "#b9b6ad");
  g.selectAll(".tick text").attr("fill", "#6b6a63")
    .attr("font-family", "'IBM Plex Mono', ui-monospace, monospace").attr("font-size", 11);

  g.append("text").attr("x", W / 2).attr("y", PLOT_H - 12).attr("text-anchor", "middle")
    .attr("font-size", 12).attr("fill", "#6b6a63")
    .attr("font-family", "'IBM Plex Mono', ui-monospace, monospace")
    .text("GDP per capita, current US$ (log scale) — circle area is population");

  const dots = g.append("g").selectAll("circle").data(points).join("circle")
    .attr("cx", p => x(p.gdp)).attr("cy", p => y(p.life)).attr("r", p => r(p.pop))
    .attr("fill", p => p.color).attr("fill-opacity", 0.55)
    .attr("stroke", p => p.color).attr("stroke-width", 1);

  // ── linked hover, both directions
  function show(iso, ev) {
    dots.attr("fill-opacity", p => (p.iso === iso ? 0.95 : 0.16))
        .attr("stroke-width", p => (p.iso === iso ? 2 : 1));
    shapes.attr("fill-opacity", d => {
      if (d.properties.iso2 === iso) return 0.95;
      return byIso.has(d.properties.iso2) ? 0.14 : 0.06;
    });
    pins.attr("fill-opacity", d => (d.properties.iso2 === iso ? 1 : 0.2));
    const p = byIso.get(iso);
    if (p && ev) {
      const [mx, my] = d3.pointer(ev, wrap);
      tip.style("opacity", 1)
         .style("left", (mx + 14) + "px")
         .style("top", (my - 6) + "px")
         .html("<b>" + p.name + "</b><br>" + d3.format("$,.0f")(p.gdp) + " per capita<br>"
             + p.life.toFixed(1) + " years<br>" + d3.format(",.3~s")(p.pop) + " people");
    }
  }

  function clear() {
    dots.attr("fill-opacity", 0.55).attr("stroke-width", 1);
    shapes.attr("fill-opacity", d => (byIso.has(d.properties.iso2) ? 0.28 : 0.1));
    pins.attr("fill-opacity", 0.85);
    tip.style("opacity", 0);
  }

  const onFeature = (e, d) =>
    (byIso.has(d.properties.iso2) ? show(d.properties.iso2, e) : clear());

  dots.on("mousemove", (e, p) => show(p.iso, e)).on("mouseout", clear);
  shapes.on("mousemove", onFeature).on("mouseout", clear);
  pins.on("mousemove", onFeature).on("mouseout", clear);

  // Bound to gMap, not the svg, so the wheel over the scatter still scrolls the
  // page. A pin disappears once its country covers MIN_AREA at this scale.
  const zoom = d3.zoom().scaleExtent([1, 60])
    .translateExtent([[0, 0], [W, MAP_H]])
    .on("zoom", (e) => {
      const k = e.transform.k;
      gZoom.attr("transform", e.transform);
      shapes.attr("stroke-width", 0.4 / k);
      pins.attr("r", 2.8 / k).attr("stroke-width", 0.7 / k)
          .attr("display", d => (tinyArea.get(d) * k * k >= MIN_AREA ? "none" : null));
    });
  gMap.call(zoom).style("cursor", "grab");

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

  // ── continent key
  const key = svg.append("g").attr("transform", "translate(16,22)");
  key.selectAll("g").data(Object.entries(CONTINENT)).join("g")
    .attr("transform", (d, i) => "translate(0," + i * 18 + ")")
    .each(function ([label, col]) {
      const row = d3.select(this);
      row.append("circle").attr("r", 5).attr("cx", 6).attr("cy", -4).attr("fill", col).attr("fill-opacity", 0.75);
      row.append("text").attr("x", 18).attr("font-size", 11).attr("fill", "#5a5952")
         .attr("font-family", "'IBM Plex Mono', ui-monospace, monospace").text(LABEL[label] || label);
    });

  // The example page on mapjson.com has a status line; the standalone does not.
  const live = document.getElementById("live");
  if (live) live.innerHTML = "<b>" + points.length + "</b> countries carry all three indicators for "
    + YEAR + ", joined to mapjson geometry on ISO2 — hover either view.";
})();
</script>
</body>
</html>