← Examples
constellation · see-through globe · graticule · art

Constellation Earth

The atlas turned astral — and spherical. Every country is a star on a transparent, graticuled globe: sized by its land area, joined to its nearest neighbours, so the borders of the world become constellations. Because the globe is see-through, the stars on the far side glimmer faintly through the grid. Drag to spin it. One /v1/geo call.

How it's made

One mapjson call gives each country's polygon and areakm2. The geographic centroid of each becomes a star, joined by great-circle arcs to its three nearest neighbours (nearest by d3.geoDistance). Everything is drawn through a d3.geoOrthographic projection with its clip opened to the whole sphere, so the back hemisphere renders too — graticule, arcs and stars all show through, the far-side stars dimmed. Drag updates the rotation and the whole globe re-projects.

API Calls

https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,areakm2&format=geojson

Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Constellation Earth — see-through star globe</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { margin: 0; background: #05070f; font-family: system-ui, sans-serif; }
    .wrap { max-width: 980px; margin: 0 auto; }
    #map { overflow: hidden; }
    #map svg { width: 100%; display: block; cursor: grab; }
    .tooltip { position: fixed; background: #0b1224; color: #dbe6ff; font: 11px ui-monospace, monospace; padding: 4px 8px; pointer-events: none; opacity: 0; border-radius: 3px; }
  </style>
</head>
<body>
  <div class="wrap"><div id="map"></div></div>
  <div class="tooltip" id="tooltip"></div>
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <script>
  const W = 960, H = 560;
  const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${W} ${H}`);
  const tip = d3.select("#tooltip");
  const projection = d3.geoOrthographic().rotate([-11, -8]);
  projection.fitExtent([[40, 30], [W - 40, H - 30]], { type: "Sphere" });
  projection.clipAngle(180);                    // open the clip → render the far hemisphere too (see-through)
  const graticule = d3.geoGraticule().step([20, 20])();
  const geoPathC = d3.geoPath(projection);

  d3.json("https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,areakm2&format=geojson").then((w) => {
    const rScale = d3.scaleSqrt([0, d3.max(w.features, (f) => f.properties.areakm2 || 0)], [0.8, 5.2]);
    const stars = w.features.map((f) => { const ll = d3.geoCentroid(f), a = f.properties.areakm2 || 0; return a > 0 ? { ll, r: rScale(a), name: f.properties.name } : null; }).filter(Boolean);
    const links = [];
    const seen = new Set();
    stars.forEach((s, i) => {
      stars.map((t, j) => ({ j, d: j === i ? 9 : d3.geoDistance(s.ll, t.ll) })).sort((a, b) => a.d - b.d).slice(1, 4)
        .forEach((o) => { const key = i < o.j ? i + "-" + o.j : o.j + "-" + i; if (seen.has(key)) return; seen.add(key); links.push([s.ll, stars[o.j].ll]); });
    });

    svg.append("rect").attr("width", W).attr("height", H).attr("fill", "#05070f");
    const dust = svg.append("g");
    for (let i = 0; i < 220; i++) dust.append("circle").attr("cx", Math.random() * W).attr("cy", Math.random() * H).attr("r", Math.random() * 0.8 + 0.2).attr("fill", "#8ea6d0").attr("fill-opacity", Math.random() * 0.35 + 0.08);
    const disc = svg.append("circle").attr("fill", "#0a1024").attr("fill-opacity", 0.55).attr("stroke", "#2b4a7f").attr("stroke-width", 0.9);
    const grat = svg.append("path").attr("fill", "none").attr("stroke", "#20365f").attr("stroke-width", 0.5).attr("stroke-opacity", 0.6);
    const linkG = svg.append("g").attr("fill", "none").attr("stroke", "#3a5487");
    const linkSel = linkG.selectAll("path").data(links).join("path").attr("stroke-width", 0.5);
    const starG = svg.append("g");
    const starSel = starG.selectAll("g").data(stars).join("g");
    starSel.append("circle").attr("class", "halo");
    starSel.append("circle").attr("class", "core").style("cursor", "pointer")
      .on("mousemove", (e, d) => tip.style("opacity", 1).style("left", (e.clientX + 12) + "px").style("top", (e.clientY - 8) + "px").text(d.name))
      .on("mouseout", () => tip.style("opacity", 0));

    function update() {
      const t = projection.translate(), R = projection.scale();
      disc.attr("cx", t[0]).attr("cy", t[1]).attr("r", R);
      grat.attr("d", geoPathC(graticule));
      const center = [-projection.rotate()[0], -projection.rotate()[1]];
      linkSel.attr("d", (d) => geoPathC({ type: "LineString", coordinates: d }))
        .attr("stroke-opacity", (d) => (d3.geoDistance(center, d[0]) < 1.6 && d3.geoDistance(center, d[1]) < 1.6) ? 0.42 : 0.12);
      starSel.each(function (d) {
        const p = projection(d.ll); const front = d3.geoDistance(center, d.ll) < Math.PI / 2;
        const g = d3.select(this).attr("transform", `translate(${p[0]},${p[1]})`);
        g.select(".halo").attr("r", d.r * 2.6).attr("fill", "#cfe0ff").attr("fill-opacity", front ? 0.16 : 0.0);
        g.select(".core").attr("r", front ? d.r : d.r * 0.75).attr("fill", front ? "#f4f8ff" : "#5f79ad").attr("fill-opacity", front ? 1 : 0.55);
      });
    }
    update();

    let dragging = false;
    svg.call(d3.drag()
      .on("start", () => { dragging = true; svg.style("cursor", "grabbing"); })
      .on("drag", (e) => { const r = projection.rotate(), k = 0.28; projection.rotate([r[0] + e.dx * k, Math.max(-90, Math.min(90, r[1] - e.dy * k))]); update(); })
      .on("end", () => { dragging = false; svg.style("cursor", "grab"); }));
    let idle = 0;
    d3.timer(() => { if (!dragging) { idle++; if (idle > 60) { const r = projection.rotate(); projection.rotate([r[0] + 0.15, r[1]]); update(); } } });
  });
  </script>
</body>
</html>