Countries colored by subregion — Northern, Eastern, Western, Middle, and Southern Africa — in a single API call. Capital city coordinates are returned as capitalLat and capitalLng properties on each country feature and plotted directly, with a force simulation to keep labels from overlapping. Hover any country to see its name.
https://api.mapjson.com/v1/geo?layer=countries&filter=africa&detail=medium&properties=name,subregion,capital,capitalLat,capitalLng
<!DOCTYPE html>
<html>
<head>
<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>
svg { width: 100%; display: block; background: #ccdde8; }
#tooltip {
position: fixed; background: #000; color: #fff;
font-size: 12px; padding: 4px 8px; pointer-events: none; opacity: 0;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="tooltip"></div>
<script>
const width = 700, height = 860;
const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${width} ${height}`);
const tooltip = d3.select("#tooltip");
const SUBREGION_COLORS = {
"Northern Africa": "#d4a853",
"Eastern Africa": "#6aaa6a",
"Western Africa": "#c46d4a",
"Middle Africa": "#9b7ab8",
"Southern Africa": "#5a9eb8",
};
const url = "https://api.mapjson.com/v1/geo?layer=countries&filter=africa&detail=medium&properties=name,subregion,capital,capitalLat,capitalLng";
d3.json(url).then(topo => {
const countries = topojson.feature(topo, topo.objects.geo);
const projection = d3.geoMercator()
.fitExtent([[30, 20], [width - 30, height - 20]], countries);
const path = d3.geoPath().projection(projection);
// Countries colored by subregion
svg.append("g")
.selectAll("path")
.data(countries.features)
.join("path")
.attr("d", path)
.attr("fill", d => SUBREGION_COLORS[d.properties.subregion] || "#ccc")
.attr("stroke", "#fff")
.attr("stroke-width", 0.6)
.style("cursor", "pointer")
.on("mouseover", function(event, d) {
d3.select(this).attr("stroke-width", 1.5).attr("stroke", "#2c2317");
tooltip.style("opacity", 1).text(d.properties.name || "");
})
.on("mousemove", function(event) {
tooltip
.style("left", event.clientX + 12 + "px")
.style("top", event.clientY - 8 + "px");
})
.on("mouseout", function() {
d3.select(this).attr("stroke-width", 0.6).attr("stroke", "#fff");
tooltip.style("opacity", 0);
});
// Build label nodes — each holds projected anchor + estimated bounding box
// Build label nodes from capitalLat/capitalLng properties on each country feature
const charW = 6, labelH = 10;
const labelNodes = countries.features
.filter(d => d.properties.capitalLat != null)
.map(d => {
const p = d.properties;
const [ax, ay] = projection([p.capitalLng, p.capitalLat]);
const w = p.capital.length * charW;
return { x: ax + 6 + w / 2, y: ay, ax, ay, name: p.capital, w, h: labelH };
});
// Rectangular collision force — pushes overlapping labels apart
function forceRectCollide(padding) {
let nodes;
function force() {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
const ox = (a.w + b.w) / 2 + padding - Math.abs(a.x - b.x);
const oy = (a.h + b.h) / 2 + padding - Math.abs(a.y - b.y);
if (ox > 0 && oy > 0) {
const push = ox < oy ? [ox / 2, 0] : [0, oy / 2];
if (a.x < b.x) { a.x -= push[0]; b.x += push[0]; }
else { a.x += push[0]; b.x -= push[0]; }
if (a.y < b.y) { a.y -= push[1]; b.y += push[1]; }
else { a.y += push[1]; b.y -= push[1]; }
}
}
}
}
force.initialize = n => { nodes = n; };
return force;
}
// Run simulation — labels settle to non-overlapping positions
d3.forceSimulation(labelNodes)
.force("x", d3.forceX(d => d.ax + 6 + d.w / 2).strength(0.08))
.force("y", d3.forceY(d => d.ay).strength(0.08))
.force("collide", forceRectCollide(3))
.stop()
.tick(400);
// Leader lines from dot to label (only drawn when label moved noticeably)
svg.append("g")
.attr("pointer-events", "none")
.selectAll("line")
.data(labelNodes)
.join("line")
.attr("x1", d => d.ax).attr("y1", d => d.ay)
.attr("x2", d => d.x).attr("y2", d => d.y)
.attr("stroke", "#2c2317").attr("stroke-width", 0.5).attr("opacity", 0.35)
.attr("display", d => Math.hypot(d.x - d.ax - 6 - d.w / 2, d.y - d.ay) > 4 ? null : "none");
// Labels at simulated positions
svg.append("g")
.attr("pointer-events", "none")
.selectAll("text")
.data(labelNodes)
.join("text")
.attr("x", d => d.x).attr("y", d => d.y)
.attr("text-anchor", "middle").attr("dominant-baseline", "central")
.style("font-family", "monospace")
.style("font-size", "9px").style("font-weight", "bold").style("fill", "#2c2317")
.text(d => d.name);
// Capital dots on top (use the same labelNodes anchors)
svg.append("g")
.selectAll("circle")
.data(labelNodes)
.join("circle")
.attr("cx", d => d.ax).attr("cy", d => d.ay)
.attr("r", 2.5).attr("fill", "#fff").attr("stroke", "#2c2317").attr("stroke-width", 1)
.attr("pointer-events", "none");
});
</script>
</body>
</html>