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.
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.
https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,areakm2&format=geojson
<!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>