DEV Community

Cover image for Building a zero-backend noise pollution map with MapLibre, PMTiles, and a lot of OpenStreetMap
Jan
Jan

Posted on

Building a zero-backend noise pollution map with MapLibre, PMTiles, and a lot of OpenStreetMap

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.


Noise pollution shown around San Francisco area, including the user interface for noise.widgita.xyz


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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pull runway centerlines from Overpass (aeroway=runway), cache to deploy/cache/osm-runways.geojson.
  2. 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.
  3. Fall back to a concentric circle when an airport has no usable OSM runway geometry (there are a few).
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Several airport runway lobes


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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 relative import too, or subsequent imports still hit the cache.
  • Revalidate on every load. The cheapest fix: swap JS/CSS from max-age=604800 to no-cache, must-revalidate. ETag makes revalidation cheap — browsers send If-None-Match, server replies 304 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;
}
Enter fullscreen mode Exit fullscreen mode

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:image is 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-dem source I wired up for hillshade also supports map.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)