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).
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
<!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 + " · " + 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>