A self-hosted replacement for Google Maps that views the street map and computes driving directions, using only free/public OpenStreetMap data and doing all the calculation itself. Scoped to a radius around a center point, both configurable (defaults to a 15-mile radius around Madison, WI).
No Google, no Mapbox account, no external tile servers. The only required network access is a one-time OSM data download; live traffic (optional, see below) is the only other outbound call.
A single-file Dash app (app.py) you run with one command:
uv run app.py # then open http://localhost:8051 (set PORT to change)Requires uv. No manual install or venv — uv
reads pyproject.toml and builds the environment on the first run.
Click once to drop a start point, click again for a destination; the route and
turn-by-turn directions appear in the sidebar. On first run it loads the street
graph from drive_network.graphml (next to the script, or under data/) if
present, otherwise downloads it from OSM once (~1 min).
- Data —
network_type="drive"graph from OSM (one-way streets respected), loaded once at startup. - Tiles — the map basemap is drawn to raster PNG tiles (Pillow) on the fly
and served from a Flask route, displayed with
dash-leaflet. No pre-baked tiles or external tile server. - Routing — a hand-written A* with a haversine heuristic over travel time, not a library shortest-path call.
- Directions — derived by walking the path edges, grouping by street name, and classifying each heading change into a turn.
Set the LAT, LON, and RADIUS_MILES env vars (or edit their defaults
near the top of app.py) and delete the saved drive_network.graphml
so it re-downloads for the new area — otherwise the old area's graph gets
reused regardless of the new settings. A larger radius means a bigger graph
and slower tile generation.
LAT=39.7392 LON=-104.9903 RADIUS_MILES=10 uv run app.py # e.g. Denver, CO- Tiles show roads only (no water, parks, or buildings).
- No text labels (street names on the map) — that needs a glyph/font source. Directions already use the real street names.
- No address search (geocoding). Points are set by clicking. A local geocoder could be built from OSM address data.
The app can pull real, live traffic data from TomTom's free-tier Traffic Flow Vector Tiles API (no credit card required) and use it two ways:
- Visual overlay — major roads are colored by current congestion (green → amber → red).
- Routing — A* and the reported ETA use live speeds where we have a fresh reading, instead of always assuming free-flow conditions.
We looked at government sources first — WisDOT's 511 API only publishes
incidents/closures, not live speeds, and WisDOT's real-time sensor feed
exists but requires an access request to their traffic operations center
rather than self-service signup. TomTom's Vector Flow Tiles turned out to be
the best fit even among commercial options: unlike a per-point speed API
(which would need one HTTP call per road segment), one vector tile call
returns real MVT data — decoded with the same mapbox_vector_tile library
our own tiles use — covering hundreds of segments with a traffic_level
property (current speed as a fraction of free-flow) per segment. We
spatially match those segments to our own OSM graph edges ourselves (TomTom's
segment IDs don't correspond to OSM's); TomTom's tile images themselves are
never used or displayed.
This is entirely optional and fails soft everywhere: with no key configured, or if TomTom is unreachable or rate-limited, affected edges are just skipped — routing falls back to static OSM speeds and tiles don't color that road — so the rest of the app works exactly as it does today.
To enable it:
-
Sign up for a free API key at developer.tomtom.com (no cost, no card).
-
Set it as an environment variable, or drop it in a
.envfile at the repo root (TOMTOM_API_KEY=your_key_here), before starting the app:export TOMTOM_API_KEY=your_key_here uv run app.py
When set, a "Live traffic" toggle appears. Traffic tile data is cached ~90s server-side (the browser never talks to TomTom directly, and the key never reaches the client), and a route request only fetches the tiles covering the trip — a handful for a typical route, capped at ~150 nearest the start→end corridor for the longest cross-area trips — so even modest use stays well within the free tier's 200,000-request/month quota.
The app runs on Plotly Cloud as-is: plotly app publish (the Plotly Cloud
CLI, pip install "dash[cloud]") uploads the files and the platform builds
and serves the app. plotly-cloud.toml pins which cloud app gets updated on
re-publish, and the one-line Procfile asks the host for threaded workers.
For any other Python host, serve app:server the same way (gunicorn lives
in the deploy dependency group):
uv run --group deploy gunicorn app:server --workers 2 --threads 8 --timeout 120Threaded workers matter: traffic tiles can block on a live TomTom fetch, and
a single pan/zoom requests many tiles at once — with single-threaded workers
those queue up and tiles load noticeably one by one. Set TOMTOM_API_KEY as
an environment variable on the host — don't commit .env.