Real OpenStreetMap streets and terrain as the basemap — but the whole world is dimmed except one country, and that country's administrative regions are drawn on top with names on hover. The dark veil is a single vector polygon covering the world with a country-shaped hole cut through it; the region layer is vector geometry keyed to the tiles, both from mapjson. Switch countries below; the region tier is picked automatically with detail=auto.
A raster basemap is pixels: it has no idea a country or a region is there, so you can't outline one, cut a hole for one, shade one by data, or know which one the cursor is over. This example uses tiles for the real-world picture and mapjson geometry for everything that carries meaning — the mask (a world rectangle with the country punched out via the even-odd fill rule), the administrative boundaries that OSM tiles don't draw consistently, and the hover identity that lets each region report its name. Because most countries' regions only exist at high detail (Poland's 16 voivodeships) while a few large federations are coarser (US at low), the region call uses detail=auto and lets the API serve the right tier.
https://api.mapjson.com/v1/geo?layer=countries&filter={ISO2}&detail=high&properties=name&format=geojson
https://api.mapjson.com/v1/geo?layer=regions&filter={ISO2}&detail=auto&format=geojson
// {ISO2} = PL · FR · IT · JP · DE · ES · GB · BR · MX · KE
// detail=auto → the API serves each country's regions at the right tier (PL→high, US→low)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
body { margin: 16px; font: 14px system-ui, sans-serif; }
svg { width: 100%; display: block; background: #0b0f14; border-radius: 4px; }
select { font: 12px monospace; padding: 4px 8px; }
#served { font: 12px monospace; color: #b0402f; }
#tooltip { position: fixed; background: #1a1a1a; color: #fff; font: 11px monospace; padding: 4px 8px; pointer-events: none; opacity: 0; border-radius: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<p>country <select id="country"></select> <span id="served"></span></p>
<div id="tooltip"></div>
<script>
const W = 960, H = 600, PAD = 8;
const API = "https://api.mapjson.com/v1/geo";
const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${W} ${H}`);
const tooltip = d3.select("#tooltip");
const gTiles = svg.append("g"); // OSM raster tiles (back)
const gMask = svg.append("g"); // dark veil with a country-shaped hole
const gOutline = svg.append("g"); // bright country outline
const gRegions = svg.append("g"); // admin1 regions (front, interactive)
let projection = d3.geoMercator();
let path = d3.geoPath(projection);
let seq = 0; // guards against a slow load being overtaken by a newer country selection
// OSM tiles placed as SVG <image> so they live in the same SVG as the vectors (and export together).
function drawTiles() {
gTiles.selectAll("*").remove();
const z = Math.max(0, Math.min(18, Math.round(Math.log2(projection.scale() * 2 * Math.PI / 256))));
const n = 1 << z, size = projection.scale() * 2 * Math.PI / n;
const c0 = projection.invert([0, 0]), c1 = projection.invert([W, H]);
const xT = (lng) => (lng + 180) / 360 * n;
const yT = (lat) => (1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * n;
for (let x = Math.floor(xT(Math.min(c0[0], c1[0]))); x <= Math.floor(xT(Math.max(c0[0], c1[0]))); x++)
for (let y = Math.max(0, Math.floor(yT(Math.max(c0[1], c1[1])))); y <= Math.min(n - 1, Math.floor(yT(Math.min(c0[1], c1[1])))); y++) {
const lng = x / n * 360 - 180, lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
const p = projection([lng, lat]);
gTiles.append("image")
.attr("href", `https://tile.openstreetmap.org/${z}/${((x % n) + n) % n}/${y}.png`)
.attr("x", p[0]).attr("y", p[1]).attr("width", size + 0.6).attr("height", size + 0.6)
.attr("preserveAspectRatio", "none");
}
}
async function fetchGeo(params) {
const res = await fetch(`${API}?${params}&format=geojson`);
return { fc: await res.json(), served: res.headers.get("X-Detail-Served") };
}
async function load(iso) {
const s = ++seq;
gRegions.selectAll("*").remove(); // drop the old country's regions right away (no stale overlay)
document.getElementById("served").textContent = "loading…";
// 1 — country boundary (high, so its edge aligns with the high-detail regions)
const country = await fetchGeo(`layer=countries&filter=${iso}&detail=high&properties=name`);
if (s !== seq) return; // a newer country selection superseded this load
projection.fitExtent([[PAD, PAD], [W - PAD, H - PAD]], country.fc);
path = d3.geoPath(projection);
drawTiles();
// 2 — the spotlight: a full-viewport rect with the country cut out (even-odd fill rule)
gMask.selectAll("*").remove();
gMask.append("path")
.attr("d", `M0,0H${W}V${H}H0Z` + path(country.fc))
.attr("fill", "#05070a").attr("fill-opacity", 0.62).attr("fill-rule", "evenodd");
gOutline.selectAll("path").data(country.fc.features).join("path")
.attr("d", path).attr("fill", "none").attr("stroke", "#fff").attr("stroke-width", 1.5).attr("stroke-opacity", 0.9);
// 3 — administrative regions from mapjson (detail=auto → the right tier), interactive
const regions = await fetchGeo(`layer=regions&filter=${iso}&detail=auto`);
if (s !== seq) return;
gRegions.selectAll("path").data(regions.fc.features).join("path")
.attr("d", path).attr("fill", "#ffd479").attr("fill-opacity", 0.05)
.attr("stroke", "#ffd479").attr("stroke-width", 0.8).attr("stroke-opacity", 0.85)
.style("cursor", "pointer")
.on("mouseover", function (e, d) {
d3.select(this).attr("fill-opacity", 0.22).raise();
tooltip.style("opacity", 1).text((d.properties && d.properties.name) || "region");
})
.on("mousemove", (e) => tooltip.style("left", (e.clientX + 12) + "px").style("top", (e.clientY - 8) + "px"))
.on("mouseout", function () { d3.select(this).attr("fill-opacity", 0.05); tooltip.style("opacity", 0); });
document.getElementById("served").innerHTML =
`<b>${regions.fc.features.length}</b> regions · detail=auto → <b>${regions.served || "?"}</b>`;
}
const SEL = document.getElementById("country");
[["PL", "Poland"], ["FR", "France"], ["IT", "Italy"], ["JP", "Japan"], ["DE", "Germany"],
["ES", "Spain"], ["GB", "United Kingdom"], ["BR", "Brazil"], ["MX", "Mexico"], ["KE", "Kenya"]]
.forEach(([iso, name]) => { const o = document.createElement("option"); o.value = iso; o.textContent = name; SEL.appendChild(o); });
SEL.addEventListener("change", () => load(SEL.value));
load("PL");
</script>
</body>
</html>