← Examples
countries · high detail · enclaves · zoom

Italy's Enclaves

Two sovereign countries live entirely inside Italy: San Marino (61 km²) and Vatican City (0.44 km² — the smallest country on earth). Both only exist in the data at detail=high. Fitted to Italy, San Marino is about five pixels and the Vatican is smaller than one — so the map zooms to 400× and the buttons fly you to each enclave. Hover any polygon for its name; markers keep constant size as you zoom. Italy's polygon carves both enclaves out with holes that match their outlines exactly, so the three responses compose seamlessly.

API Calls

https://api.mapjson.com/v1/geo?filter=Italy&detail=high&properties=name,iso2
https://api.mapjson.com/v1/geo?filter=San%20Marino&detail=high&properties=name,iso2
https://api.mapjson.com/v1/geo?filter=Vatican%20City&detail=high&properties=name,iso2

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: #7ab4cc; }
    #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>
  <button data-jump="italy">Italy</button>
  <button data-jump="sm">San Marino</button>
  <button data-jump="va">Vatican City</button>
  <div id="tooltip"></div>
  <script>
    const width = 960, height = 640;
    const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${width} ${height}`);
    const tooltip = d3.select("#tooltip");
    const projection = d3.geoMercator();
    const path = d3.geoPath(projection);

    const base = "https://api.mapjson.com/v1/geo?detail=high&properties=name,iso2&filter=";
    const FILL = { IT: "#b8c9a8", SM: "#4a7fb5", VA: "#d4a017" };   // Italy, San Marino, Vatican

    const gLand = svg.append("g");     // zoomed (CSS transform)
    const gMarks = svg.append("g");    // repositioned each frame — markers stay a fixed size

    const places = [
      { name: "San Marino",   lng: 12.4578, lat: 43.9424, color: "#4a7fb5" },
      { name: "Vatican City", lng: 12.4534, lat: 41.9033, color: "#d4a017" },
      { name: "Rome",         lng: 12.4964, lat: 41.9028, color: "#5a5a5a", small: true },
    ];
    function renderMarks(t) {
      const mk = gMarks.selectAll("g.mk").data(places);
      const ent = mk.enter().append("g").attr("class", "mk");
      ent.append("circle").attr("stroke", "#fff").attr("stroke-width", 1.2);
      ent.append("text").attr("text-anchor", "middle").attr("font-family", "monospace").attr("font-weight", 600)
        .attr("fill", "#1a1a1a").attr("stroke", "#fff").attr("stroke-width", 3).attr("paint-order", "stroke");
      const m = ent.merge(mk).attr("transform", d => {
        const p = projection([d.lng, d.lat]); return `translate(${t.applyX(p[0])},${t.applyY(p[1])})`;
      });
      m.select("circle").attr("r", d => d.small ? 2.5 : 4).attr("fill", d => d.color);
      m.select("text").attr("y", d => d.small ? -8 : -10).attr("font-size", d => d.small ? 10 : 12).text(d => d.name);
    }

    const zoom = d3.zoom().scaleExtent([1, 400]).on("zoom", (e) => {
      gLand.attr("transform", e.transform);
      renderMarks(e.transform);
    });
    svg.call(zoom);

    // fly the viewport to [lng, lat] at zoom k (k = 1 resets to all of Italy)
    function jumpTo(lng, lat, k) {
      const [x, y] = projection([lng, lat]);
      const t = k === 1 ? d3.zoomIdentity
        : d3.zoomIdentity.translate(width / 2, height / 2).scale(k).translate(-x, -y);
      svg.transition().duration(1400).call(zoom.transform, t);
    }
    const JUMPS = { italy: [12.5, 42.5, 1], sm: [12.4578, 43.9424, 60], va: [12.4534, 41.9033, 350] };
    document.querySelectorAll("[data-jump]").forEach(b =>
      b.addEventListener("click", () => jumpTo(...JUMPS[b.dataset.jump])));

    Promise.all(["Italy", "San Marino", "Vatican City"].map(f => d3.json(base + encodeURIComponent(f)))).then(topos => {
      const feats = topos.flatMap(t => topojson.feature(t, t.objects.geo).features);
      // fit the projection to Italy; the enclaves are inside it by definition
      projection.fitExtent([[24, 24], [width - 24, height - 24]],
        { type: "FeatureCollection", features: feats.filter(d => d.properties.iso2 === "IT") });

      gLand.selectAll("path").data(feats).join("path")
        .attr("d", path)
        .attr("fill", d => FILL[d.properties.iso2] || "#d9d0be")
        .attr("stroke", "#fff").attr("stroke-width", 0.6).attr("vector-effect", "non-scaling-stroke")
        .style("cursor", "pointer")
        .on("mouseover", function (e, d) {
          d3.select(this).attr("stroke", "#1a1a1a");
          tooltip.style("opacity", 1).text(d.properties.name + " (" + d.properties.iso2 + ")");
        })
        .on("mousemove", e => tooltip.style("left", (e.clientX + 12) + "px").style("top", (e.clientY - 8) + "px"))
        .on("mouseout", function () { d3.select(this).attr("stroke", "#fff"); tooltip.style("opacity", 0); });

      renderMarks(d3.zoomTransform(svg.node()));
    });
  </script>
</body>
</html>