← Examples
countries · shape quiz · interactive game · Natural Earth

Map Shape Guess Game

One country silhouette, north-up and centered — pick its name from four choices. The three decoys are countries of similar area (chosen with the areakm2 property), so the size gives nothing away — you have to know the outline. Both the geometry and the country names come straight from the MapJSON /v1/geo endpoint.

Loading countries…
loading…

Stuck? Hint zooms out to the neighbouring countries — or, for an island, just the nearest land around it. Skip sets one aside to come back around later. Each shape loads fast (the world comes in at a light auto tier), then sharpens to high detail for the country on screen; silhouettes are re-centered on their own longitude so they never split at the map's edge.

API Call

1 · the whole world at auto detail — a light tier, fast first paint, drives the choices + hint
https://api.mapjson.com/v1/geo?layer=countries&detail=auto&properties=name,iso2,areakm2&format=geojson

2 · then just the country on screen, at high detail — a crisp silhouette (filter={iso2})
https://api.mapjson.com/v1/geo?layer=countries&detail=high&filter=FR&properties=name,iso2,areakm2&format=geojson

The first call uses detail=auto, so the server returns the lightest world tier that still contains every country — enough to build the choices and the hint's context. Each round then fetches only the shown country at detail=high (cached by ISO2): the game starts fast, yet every silhouette sharpens to the 10 m coastline. (Using auto on a single country would go the other way — the coarsest tier it appears in, a blocky outline.)

Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Map Shape Guess</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <style>
    body { font-family: sans-serif; margin: 2rem; max-width: 820px; }
    #map-wrap { position: relative; background: #e9e5dd; border-radius: 4px; overflow: hidden; }
    #map-wrap svg { width: 100%; display: block; }
    .score { position: absolute; top: 10px; right: 10px; background: #1a1a1a; color: #fff;
             font: 13px monospace; padding: 6px 12px; border-radius: 20px; display: flex; gap: 12px; }
    .s-ok { color: #7fdca9; } .s-no { color: #f2a0a0; }
    .loading { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
               font: 13px monospace; color: #8a857c; }
    #choices { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; margin: 1rem 0; }
    .choice { font: 500 15px sans-serif; padding: 0.75rem 1rem; border: 1px solid #ddd;
              border-radius: 8px; background: #fff; cursor: pointer; text-align: left; }
    .choice.ok { background: #e6f4ec; border-color: #4caf7d; color: #256f47; }
    .choice.no { background: #f9e7e6; border-color: #d6534e; color: #a23530; }
  </style>
</head>
<body>
  <div id="bar"><span id="prompt-msg">Loading…</span> <button id="hint">Hint</button> <button id="skip">Skip →</button></div>
  <div id="map-wrap"><div class="score" id="score"></div><div class="loading" id="loading">loading…</div></div>
  <div id="choices"></div>

  <script>
    const BG = "#e9e5dd", SHAPE = "#3a5e70", SHAPE_OK = "#4caf7d", SHAPE_NO = "#d6534e", LAND_CTX = "#c9c0b0";
    const MIN_AREA_KM2 = 25000;   // drop specks too small to recognize
    const NUM_CHOICES = 4;
    const NEIGHBORS = 5;          // nearby countries the Hint frames around the target
    const width = 800, height = 480;
    const API = "https://api.mapjson.com/v1/geo?layer=countries&properties=name,iso2,areakm2&format=geojson";

    const wrap = document.getElementById("map-wrap");
    const promptEl = document.getElementById("prompt-msg");
    const scoreEl = document.getElementById("score");
    const choicesEl = document.getElementById("choices");
    const hintBtn = document.getElementById("hint");
    const loadingEl = document.getElementById("loading");

    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", BG);
    const g = svg.append("g");
    const projection = d3.geoNaturalEarth1();
    const path = d3.geoPath(projection);

    let allFeatures = [], features = [], queue = [], qi = 0, target = null, hero = null,
        locked = false, hintOn = false, lastCorrect = false, correct = 0, wrong = 0;
    const hiCache = {}, pending = {};   // high-detail geometry cached by ISO2

    // Whole world at auto (the server picks a light tier) for a fast first paint; it drives
    // the choices + hint. Each country is then re-fetched at high detail so its silhouette
    // is razor-sharp — auto on a single country would return a coarse, blocky outline.
    d3.json(API + "&detail=auto")
      .then((geo) => {
        allFeatures = geo.features.filter((d) => d.properties && d.properties.name !== "Antarctica");
        allFeatures.forEach((f) => (f._c = d3.geoCentroid(f)));   // cache centroids for the Hint
        features = allFeatures.filter((d) => {
          const p = d.properties;
          return p.iso2 && p.name && (p.areakm2 || 0) >= MIN_AREA_KM2;
        });
        queue = shuffle(features.slice());
        updateScore();
        next();
      });

    function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; const t = a[i]; a[i] = a[j]; a[j] = t; } return a; }
    function updateScore() { scoreEl.innerHTML = `<span class="s-ok">✓ ${correct}</span><span class="s-no">✗ ${wrong}</span>`; }
    function targetFill() { return locked ? (lastCorrect ? SHAPE_OK : SHAPE_NO) : SHAPE; }
    function setLoading(on) { loadingEl.style.display = on ? "flex" : "none"; }

    // Draw a set of features, re-centered on `center` longitude so nothing splits at the
    // antimeridian and fit to `fitTo`; `heroFeat` is filled dark, the rest muted context.
    function paint(list, center, fitTo, pad, heroFeat) {
      hero = heroFeat; setLoading(false);
      projection.rotate([-center, 0]).fitExtent([[pad, pad], [width - pad, height - pad]], fitTo);
      g.selectAll("path").data(list, (d) => d.properties.iso2 || d.properties.name).join("path")
        .attr("d", path)
        .attr("fill", (d) => d === hero ? targetFill() : LAND_CTX)
        .attr("stroke", BG).attr("stroke-width", (d) => d === hero ? 1.2 : 0.5)
        .attr("stroke-linejoin", "round");
    }

    // Plain mode: the silhouette alone, always at high detail. Rather than flash the coarse
    // world-tier shape first, we wait for the high fetch — the country pops in already sharp
    // (usually instantly, since next() prefetches upcoming countries into the cache).
    function drawShape() {
      const hi = hiCache[target.properties.iso2];
      if (hi) { paint([hi], hi._c[0], hi, 30, hi); }
      else { g.selectAll("path").remove(); setLoading(true); fetchHigh(target); }
    }

    // The N countries whose centroids sit closest to the target's.
    function neighbors(t, n) {
      return allFeatures
        .filter((f) => f !== t)
        .sort((a, b) => d3.geoDistance(t._c, a._c) - d3.geoDistance(t._c, b._c))
        .slice(0, n);
    }

    // Hint mode: frame the map around the target plus its nearest neighbours — so a
    // bordered country shows the ones it touches, and an island pulls back only to the
    // nearest land around it, never the whole world. All land in view is drawn for context.
    function drawHint() {
      const fit = { type: "FeatureCollection", features: [target, ...neighbors(target, NEIGHBORS)] };
      paint(allFeatures, target._c[0], fit, 26, target);
    }

    function render() { hintOn ? drawHint() : drawShape(); }

    // Fetch just this country at high detail (filter={ISO2}) and cache it, then redraw if
    // it's still the one on screen. Choices and area still come from the world set.
    function fetchHigh(t) {
      const iso = t.properties.iso2;
      if (hiCache[iso] || pending[iso]) return;
      pending[iso] = true;
      d3.json(`${API}&detail=high&filter=${iso}`).then((geo) => {
        pending[iso] = false;
        const f = (geo.features || []).find((x) => x.properties.iso2 === iso) || (geo.features || [])[0];
        if (!f) return;
        f._c = t._c;   // reuse the world-tier centroid so framing stays put
        hiCache[iso] = f;
        if (target === t && !hintOn) drawShape();
      }).catch(() => { pending[iso] = false; });
    }

    // Correct answer + 3 decoys drawn from the countries CLOSEST in area — so scale
    // is no hint. (fitExtent normalizes on-screen size anyway; this fights memory.)
    function sampleChoices(t) {
      const ta = t.properties.areakm2 || 0;
      const near = features
        .filter((f) => f.properties.iso2 !== t.properties.iso2)
        .sort((a, b) => Math.abs((a.properties.areakm2 || 0) - ta) - Math.abs((b.properties.areakm2 || 0) - ta))
        .slice(0, 40);
      shuffle(near);
      return shuffle([t, ...near.slice(0, NUM_CHOICES - 1)]);
    }

    function renderChoices(opts) {
      choicesEl.innerHTML = "";
      for (const f of opts) {
        const b = document.createElement("button");
        b.className = "choice";
        b.textContent = f.properties.name;
        b.addEventListener("click", () => guess(f, b));
        choicesEl.appendChild(b);
      }
    }

    function next() {
      if (qi >= queue.length) { shuffle(queue); qi = 0; }
      target = queue[qi++]; locked = false; hintOn = false;
      hintBtn.textContent = "Hint"; hintBtn.disabled = false;
      promptEl.textContent = "Which country has this shape?";
      render();
      renderChoices(sampleChoices(target));
      for (let i = 0; i < 3 && i < queue.length; i++) fetchHigh(queue[(qi + i) % queue.length]);  // warm the next few
    }

    function guess(f, btn) {
      if (locked) return;
      locked = true; hintBtn.disabled = true;
      lastCorrect = f.properties.iso2 === target.properties.iso2;
      const buttons = choicesEl.querySelectorAll("button");
      buttons.forEach((b) => (b.disabled = true));
      g.selectAll("path").filter((d) => d === hero).attr("fill", targetFill());
      if (lastCorrect) {
        correct++; btn.classList.add("ok");
        promptEl.textContent = `✓ Correct — ${target.properties.name}`;
        updateScore(); setTimeout(next, 900);
      } else {
        wrong++; btn.classList.add("no");
        buttons.forEach((b) => { if (b.textContent === target.properties.name) b.classList.add("ok"); });
        promptEl.textContent = `✗ You picked ${f.properties.name} — it was ${target.properties.name}`;
        updateScore(); setTimeout(next, 1600);
      }
    }

    hintBtn.addEventListener("click", () => {
      if (locked) return;
      hintOn = !hintOn;
      hintBtn.textContent = hintOn ? "Hide hint" : "Hint";
      render();
    });

    document.getElementById("skip").addEventListener("click", () => {
      if (locked) return;
      queue.push(target);  // re-ask later, no reveal
      next();
    });
  </script>
</body>
</html>