I just shipped noise.widgita.xyz - a browser-only noise pollution map covering roughly the entire OpenStreetMap-covered world. Click anywhere, get a 1-10 score broken down by road, rail, aviation, and industry. No accounts, no backend, no tracking.
The stack is deliberately small: nginx → static files → MapLibre GL → PMTiles. Everything interesting happens in the browser. This post walks through four design decisions I enjoyed making, and two gotchas that cost me real time.
The architecture, in one breath
nginx ──► index.html + assets/*.js
│
├─ MapLibre GL JS (renders the map + overlays)
├─ pmtiles protocol (vector tiles via HTTP Range)
├─ assets/noise-probe.js (samples PMTiles at a coordinate)
├─ assets/noise-score.js (log-sum-exp dB-style mixing)
└─ assets/layers.js (paint layers + halo smoothing)
The tiles are ~600 MB of OSM-derived noise PMTiles, ~46 MB of OSM rail-crossing dots, and ~6 MB of airport contour GeoJSON. Nginx serves them with Accept-Ranges: bytes, MapLibre streams the relevant ranges, and the whole thing feels like a regular web app.
Tricky bits live above the tiles. That's where this post is actually going.
1. Sampling a vector tile, in the browser, to produce a score
MapLibre will happily render a PMTiles vector layer, but it won't tell you "what's the noise level at 37.49118, -122.24514". For that I need to decode the tile containing the point, walk features, and read their noise_level property.
The script is ~150 lines. The core shape:
// assets/noise-probe.js (abridged)
import { PMTiles } from "pmtiles";
import { VectorTile } from "@mapbox/vector-tile";
import Pbf from "pbf";
const tiles = new PMTiles(NOISE_PMTILES_URL);
export async function sampleNoiseAt(lng, lat) {
const z = 10; // our max zoom; road/rail/industry all live here
const { x, y } = lngLatToTile(lng, lat, z);
const arr = await tiles.getZxy(z, x, y);
if (!arr) return { road: 0, rail: 0, industry: 0 };
const vt = new VectorTile(new Pbf(arr.data));
const px = lngLatToTilePx(lng, lat, z, x, y);
const score = {};
for (const layer of ["noise_road", "noise_rail", "noise_industrial"]) {
const l = vt.layers[layer];
if (!l) continue;
for (let i = 0; i < l.length; i++) {
const f = l.feature(i);
if (pointInFeature(px, f)) {
score[layer] = Math.max(score[layer] ?? 0, +f.properties.noise_level);
}
}
}
return score;
}
That's the whole probe. PMTiles' getZxy() does the HTTP Range dance; VectorTile decodes MVT; a classic point-in-polygon visits only the features in the one tile that contains the point. A full score at any coordinate on the planet is one Range request and a few hundred microseconds of math.
2. Mixing four noisy sources on a dB-style scale
The first scoring attempt was simple: overall = max(road, rail, aviation, industry). It tested fine on quiet blocks, then misbehaved in any dense city: a street with a train a block away got the same score as a street with only the train, which nobody who lives near two sources of noise will agree with.
Noise doesn't work like that. Sound pressure levels add logarithmically — two equal sources aren't 2× as loud, they're +3 dB. So I stopped averaging and started log-sum-exp'ing:
// Combine k sources that each carry a [0..10] score.
// Higher numbers = louder, ~linearly in perceived loudness.
// Treat them as if they were dB values and mix them back through 10 · log10(Σ 10^(x/10)).
export function combineScoresDb(values) {
const sum = values.reduce((s, v) => s + Math.pow(10, v / 10), 0);
return Math.min(10, 10 * Math.log10(sum));
}
Two 7s become an 8.5. Two 3s stay at ~3.3. A 9 next to a 2 stays at ~9. That matches how a resident actually experiences the block, and it falls out of two lines of math.
3. Soft edges — visually and numerically
The OSM-derived noise data is a stack of buffer polygons: inside = noisy, outside = silent. Rendered naïvely, the map gets hard cliffs where an orange road-noise blob meets a dark background, and the scorer reports "8" one meter from the edge and "0" one meter past it. Neither matches reality.
Two matching fixes, one numeric, one visual:
Numeric — signed-distance decay. Rather than a 0/1 step at the boundary, the score tapers from level at the polygon boundary down to 0 over SOFT_OUTER_M meters past the edge.
// assets/scoring-common.js
export function decayLevelScoreSigned(level, signedDistanceM) {
if (signedDistanceM <= 0) return level; // inside
if (signedDistanceM >= SOFT_OUTER_M) return 0; // past the falloff
const t = signedDistanceM / SOFT_OUTER_M;
return level * (1 - t * t); // smooth-ish shoulder
}
Visual — line halos. I paint a blurred line layer along each polygon's boundary, with line-blur tuned to roughly 60% of line-width and both scaled with zoom. The halo sits beneath the opaque fill, so inside the polygon you see the fill; outside, you see the feathered glow.
// assets/layers.js
const HALO_WIDTH = ["interpolate", ["exponential", 1.6], ["zoom"],
8, 1, 11, 3, 14, 10, 16, 22, 18, 36];
const HALO_BLUR = ["interpolate", ["exponential", 1.6], ["zoom"],
8, 1, 11, 2, 14, 6, 16, 14, 18, 22];
On systems with prefers-reduced-transparency: reduce, I skip halo layers entirely. Matching numbers and pixels was the whole point: the score you read off the location panel is the same falloff you see around the fills.
4. Aviation noise without real flight tracks
Concentric circles around each airport are the textbook "aviation noise" approximation. They're also wrong in the one way that matters: flights are not radially symmetric around the airport reference point — they fly down the runway axis. A block directly off the end of SFO's 28R is nothing like a block the same distance perpendicular to the runway.
Without real flight tracks, the next-best thing is to shape the contour like the runways themselves. That's a few-hundred-line pipeline:
-
Pull runway centerlines from Overpass (
aeroway=runway), cache todeploy/cache/osm-runways.geojson. - For each runway, generate a "lobe" polygon — an elongated teardrop aligned with the runway heading, long along the approach/departure path, narrow abeam the runway. A parametric ring does this in closed form.
- Fall back to a concentric circle when an airport has no usable OSM runway geometry (there are a few).
- Emit a single GeoJSON FeatureCollection with one feature per Lden band per airport.
// deploy/build-airport-contours.mjs (abridged)
function buildLobeRing(center, headingDeg, longM, wideM) {
const pts = [];
const N = 96;
for (let i = 0; i < N; i++) {
const t = (i / N) * 2 * Math.PI;
// Asymmetric lobe: long along the heading, narrow perpendicular.
const r = Math.hypot(Math.cos(t) * longM, Math.sin(t) * wideM);
const localBearing = (Math.atan2(Math.sin(t), Math.cos(t)) * 180) / Math.PI;
pts.push(offsetMtoLngLat(center, localBearing + headingDeg, r));
}
pts.push(pts[0]);
return pts;
}
Result: SJC's lobe points along 30L/30R, SFO's points along 28L/28R, LAX points along 25R/25L. A Playwright regression asserts that an on-axis probe strictly outscores a perpendicular probe at the same distance from the airport reference point — a check no circle-based model could pass.
Gotcha #1: map.setStyle() with an inline spec fires style.load synchronously
Here's the bug that cost me an evening. My base-map picker swaps between MapLibre's OpenFreeMap style (URL) and an Esri World Imagery style (inline JSON object) via map.setStyle(). I wired it up like this:
export function swapBasemap(map, basemap, themeMode, onReady) {
const spec = styleSpecForBasemap(basemap, themeMode);
map.setStyle(spec); // ← wait for it
map.once("style.load", () => onReady?.()); // ← fires too late
}
For OpenFreeMap (URL) this works perfectly: setStyle kicks off a fetch, returns; once subscribes; fetch resolves; style.load fires; our callback runs; noise layers get re-added.
For the inline Esri spec, setStyle parses synchronously inside the call, fires style.load before it returns, and once() subscribes to an event that has already happened. The callback never runs. Our noise layers never come back. Switching to satellite gave you a beautiful Esri basemap with no noise data on it.
The fix is a one-line swap — subscribe first, then call setStyle:
export function swapBasemap(map, basemap, themeMode, onReady) {
const spec = styleSpecForBasemap(basemap, themeMode);
map.once("style.load", () => onReady?.()); // subscribe first
map.setStyle(spec); // now it's safe either way
}
Lesson: when an event might fire synchronously inside the call that triggers it (and it's not obvious from the API docs whether it will), always subscribe first.
Gotcha #2: versionless + long cache = broken-for-returning-users
The nginx config shipped index.html with no-cache, but JS and CSS had a 7-day public, max-age=604800. When I released the new base-map picker, every returning user loaded a fresh index.html referencing /assets/app.js, and their browser dutifully served the old, still-valid app.js out of cache. The old app didn't know about the new picker, so the new HTML's new sidebar section rendered, the script was loaded — just the wrong version — and the click handlers silently did nothing.
There are a few common fixes:
- Rename assets on every build (
app.a7b3c.js). Best cache story, more build complexity. - Append
?v=<hash>to every import. Works, but ES modules make this fiddly — you have to version every relativeimporttoo, or subsequent imports still hit the cache. - Revalidate on every load. The cheapest fix: swap JS/CSS from
max-age=604800tono-cache, must-revalidate. ETag makes revalidation cheap — browsers sendIf-None-Match, server replies304 Not Modified, zero body transfer, zero perceptible latency.
I went with the last one because there is no build step and "every page load round-trips a few ETags" is fine for a low-traffic static site. Binary assets (fonts, PNGs, SVGs, GeoJSON) keep the 7-day cache.
# Versionless JS / CSS: must revalidate every load.
location ~* \.(?:css|js|mjs)$ {
add_header Cache-Control "no-cache, must-revalidate" always;
}
location ~* \.(?:woff2?|svg|png|ico|geojson)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
}
Lesson: the day you ship your first versionless JS/CSS file, decide on a cache-busting strategy. "I'll fix it later" means the first release that actually changes behavior appears broken to half your returning users.
What I'd do differently
-
Per-URL social previews. The current
og:imageis one static Times Square card for the whole site. A Cloudflare Worker that decodes the gzipped URL-hash state and snapshots the map at that view would give every shared link a tailored preview. Pleasant weekend project. -
3D terrain. The
terrarium-demsource I wired up for hillshade also supportsmap.setTerrain({ source: "terrarium-dem" }). Disabled by default (it raises render cost noticeably), but one toggle away. - OpenSky track-density heatmap. Runway lobes are a decent proxy, but real-track densities from OpenSky would turn the aviation layer from "approximation" into "data." Deferred until I know what the API credits look like.
Try it
noise.widgita.xyz — click anywhere, share the URL, drop the basemap to satellite, drag the noise sources around. The whole thing runs in your browser; the only network calls go to nginx for tile bytes and to OpenStreetMap's Nominatim when you actually type a search.
If you've built something similar and mixed noise sources differently, or if you've found a cleaner cache-bust story for static sites, I'd love to hear it in the comments.


Top comments (0)