The /v1/points?type=city endpoint returns the world's most populous cities plus every national capital as a GeoJSON FeatureCollection. Circles are sized by popMetro — metropolitan / urban-agglomeration population, so Greater Tokyo reads ≈ 35.7M, not the ~9M city proper. National capitals (capital: true) are drawn in gold. Every point is pre-tagged with its country and admin1 region, so a single filter narrows the set to a continent, country, or state — no client-side geometry needed.
https://api.mapjson.com/v1/points?type=city&filter=world
Swap filter to narrow it: filter=FR (France), filter=US-MA (Massachusetts), or filter=europe (a continent).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cities & Capitals</title>
<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: #16323f; }
svg { width: 100%; height: 100vh; display: block; }
circle { transition: opacity 0.1s; }
#tooltip {
position: fixed; background: #1a1a1a; color: #fafafa;
font-family: monospace; font-size: 11px; padding: 6px 10px;
pointer-events: none; opacity: 0; line-height: 1.6;
}
</style>
</head>
<body>
<div id="tooltip"></div>
<svg id="map"></svg>
<script>
const OCEAN = "#16323f", GRAT = "#264d5e", LAND = "#33505b",
LAND_STROKE = "#1b3a4b", CITY = "#6fb0cc", CAPITAL = "#edb24a", CAP_STROKE = "#fff6e5";
const width = 960, height = 500;
const tooltip = document.getElementById("tooltip");
const svg = d3.select("#map").attr("viewBox", `0 0 ${width} ${height}`);
const projection = d3.geoEqualEarth().scale(168).translate([width / 2, height / 2 + 12]);
const path = d3.geoPath(projection);
const radius = d3.scaleSqrt().domain([0, 35000000]).range([0.6, 13]);
svg.append("path").datum({ type: "Sphere" }).attr("fill", OCEAN).attr("d", path);
svg.append("path").datum(d3.geoGraticule()())
.attr("fill", "none").attr("stroke", GRAT).attr("stroke-width", 0.3).attr("d", path);
const LAND_URL = "https://api.mapjson.com/v1/geo?layer=countries&detail=low&format=geojson";
const CITY_URL = "https://api.mapjson.com/v1/points?type=city&filter=world";
Promise.all([d3.json(LAND_URL), d3.json(CITY_URL)]).then(([land, cities]) => {
svg.append("g").selectAll("path").data(land.features).join("path")
.attr("d", path).attr("fill", LAND).attr("stroke", LAND_STROKE).attr("stroke-width", 0.3);
// Biggest circles first so smaller ones stay clickable on top
const feats = cities.features.slice().sort((a, b) => (b.properties.popMetro || 0) - (a.properties.popMetro || 0));
svg.append("g").selectAll("circle").data(feats).join("circle")
.attr("cx", d => projection(d.geometry.coordinates)[0])
.attr("cy", d => projection(d.geometry.coordinates)[1])
.attr("r", d => radius(d.properties.popMetro || 0))
.attr("fill", d => d.properties.capital ? CAPITAL : CITY)
.attr("fill-opacity", 0.85)
.attr("stroke", d => d.properties.capital ? CAP_STROKE : "none")
.attr("stroke-width", d => d.properties.capital ? 0.6 : 0)
.on("mousemove", function (event, d) {
const p = d.properties;
tooltip.style.left = (event.clientX + 12) + "px";
tooltip.style.top = (event.clientY - 10) + "px";
tooltip.style.opacity = 1;
tooltip.innerHTML =
`<strong>${p.name}${p.capital ? " ★" : ""}</strong><br>` +
`${p.countryName}${p.region ? " · " + p.region : ""}<br>` +
(p.popMetro ? p.popMetro.toLocaleString() + " (metro area)" : "population n/a");
})
.on("mouseleave", () => { tooltip.style.opacity = 0; });
});
</script>
</body>
</html>