building ablemaps — decisions thread

this is a notes-style walk through the choices behind a little web app i built for my girlfriend that solves a very specific problem: a pediatric clinic outreach team in metro Atlanta needs to visit pediatricians’ offices, and wants the optimal driving route each day.

the problem, compressed

you have a list of clinics; you have one car; you start from the office. what’s the shortest route that hits all of them? that’s the traveling salesman problem (tsp), and it’s np-hard, but for ~15 stops an exact solution is totally doable in javascript.

why not just use google maps?

google maps directions has a 10-stop limit. apple maps has 15. if you have 40 clinics in a region, you’re manually splitting and re-ordering. plus, you want to optimize the order, not just pick the order yourself.

so you need: a way to get clinic lists into the app, a distance matrix (real driving distances, not crow-flies), a tsp solver, and a map to visualize the result.

frontend: react + vite + typescript

this is just the standard modern web stack at this point. vite 8 for dev speed, react 19, typescript 6. tailwind v4 for styling. nothing wild here — it’s the path of least resistance for a polished single-page app that needs to work on a tablet in a car.

map: leaflet, not google maps

leaflet is free. openstreetmap tiles are free. no api key needed. since we’re already using osrm for routing (also free), the whole stack stays open-source. react-leaflet bridges it into react cleanly.

the markers are custom div icons: blue “O” for the office, amber numbered circles for route stops, gray dots for unselected places. the polyline is bold blue with opacity. no frills.

routing: osrm (open-source routing machine)

osrm has a free demo server at router.project-osrm.org. you send it a list of coordinates, it returns a driving distance matrix or full route geometry. it’s graph-based, so distances are real road distances, not euclidean.

one bug we hit (documented in the devlog): the osrm table endpoint has annotations=duration and annotations=distance. annotations=duration returns durations. annotations=distance returns distances. we initially used annotations=distance thinking it returned durations — nope, it returns distances which is… not what we wanted. swapped to annotations=duration and durations came back as expected. fixed.

tsp solver: held-karp dp

for n stops, the held-karp algorithm runs in o(n²·2ⁿ). at n=15, that’s about 15² * 2¹⁵ = 225 * 32768 ≈ 7 million operations. javascript handles that in under a second. for this use case (clinic routes), you never need more than 15 stops in a day, so exact is better than approximate.

the implementation indexes 0 as the fixed origin (the office). it builds a dp table of [i][mask] = shortest path ending at i having visited set mask. then walks back through the parent pointers to reconstruct the route. clean, textbook dp.

geocoding: nominatim + localStorage cache

coordinates come pre-baked in the json import paths (from gmaps-list extraction). but if you import via google takeout csv, the csv has place ids, not coordinates. so we extract the place id from the url in the csv, then geocode via nominatim (openstreetmap’s geocoding api). rate-limited to 1 req/s, which is fine for 15-30 places. results get cached in localStorage keyed by place id so subsequent imports of the same list are instant.

originally planned a google places api as primary with nominatim as fallback, but since we don’t need an api key for nominatim and the volume is low, we just use nominatim directly. simpler.

importing clinic lists: two versions

version 1: pre-extracted json. the maps/ directory has 7 metro atlanta region files. these were extracted from google maps shared lists using gmaps-list, a python tool that reverse-engineers google’s undocumented entitylist/getlist endpoint. the json files already have lat/lng, so no geocoding needed. the app serves them as static assets from public/maps/.

version 2: dynamic google maps list parsing. paste a maps.app.goo.gl/... link and the app fetches it server-side via a vite dev middleware. the middleware ports the gmaps-list logic to typescript: follows the short link redirect, scrapes the page for the entitylist/getlist url, fetches that, parses the response. the response structure is a deeply nested array where the places are buried at data[0][8] and coordinates at data[0][8][n][1][5][2] and [3]. fragile but works.

google maps supports up to 10 waypoints in a single directions url. apple maps supports up to 15. if your optimized route has more stops than the limit, the app chunks the route into segments and generates multiple links. each segment overlaps by one stop (the last stop of segment n is the first stop of segment n+1) so you don’t lose continuity.

waze was removed because it doesn’t support multi-stop urls at all. not much to do there.

persistence: localStorage + d1

the app saves places, stops, and notes to localStorage on every change. it also fire-and-forgets to a /api/session endpoint backed by cloudflare d1. the localStorage is the source of truth locally; d1 is for potential future multi-device sync. on mount, it tries to load from d1 and uses that if available, falling back to localStorage.

the lists api (/api/lists) similarly tries d1 first and falls back to localStorage. this lets the app work completely offline — the api calls are best-effort.

mobile layout: tab bar

on desktop, it’s a sidebar (import, controls, stop list) + map side by side. on mobile, a bottom tab bar toggles between map and route views. the sidebar becomes the route tab, hidden behind hidden md:flex.

collapsible sections auto-fold

once a route is optimized, the “import places” and “route controls” sections collapse to make room for the stop list. they’re keyed on routeVersion, so each new optimization remounts them closed. on initial load (routeVersion=0), both start open. small ux detail but it keeps the flow clean.

status messages, not spinners

originally had a loading spinner. swapped for text status messages that show what step is running: “fetching distance matrix (15 waypoints)…”, “solving tsp via held-karp…”, “fetching optimized route geometry…”, “route optimized successfully” or an error message. clearer for the user, less ambiguous than a spinning circle.

the schema

three tables in d1: session (singleton row with places, stops, stop_count), saved_lists (name, url, places json), and parsed_lists_cache (url -> result, for caching gmaps-list parses). simple. no migrations framework yet — just raw sql via the schema file. drizzle orm is in the roadmap but not implemented.

what’s next

the roadmap mentions exporting notes, tablet ui polish, cloudflare pages deployment, and eventually multi-user support with shared clinic databases. but the core loop already works: import list → pick stop count → optimize → drive.

closing thought

this is a good example of when np-hard problems meet real life: you don’t need a general-purpose solver that scales to 10,000 cities. you need something that solves 15 stops exactly, runs in the browser, and doesn’t require an api key. held-karp + osrm + leaflet + a reverse-engineered google endpoint gets you there. not bad for a weekend project.

links