I rebuilt this map four times

September 11, 2026 | Seattle 🌧️

The whole point of the Trad Directory is discovery — find a killer traditional artist near you, or near wherever you’re traveling. Search gets you there if you already know what you’re looking for. But nobody browses a directory by typing. People browse by looking at a map and going “oh, there’s a shop two miles from me.”

So I built a map. Then I built it again. Then again. I’m on the fourth version and the code still has a TEMP DEBUG block in it, so let’s be honest up front: this is the story of a feature that refused to be done.

Version one: a beautiful SVG that couldn’t zoom

The first map was react-simple-maps — SVG, d3-geo under the hood. You hand it GeoJSON, it draws countries as <path> elements, you drop markers on top. It’s genuinely lovely for a static “here are the dots” picture.

import { ComposableMap, Geographies, Geography, Marker, Annotation } from "react-simple-maps";

It’s still in the repo, actually — that exact import still powers one legacy US page. That’s the tell for how all of this went: nothing ever got a clean cutover, each version just grew next to the last one until the new one won.

The problem with SVG is zoom. Pan and zoom on a few thousand <path> nodes is jank city — you’re re-rendering the DOM on every frame. The second I wanted “click a city, fly to it,” SVG was done. You want the GPU for that.

Version two: rip it out for WebGL

So I moved the whole thing to MapLibre GL — WebGL rendering — via the react-map-gl wrapper.

"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"react-simple-maps": "^3.0.0",

Three map libraries in one package.json. Cool. Great. Anyway.

The migration immediately crashed. I had a “fly to this city” effect ported over from the old code, and it blew up every time it fired. The fix is embarrassingly small — I was calling flyTo on the ref object instead of the actual map instance living on .current:

useEffect(() => {
  if (flyToKey > 0 && flyTo && mapRef.current) {
    // Old zoom 6 ~ MapLibre zoom 6.5, old zoom 1 ~ MapLibre 1.5
    const mlZoom = flyTo.zoom * 1.1;
    mapRef.current.flyTo({
      center: flyTo.coordinates,
      zoom: mlZoom,
      duration: 1200,
      padding: getMapPadding(),
    });
  }
}, [flyToKey]);

See that flyTo.zoom * 1.1? That’s a fudge factor to translate the old d3 zoom levels into MapLibre’s zoom scale. It’s a fossil. There is no principled reason it’s 1.1 — it’s “the old numbers looked right when I multiplied them by roughly this.” The whole file is full of little seams like that where one era bled into the next.

Drill-downs, breadcrumbs, and a mobile sheet

Once it was on WebGL I could actually build the interaction I wanted: click North America, it explodes into country dots; click a country, drill into states; breadcrumbs to climb back out. Only three countries have enough data to bother with state-level drill-down, so that’s literally a hardcoded set:

const STATE_HAVING_COUNTRIES = new Set(["United States", "Canada", "Australia"]);

Mobile got a bottom sheet — the panel that slides up from the bottom with peek/half/full snap points. The one non-obvious thing there: every transform is in dvh (dynamic viewport height), not vh. On mobile Safari vh includes the space behind the address bar, so a vh-based sheet gets shoved under the browser chrome. dvh tracks the actual visible viewport. And I killed rotate and pitch entirely — nobody needs to tilt a map of tattoo shops into a 3D diorama:

dragRotate={false}
touchPitch={false}
pitchWithRotate={false}

Version three: a 3D globe, because I have no self-control

MapLibre added a globe projection, so obviously I turned it on. The whole basemap style is one flag:

projection: { type: "globe" }

Now the map opens on an actual spinning globe, eases to your region (it grabs a rough lat/lng from the edge — no permission prompt, no creepy geolocation ask), and lets you zoom down from space into a city. It’s pure eye candy and it made the whole thing feel ten times more alive.

Version four: real borders with no tile server

Here’s the one I’m actually proud of. To draw clean state and province boundaries you normally either ship a giant GeoJSON blob to the browser or stand up a tile server. I didn’t want either.

PMTiles is the trick: a single-file tileset you drop in your static folder and read with HTTP range requests. No server, no API token, no monthly bill. You register the protocol once:

// Register the PMTiles protocol once so vector sources can load `pmtiles://`
// URLs (self-hosted, token-free boundary tiles served via HTTP range requests).
const pmtilesProtocol = new Protocol();
maplibregl.addProtocol("pmtiles", pmtilesProtocol.tile);

…and then you point a vector source at a file in public/:

<Source id="us-states" type="vector" url="pmtiles:///geo/states.pmtiles" promoteId="name">
  <Layer id="states-fill" type="fill" source-layer="states" paint={statesFillPaint} />
  <Layer id="states-line" type="line" source-layer="states" paint={{ "line-color": "#ffffff", "line-width": 0.3 }} />
</Source>

That triple slash isn’t a typo — it’s site-root-relative, so it resolves to public/geo/states.pmtiles. One file, US states + Canadian provinces + Australian states, served straight off my own static host. The street basemap underneath comes from OpenFreeMap, also keyless. My entire map stack costs zero dollars a month, which for a project with an audience of mostly-me is exactly the right number.

Killing the choropleth for dots

The last big rewrite was how density gets shown. I had a choropleth — states shaded darker the more artists they had. It looked fine and it lied a little; a big empty state with a handful of artists in one city reads as “this whole state is medium-busy.”

So I swapped it for proportional symbols: one dot per city and per shop, radius growing with the count. The key is that the radius scales by sqrt — because you’re comparing the area of the circles, and area goes with the square of the radius, so a city with 4x the artists should look 2x as wide, not 4x:

const dotRadius = (prop) => [
  "interpolate", ["linear"], ["sqrt", ["max", ["get", prop], 1]],
  1, 7, 2.5, 11, 5, 16, 9, 22, 16, 30, 26, 40,
];
const dotColor = (prop) => [
  "interpolate", ["linear"], ["get", prop],
  1, "#dd9b76", 6, "#d07a4e", 18, "#c2410c", 45, "#9a3412", 120, "#7a1f1a",
];

Cities and shops are two clustered GeoJSON sources; zoom out and nearby dots merge into a cluster carrying the summed artist count. The funny leftover: half the variable names in that file are still statesFillPaint / countryFillPaint and there’s a comment about “the choropleth fill” that describes code that no longer exists. Dead words from version three, sitting right next to version four. I’ll clean it up. Probably. (Everything you’re looking at is served off /api/mapData, which counts distinct artists per city and works out how many aren’t shown at any pinned shop yet — the “unshopped” remainder — and every coordinate on the thing was geocoded through Nominatim on a polite 1.1-second throttle.)

Where it’s at

Four rewrites in and the map is genuinely good — a zoomable globe, real borders, honest density, a mobile sheet that behaves. It’s also still gated behind a modal that literally says “the map’s still a beta,” and there’s a console.log in the recenter effect I keep forgetting to delete. That’s the deal with a solo project: it ships when it’s good enough to be useful, not when it’s clean.

Go spin the globe: www.trad-directory.com/map.

What was spinning while writing:

T.L.C. (Turnstile Love Connection) by Turnstile