← Examples
countries · Natural Earth · progressive detail · PNG export

Been - Visited Countries

Pick a color, then click countries — or choose them from the dropdown — to mark everywhere you've been, then download your map as a PNG. The map loads at low detail for a fast first paint; zoom in (scroll or pinch) and it upgrades to high detail, unlocking all 265 territories — right down to the micro-states you pick from the list.

Color
Add 0 marked
low detail — zoom in for all 265

Tip: click a country again with the same color to un-mark it. Scroll or pinch to zoom (drag to pan) — the map sharpens to full detail once you zoom in, open the country list, or (on a phone or tablet) first touch the map. On touch, tapping a country marks it and flashes its name. When you're done, hit save in the bottom-right of the map.

API Call

https://api.mapjson.com/v1/geo?layer=countries&detail=low&properties=name,iso2&format=geojson
https://api.mapjson.com/v1/geo?layer=countries&detail=high&properties=name,iso2&format=geojson   // loaded on zoom

Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Been - Visited Countries</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <style>
    body { font-family: sans-serif; margin: 2rem; }
    .controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
    .swatch { width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
    .swatch.active { border-color: #1a1a1a; box-shadow: 0 0 0 2px #fff inset; }
    #map-wrap { position: relative; background: #eaf0f2; max-width: 960px; cursor: grab; }
    #map-wrap svg { width: 100%; display: block; }
    #map-wrap svg g path { vector-effect: non-scaling-stroke; }
    #tooltip { position: absolute; background: #1a1a1a; color: #fff; font: 11px monospace;
               padding: 4px 8px; border-radius: 3px; pointer-events: none; opacity: 0; }
    #detail-tag { position: absolute; left: 8px; bottom: 8px; background: #1a1a1a; color: #fff;
                  font: 11px monospace; padding: 4px 9px; border-radius: 12px; }
  </style>
