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