← Examples
pan · zoom · click-to-pick · reverse-geocode · live radar + weather

Local Weather Radar

A live map you can drag and zoom, with open-source weather radar (RainViewer) laid over the basemap. Click anywhere — or use my location — and mapjson reverse-geocodes that point: d3.geoContains finds the country, then its regions (at the right tier via detail=auto) name and highlight the exact admin area, while Open-Meteo returns the conditions there. Pan to a rainy part of the world to see the radar light up.

click the map to pick a spot
Drag to pan · scroll / pinch to zoom · click to pick a point. The highlighted region and marker are vector; the basemap and radar are tiles.

How it fits together

A standard slippy map (d3-tile + d3-zoom) renders two tile layers — a Carto basemap and RainViewer's radar — in one SVG. On any click it inverts the screen point to a lng/lat, and that's where mapjson earns its place: the world geometry reverse-geocodes the point to a country, that country's regions (loaded once, at detail=auto) find the exact admin area, and it gets outlined and highlighted. Open-Meteo fills in the current weather.

API Calls

// reverse-geocode the clicked point, then fetch that region's boundaries (mapjson):
https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,iso2&format=geojson
https://api.mapjson.com/v1/geo?layer=regions&filter={ISO2}&detail=auto&properties=name&format=geojson

// live weather + radar (open, CORS-enabled):
// https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lng}&current=temperature_2m,weather_code,wind_speed_10m
// https://api.rainviewer.com/public/weather-maps.json   → radar tile template

Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Local Weather Radar</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; background: #fafafa; color: #1a1a1a; }
    .wrap { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
    #map { border: 1px solid #e5e4df; background: #dfe6ea; border-radius: 4px; overflow: hidden; }
    #map svg { width: 100%; display: block; cursor: crosshair; }
    .row { display: flex; align-items: center; gap: 0.9rem; margin-top: 0.6rem; font-family: monospace; font-size: 13px; }
    .row b { color: #b0402f; }
    button { font-family: monospace; font-size: 11px; padding: 5px 11px; background: #fff; border: 1px solid #e5e4df; border-radius: 4px; cursor: pointer; }
    #note { font-family: monospace; font-size: 11px; color: #9b9a94; margin-top: 0.4rem; }
  </style>
</head>
<body>
  <div class="wrap">
    <div id="map"></div>
    <div class="row"><button id="locate">📍 my location</button><span id="stat">click the map to pick a spot</span></div>
    <div id="note"></div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/d3-tile@1"></script>
  <script>
  const W = 960, H = 560;
  const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${W} ${H}`);
  const tooltip = d3.select("#tooltip");
  const gBase = svg.append("g"), gRadar = svg.append("g").style("opacity", 0.6),
        gRegions = svg.append("g").attr("pointer-events", "none"), gMark = svg.append("g").attr("pointer-events", "none");

  // unit Mercator + a tile layout; the zoom transform drives both tiles and vectors (canonical d3-tile pattern)
  const unit = d3.geoMercator().scale(1 / (2 * Math.PI)).translate([0, 0]);
  const tile = d3.tile().extent([[0, 0], [W, H]]).tileSize(256).clampX(false);
  const zoom = d3.zoom().scaleExtent([1 << 10, 1 << 19]).on("zoom", zoomed);

  // RainViewer radar has no tiles past ~z7 (it serves a "Zoom Level Not Supported" placeholder),
  // so we cap the radar layer there and upscale it — the basemap/vectors still zoom in fully.
  const RADAR_MAX = 7;

  const WMO = { 0: "Clear ☀️", 1: "Mainly clear 🌤️", 2: "Partly cloudy ⛅", 3: "Overcast ☁️",
    45: "Fog 🌫️", 48: "Rime fog 🌫️", 51: "Light drizzle 🌦️", 53: "Drizzle 🌦️", 55: "Dense drizzle 🌧️",
    61: "Light rain 🌦️", 63: "Rain 🌧️", 65: "Heavy rain 🌧️", 71: "Light snow 🌨️", 73: "Snow 🌨️", 75: "Heavy snow ❄️",
    80: "Rain showers 🌦️", 81: "Showers 🌧️", 82: "Violent showers ⛈️", 95: "Thunderstorm ⛈️", 96: "Storm, hail ⛈️", 99: "Severe storm ⛈️" };
  const DEFAULT = { lng: -0.12, lat: 51.5, z: 7 };

  let radar = null, worldFC = null, regionsFC = null, currentIso = null, selected = null;
  let proj = unit, path = d3.geoPath(unit);

  const wrapX = (x, z) => { const n = 1 << z; return ((x % n) + n) % n; };
  const urlBase = (x, y, z) => `https://a.basemaps.cartocdn.com/light_all/${z}/${wrapX(x, z)}/${y}.png`;
  const urlRadar = (x, y, z) => `${radar.host}${radar.path}/256/${z}/${wrapX(x, z)}/${y}/2/1_1.png`;

  function renderTiles(g, tiles, url) {
    g.selectAll("image").data(tiles, (d) => d[2] + "/" + d[0] + "/" + d[1]).join("image")
      .attr("href", (d) => url(d[0], d[1], d[2]))
      .attr("xlink:href", (d) => url(d[0], d[1], d[2]))
      .attr("x", (d) => Math.round((d[0] + tiles.translate[0]) * tiles.scale))
      .attr("y", (d) => Math.round((d[1] + tiles.translate[1]) * tiles.scale))
      .attr("width", Math.ceil(tiles.scale)).attr("height", Math.ceil(tiles.scale))
      .attr("preserveAspectRatio", "none");
  }

  // radar tiles capped at RADAR_MAX, positioned to cover the current viewport (upscaled when zoomed past the cap)
  function radarTilesFor(t) {
    const z = Math.min(RADAR_MAX, Math.max(0, Math.round(Math.log2(t.k / 256))));
    const n = 1 << z, tileScreen = t.k / n;             // one z-tile in screen px at the current scale
    const originX = t.x - t.k / 2, originY = t.y - t.k / 2;   // screen position of world-fraction (0,0)
    const x0 = Math.floor(-originX / tileScreen), x1 = Math.ceil((W - originX) / tileScreen);
    const y0 = Math.max(0, Math.floor(-originY / tileScreen)), y1 = Math.min(n, Math.ceil((H - originY) / tileScreen));
    const tiles = [];
    for (let X = x0; X < x1; X++) for (let Y = y0; Y < y1; Y++) tiles.push([X, Y, z]);
    tiles.scale = tileScreen;
    tiles.translate = [originX / tileScreen, originY / tileScreen];
    return tiles;
  }

  function zoomed(e) {
    const t = (e && e.transform) || d3.zoomTransform(svg.node());
    if (radar) { renderTiles(gBase, tile(t), urlBase); renderTiles(gRadar, radarTilesFor(t), urlRadar); }
    proj = d3.geoMercator().scale(t.k / (2 * Math.PI)).translate([t.x, t.y]);
    path = d3.geoPath(proj);
    gRegions.selectAll("path").attr("d", path);
    if (selected) { const mp = proj(selected); gMark.attr("transform", `translate(${mp[0]},${mp[1]})`); }
  }

  svg.call(zoom);

  // click-to-pick (ignore clicks that were really a drag)
  let down = null;
  svg.on("pointerdown", (e) => { down = d3.pointer(e); });
  svg.on("click", (e) => {
    const p = d3.pointer(e);
    if (down && Math.hypot(p[0] - down[0], p[1] - down[1]) > 6) return;   // that was a pan
    const ll = proj.invert(p);
    if (ll) selectPoint(ll[0], ll[1]);
  });

  function goTo(lng, lat, z) {
    const p = unit([lng, lat]);
    svg.transition().duration(650).call(zoom.transform,
      d3.zoomIdentity.translate(W / 2, H / 2).scale(256 * (1 << z)).translate(-p[0], -p[1]));
  }

  async function selectPoint(lng, lat) {
    selected = [lng, lat];
    gMark.selectAll("*").remove();
    gMark.append("circle").attr("r", 11).attr("fill", "none").attr("stroke", "#b0402f").attr("stroke-width", 2).attr("stroke-opacity", 0.55);
    gMark.append("circle").attr("r", 4.5).attr("fill", "#b0402f").attr("stroke", "#fff").attr("stroke-width", 1.5);
    const mp = proj(selected); gMark.attr("transform", `translate(${mp[0]},${mp[1]})`);

    if (!worldFC) worldFC = await d3.json("https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&properties=name,iso2&format=geojson");
    const country = worldFC.features.find((f) => d3.geoContains(f, [lng, lat]));

    // load this country's regions once (refetch only when the country changes)
    if (country && country.properties.iso2 !== currentIso) {
      currentIso = country.properties.iso2;
      regionsFC = await d3.json(`https://api.mapjson.com/v1/geo?layer=regions&filter=${currentIso}&detail=auto&properties=name&format=geojson`);
      gRegions.selectAll("path").data(regionsFC.features).join("path")
        .attr("d", path).attr("fill", "none").attr("stroke", "#1a4a6a").attr("stroke-width", 0.8).attr("stroke-opacity", 0.5)
        .attr("vector-effect", "non-scaling-stroke");
    } else if (!country) { currentIso = null; regionsFC = null; gRegions.selectAll("*").remove(); }

    let regionName = "";
    if (regionsFC) {
      gRegions.selectAll("path").attr("fill", "none").attr("fill-opacity", null).attr("stroke-width", 0.8).attr("stroke-opacity", 0.5);
      const region = regionsFC.features.find((f) => d3.geoContains(f, [lng, lat]));
      if (region) {
        regionName = region.properties.name;
        gRegions.selectAll("path").filter((d) => d === region)
          .attr("fill", "#1a4a6a").attr("fill-opacity", 0.14).attr("stroke-width", 1.8).attr("stroke-opacity", 0.95).raise();
      }
    }

    const wx = await d3.json(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weather_code,wind_speed_10m`);
    const c = wx.current, cond = WMO[c.weather_code] || `code ${c.weather_code}`;
    const where = [regionName, country && country.properties.name].filter(Boolean).join(", ") || `${lat.toFixed(2)}, ${lng.toFixed(2)}`;
    document.getElementById("stat").innerHTML =
      `📍 <b>${where}</b> · ${Math.round(c.temperature_2m)}°C ${cond} · wind ${Math.round(c.wind_speed_10m)} km/h`;
  }

  function useMyLocation() {
    if (!navigator.geolocation) { document.getElementById("note").textContent = "geolocation not supported — click the map instead"; return; }
    navigator.geolocation.getCurrentPosition(
      (pos) => { goTo(pos.coords.longitude, pos.coords.latitude, 9); selectPoint(pos.coords.longitude, pos.coords.latitude); },
      () => { document.getElementById("note").textContent = "location blocked — open over https/localhost, or just click the map"; },
      { timeout: 8000, maximumAge: 600000 }
    );
  }
  document.getElementById("locate").addEventListener("click", useMyLocation);

  // startup: fetch the latest radar frame, frame a default view, then try to jump to the user
  d3.json("https://api.rainviewer.com/public/weather-maps.json").then((maps) => {
    const f = maps.radar.past[maps.radar.past.length - 1];
    radar = { host: maps.host, path: f.path, time: f.time };
    document.getElementById("note").textContent =
      `radar: RainViewer ${new Date(f.time * 1000).toLocaleTimeString()} · click a spot, or use my location`;
    goTo(DEFAULT.lng, DEFAULT.lat, DEFAULT.z);
    if (navigator.geolocation) navigator.geolocation.getCurrentPosition(
      (pos) => { goTo(pos.coords.longitude, pos.coords.latitude, 9); selectPoint(pos.coords.longitude, pos.coords.latitude); },
      () => {}, { timeout: 8000, maximumAge: 600000 });
  }).catch(() => { document.getElementById("stat").textContent = "could not load radar data"; });
  </script>
</body>
</html>