</head>
<body>
  <div class="controls">
    <div id="swatches" style="display:flex; gap:6px;"></div>
    <input type="color" id="custom-color" value="#2f9e6f">
    <select id="country-select"><option value="">Choose a country…</option></select>
    <button id="clear" type="button">Clear all</button>
    <button id="reset-view" type="button">Reset view</button>
    <span id="count">0 marked</span>
  </div>
  <div id="map-wrap"><div id="tooltip"></div><div id="detail-tag">low detail — zoom in for all 265</div></div>

  <script>
    const OCEAN = "#eaf0f2", LAND = "#d6d0c4", STROKE = "#ffffff";
    const PRESETS = ["#e8663d", "#e2b13a", "#4caf7d", "#3d8ee8", "#8a63d2", "#d13b52"];
    const LOW  = "https://api.mapjson.com/v1/geo?layer=countries&detail=low&properties=name,iso2&format=geojson";
    const HIGH = "https://api.mapjson.com/v1/geo?layer=countries&detail=high&properties=name,iso2&format=geojson";
    const ZOOM_TRIGGER = 2.2;   // upgrade to high detail past this zoom scale

    let currentColor = PRESETS[0];
    const state = {};           // gid -> color (survives the low→high swap)
    let active = null;          // the visible path selection (low, then high)
    let highLoaded = false, highPending = false, touchMode = false;

    const width = 960, height = 500;
    const wrap = document.getElementById("map-wrap");
    const tooltip = document.getElementById("tooltip");
    const detailTag = document.getElementById("detail-tag");
    const select = document.getElementById("country-select");

    const svg = d3.select(wrap).append("svg")
      .attr("viewBox", `0 0 ${width} ${height}`).style("width", "100%").style("height", "auto");
    svg.append("rect").attr("width", width).attr("height", height).attr("fill", OCEAN);
    const gZoom = svg.append("g");
    const gLow = gZoom.append("g");
    const gHigh = gZoom.append("g");
    const projection = d3.geoNaturalEarth1();
    const path = d3.geoPath(projection);
    const gid = (d) => d.properties.gid || d.properties.iso2;

    const zoom = d3.zoom().scaleExtent([1, 12]).on("zoom", (e) => {
      gZoom.attr("transform", e.transform);
      if (e.transform.k >= ZOOM_TRIGGER) ensureHigh();
    });
    svg.call(zoom);
    // Mobile / iPad: there's no hover, and a tap-to-mark may never zoom — so the first
    // touch of the map upgrades to full detail (and taps flash the country name).
    svg.on("touchstart.load", () => { touchMode = true; ensureHigh(); });

    d3.json(LOW).then((geo) => {
      projection.fitExtent([[6, 6], [width - 6, height - 6]], { type: "Sphere" });
      active = drawLayer(gLow, geo.features);
      fillDropdown(geo.features);
    });

    function drawLayer(layer, feats) {
      return layer.selectAll("path").data(feats).join("path")
        .attr("d", path).attr("fill", (d) => state[gid(d)] || LAND)
        .attr("stroke", STROKE).attr("stroke-width", 0.6).style("cursor", "pointer")
        .on("mouseenter", function () { d3.select(this).attr("stroke", "#555").attr("stroke-width", 1.4).raise(); })
        .on("mousemove", function (event, d) { tip(event, d); })
        .on("mouseleave", function () { d3.select(this).attr("stroke", STROKE).attr("stroke-width", 0.6); if (!touchMode) tooltip.style.opacity = 0; })
        .on("click", function (event, d) { toggle(gid(d)); tip(event, d, true); });
    }

    // On touch there's no hover, so a tap (tap=true) flashes the name and auto-hides it.
    function tip(event, d, tap) {
      const r = wrap.getBoundingClientRect();
      tooltip.style.left = (event.clientX - r.left + 12) + "px";
      tooltip.style.top = (event.clientY - r.top - 10) + "px";
      tooltip.style.opacity = 1;
      tooltip.textContent = d.properties.name || gid(d);
      if (tap && touchMode) { clearTimeout(tip.t); tip.t = setTimeout(() => { tooltip.style.opacity = 0; }, 1500); }
    }

    function ensureHigh() {
      if (highLoaded || highPending) return;
      highPending = true; detailTag.textContent = "loading full detail…";
      d3.json(HIGH).then((geo) => {
        highLoaded = true; highPending = false;
        const hs = drawLayer(gHigh, geo.features);
        gHigh.style("opacity", 0).transition().duration(600).style("opacity", 1);
        gLow.transition().duration(600).style("opacity", 0).on("end", () => gLow.remove());
        active = hs;
        fillDropdown(geo.features);
        detailTag.textContent = "full detail · 265 territories";
      }).catch(() => { highPending = false; detailTag.textContent = "detail unavailable"; });
    }

    function fillDropdown(feats) {
      const cur = select.value;
      select.innerHTML = '<option value="">Choose a country…</option>';
      feats.filter((d) => d.properties.name)
        .sort((a, b) => a.properties.name.localeCompare(b.properties.name))
        .forEach((d) => { const o = document.createElement("option"); o.value = gid(d); o.textContent = d.properties.name; select.appendChild(o); });
      select.value = cur;
    }

    function repaint(k) { if (active) active.filter((d) => gid(d) === k).attr("fill", state[k] || LAND); }
    function updateCount() { document.getElementById("count").textContent = Object.keys(state).length + " marked"; }
    function toggle(k) { if (state[k] === currentColor) delete state[k]; else state[k] = currentColor; repaint(k); updateCount(); }
    function mark(k) { state[k] = currentColor; repaint(k); updateCount(); }

    const swatches = document.getElementById("swatches");
    PRESETS.forEach((c, i) => {
      const b = document.createElement("button");
      b.type = "button"; b.className = "swatch" + (i === 0 ? " active" : "");
      b.style.background = c; b.dataset.color = c; b.title = c;
      swatches.appendChild(b);
    });
    swatches.addEventListener("click", (e) => {
      const b = e.target.closest(".swatch"); if (!b) return;
      currentColor = b.dataset.color;
      document.querySelectorAll(".swatch").forEach((s) => s.classList.remove("active"));
      b.classList.add("active");
    });
    document.getElementById("custom-color").addEventListener("input", (e) => {
      currentColor = e.target.value;
      document.querySelectorAll(".swatch").forEach((s) => s.classList.remove("active"));
    });
    // Opening the country list also upgrades to full detail, so every territory is selectable.
    select.addEventListener("focus", ensureHigh);
    select.addEventListener("change", (e) => { if (e.target.value) { mark(e.target.value); e.target.value = ""; } });
    document.getElementById("clear").addEventListener("click", () => {
      for (const k in state) delete state[k];
      if (active) active.attr("fill", LAND);
      updateCount();
    });
    document.getElementById("reset-view").addEventListener("click", () => {
      svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
    });
  </script>
</body>
</html>