← Examples
countries · capitals · orthographic · zoomable · coastlines

Capitals on the Globe

Every national capital plotted from the capital, capitalLat and capitalLng properties, on an orthographic globe — drag to spin, scroll to zoom. Each dot is coloured by whether the capital sits within roughly 120 km of a coastline (measured against the coastlines layer).

coastal capital (< ~120 km from the sea)
inland capital

API Calls

https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,capital,capitalLat,capitalLng
https://api.mapjson.com/v1/geo?layer=coastlines&detail=medium

Code

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
  <style>
    body { margin: 0; background: #070b13; }
    svg { width: 100%; height: 100vh; display: block; }
  </style>
</head>
<body>
  <svg id="map"></svg>
  <script>
  const OCEAN = "#0a1322", LAND = "#16263f", LANDLINE = "#26436b",
        COASTAL = "#45e0b0", INLAND = "#f0a63a", R_KM = 6371, COASTAL_KM = 120;
  const W = 900, H = 680, base = H / 2 - 16;
  const svg = d3.select("#map").attr("viewBox", `0 0 ${W} ${H}`);
  const tooltip = d3.select("body").append("div")
    .style("position", "fixed").style("background", "#0b1220").style("color", "#eaf2ff")
    .style("font", "11px ui-monospace, monospace").style("padding", "4px 8px")
    .style("border", "1px solid #1d3252").style("border-radius", "4px")
    .style("pointer-events", "none").style("opacity", 0);
  const projection = d3.geoOrthographic().translate([W / 2, H / 2]).rotate([-10, -20]).scale(base);
  const path = d3.geoPath(projection);

  const gSphere = svg.append("g"), gGrat = svg.append("g"), gLand = svg.append("g"), gDots = svg.append("g");
  gSphere.append("path").datum({ type: "Sphere" }).attr("fill", OCEAN).attr("stroke", "#1b3050").attr("stroke-width", 1);
  gGrat.append("path").datum(d3.geoGraticule10()).attr("fill", "none").attr("stroke", "#132743").attr("stroke-width", 0.5);

  const API = "https://api.mapjson.com/v1/geo?format=topojson&";
  Promise.all([
    d3.json(API + "layer=countries&filter=world&detail=low&properties=name,capital,capitalLat,capitalLng"),
    d3.json(API + "layer=coastlines&detail=medium"),
  ]).then(([cT, coastT]) => {
    const land = topojson.feature(cT, cT.objects.geo);
    const coast = topojson.feature(coastT, coastT.objects.geo);
    let caps = land.features.map(f => f.properties)
      .filter(p => p.capital && isFinite(p.capitalLat) && isFinite(p.capitalLng))
      .map(p => ({ name: p.capital, country: p.name, lng: +p.capitalLng, lat: +p.capitalLat }));
    // downsample coastline vertices, then flag each capital coastal / inland
    const pts = [];
    coast.features.forEach(ft => {
      const gs = ft.geometry.type === "MultiLineString" ? ft.geometry.coordinates : [ft.geometry.coordinates];
      gs.forEach(line => line.forEach(c => pts.push(c)));
    });
    const stepN = Math.max(1, Math.floor(pts.length / 6000)), cpts = pts.filter((_, i) => i % stepN === 0);
    caps.forEach(c => { let m = Infinity; for (const p of cpts) { const d = d3.geoDistance([c.lng, c.lat], p); if (d < m) m = d; } c.coastal = (m * R_KM) <= COASTAL_KM; });

    const landSel = gLand.selectAll("path").data(land.features).join("path")
      .attr("fill", LAND).attr("stroke", LANDLINE).attr("stroke-width", 0.4);
    const dotSel = gDots.selectAll("circle").data(caps).join("circle")
      .attr("fill", d => d.coastal ? COASTAL : INLAND).attr("stroke", "#070b13").attr("stroke-width", 0.6).style("cursor", "pointer")
      .on("mouseover", (e, d) => tooltip.style("opacity", 1).html(d.name + " &middot; " + d.country + "<br>" + (d.coastal ? "coastal" : "inland")))
      .on("mousemove", e => tooltip.style("left", (e.clientX + 12) + "px").style("top", (e.clientY - 8) + "px"))
      .on("mouseout", () => tooltip.style("opacity", 0));

    function render() {
      const center = [-projection.rotate()[0], -projection.rotate()[1]], z = projection.scale() / base;
      gSphere.select("path").attr("d", path); gGrat.select("path").attr("d", path); landSel.attr("d", path);
      dotSel.attr("cx", d => projection([d.lng, d.lat])[0]).attr("cy", d => projection([d.lng, d.lat])[1])
        .attr("r", Math.max(1.6, 2.4 * Math.sqrt(z)))
        .style("display", d => d3.geoDistance([d.lng, d.lat], center) > Math.PI / 2 ? "none" : null);
    }
    render();

    // drag to spin (slower as you zoom in), scroll / pinch to zoom
    let last = null;
    svg.style("cursor", "grab").call(d3.drag()
      .on("start", e => last = [e.x, e.y])
      .on("drag", e => {
        const k = 0.26 / (projection.scale() / base), r = projection.rotate();
        projection.rotate([r[0] + (e.x - last[0]) * k, Math.max(-90, Math.min(90, r[1] - (e.y - last[1]) * k))]);
        last = [e.x, e.y]; render();
      }));
    svg.node().addEventListener("wheel", ev => {
      ev.preventDefault();
      projection.scale(Math.max(base, Math.min(base * 6, projection.scale() * (ev.deltaY < 0 ? 1.12 : 1 / 1.12))));
      render();
    }, { passive: false });
  });
  </script>
</body>
</html>