← Examples
countries · subregions · capitals · hover · multiple layers

Africa

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.

API Call

https://api.mapjson.com/v1/geo?layer=countries&filter=africa&detail=medium&properties=name,subregion,capital,capitalLat,capitalLng

Code

<!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>