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.
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.
// 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}¤t=temperature_2m,weather_code,wind_speed_10m
// https://api.rainviewer.com/public/weather-maps.json → radar tile template
<!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}¤t=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>