A minimalist, fully offline map viewer: one Flask process serves a vendored
Protomaps basemap rendered with
MapLibre GL JS, plus swappable waypoint decks —
single-file point sets you can trade like game cartridges: drop one into
data/decks/, and it shows up in the viewer on the next page load. The
reference deck mirrors every automated license-plate reader (ALPR) known to
OpenStreetMap, via DeFlock. No tile server, no CDNs, no
API keys — the browser makes no network requests at runtime, and a strict
same-origin Content-Security-Policy makes that hold by construction, whatever
a deck contains. ("Offline" means no remote calls; local requests, including
dynamically querying local data, are fine.)
- Basemap — a single
.pmtilesvector-tile archive extracted from a dated Protomaps planet build. Flask serves it as a static file with HTTP range support, and pmtiles.js reads tile slices out of it directly in the browser — no tile server. - Decks — each deck's points load once into an in-memory GeoDataFrame
with a spatial index (lazily, on first query, re-read when the file
changes). The page fetches the current viewport's points as GeoJSON
(
/api/decks/<id>/points?bbox=…, capped atdecks.max_features) and clusters them client-side. One deck shows at a time — a radio picker swaps the layer, like changing cartridges. - Popups — clicking a point shows a card built from the point's own
properties (a
namebecomes the title; decks with surveillance tags get the DeFlock-style operator/manufacturer card), then lazily fills in the deck's per-point detail record and photo, when the deck carries them. - Offline by construction — every response carries a same-origin CSP, so
the browser itself refuses any off-origin request; attempts are logged via
/csp-report. Deck data is escaped everywhere it renders, and ids are validated before they touch a lookup. - Vendored frontend — MapLibre GL JS, pmtiles.js, the Protomaps style,
sprites, and Noto Sans glyphs all live under
static/; nothing is fetched from a CDN.
Every file under data/ (git-ignored) is a rebuildable artifact: the
filesystem is the source of truth, and each file can be deleted and
regenerated with one command.
Requires Python ≥ 3.12, uv, and the
pmtiles CLI
(brew install pmtiles).
cp config.yaml.example config.yaml # set map.center / default_zoom to your area
uv sync
bash scripts/fetch-tiles.sh # basemap — contiguous US, full detail, ~19 GB (see below)
uv run offline-maps-sync # the alpr deck — mirror DeFlock's dataset (~3 MB)
uv run offline-maps # → http://127.0.0.1:5000A deck is any entry in data/decks/, in one of three forms:
<name>.parquet / .geojson / .json / .gpx / .gpkg bare file of points
<name>/ deck directory
<name>.deck zipped deck directory
A deck directory holds points.parquet plus optional pieces; a .deck file
is an ordinary zip of the same layout (stored, not compressed — parquet and
JPEG already are), so the viewer serves straight out of the archive and any
zip tool can inspect one:
points.parquet the point set (required)
meta.parquet per-point detail records, keyed by id (optional)
deck.yaml name / description / attribution / license (optional)
photos/<id>.<ext> full-resolution images for the lightbox (optional)
thumbs/<id>.jpg popup-sized thumbnails (optional)
Every deck is normalized on load — reprojected to WGS84, filtered to Point
geometries, given a stable string id (from an id column, else osm_id,
else the row number) that keys the photos and detail records.
Trading. offline-maps-pack data/decks/alpr builds alpr.deck from a
deck directory; --photos none|thumbs|full picks the size tier (the alpr
deck: ~10 MB bare, ~60 MB with thumbnails, a few GB with full photos). Hand
the file to someone, they drop it in data/decks/, done. Before slotting in
a deck from someone else, offline-maps-verify their.deck reports on it:
structural problems (path-traversing member names, zip-bomb ratios,
unreadable data, files masquerading as images) are hard errors; URLs and
HTML-looking text in values are surfaced for eyeballing — legitimate decks
carry both as inert provenance data, and the viewer escapes everything and
the CSP blocks all off-origin requests regardless.
Decks are data; recipes are code. A deck never executes — the viewer
treats everything in it as untrusted data. To share the pipeline that
builds a deck rather than a snapshot of it, write a recipe: a single Python
file with PEP 723 inline metadata
(# /// script declaring its dependencies) that fetches its source data and
writes a deck. The recipient runs uv run their-recipe.py — deliberately,
after a skim, the same trust model as any script — and gets a fresh deck
instead of a stale one. The three offline-maps-sync* tools in this repo are
the reference pipeline, maintaining data/decks/alpr/ (config sync.deck).
scripts/fetch-tiles.sh extracts a bounding box from a dated Protomaps
planet build with pmtiles extract — HTTP range requests transfer only the
tiles inside the bbox, never the 127 GB planet. Extracts keep the build's
full detail (max zoom 15), so the bounding box (W,S,E,N) is the size knob:
# Contiguous US (default) — ~19 GB
bash scripts/fetch-tiles.sh
# The West Coast — ~2.7 GB
bash scripts/fetch-tiles.sh --bbox=-125,32.5,-114,49
# San Francisco — ~14 MB
bash scripts/fetch-tiles.sh --bbox=-122.52,37.70,-122.35,37.83If an extract is still too big, --maxzoom trims depth (--maxzoom 13 cuts
the contiguous US to ~4 GB): the viewer over-zooms past whatever the archive
holds, so a shallower extract costs only the detail that lives in deeper
tiles — buildings, most notably. Upstream keeps about a week of daily
builds; the script uses the newest live one unless you pin BUILD=YYYYMMDD.
Use --out (or OUT=…) to write somewhere else, e.g. an external disk, and
point tiles.file in config.yaml at wherever it lands.
The reference deck mirrors DeFlock's worldwide dataset of ALPRs, which
DeFlock regenerates hourly from OpenStreetMap and publishes as static JSON
region tiles. offline-maps-sync fetches every region tile (~50 files,
~15 MB) and writes the deck's points.parquet (~120k points, ~3 MB), seeding
a deck.yaml with the ODbL attribution. Each run takes a full snapshot and
atomically overwrites the file — additions and deletions both just fall out —
so it is idempotent; re-run any time to refresh.
DeFlock's tiles carry only a 9-tag whitelist and no edit history.
offline-maps-sync-osm pulls each node's complete OSM record — every tag,
plus version / timestamp / changeset / user — into the deck's sidecar
(meta.parquet). It fetches the nodes already in points.parquet by id
straight from Overpass (a direct lookup, no bbox harvest), so it stays fast
where a global tag query would time out. The run is resumable — each batch
appends to a JSONL checkpoint, so an interrupted or rate-limited run picks up
where it left off — and --limit N does a quick partial run. The viewer
reads the sidecar lazily to fill popups; everything works without it.
~1,400 nodes reference a photo (wikimedia_commons, image/photo URLs,
mapillary, panoramax) — all external links. offline-maps-sync-photos
downloads one size-capped image per node into the deck's photos/ (a few
GB) and builds the JPEG thumbnails the popups embed in thumbs/, so the
viewer serves photos from its own routes and the map stays fully offline. It
is resumable: an already-cached file is its own checkpoint, and dead links
are recorded and skipped (--retry-failed to retry them). Mapillary's ~480
photos need a free token — copy .env.example to .env, set
MAPILLARY_TOKEN, and re-run; it fills in the rest.
static/styles/basemap.json is generated, not hand-written. To regenerate it
— e.g. to switch flavors:
npm i protomaps-themes-base@4.5.0 # one-time
node scripts/build-style.mjs [light|dark|white|grayscale|black]A non-light flavor also needs its sprite sheet vendored into
static/sprites/ (and any missing fonts into static/glyphs/) from
protomaps/basemaps-assets.
scripts/
fetch-tiles.sh extract a basemap bbox from a Protomaps planet build
build-style.mjs regenerate the vendored MapLibre style JSON
src/offline_maps/
config.py module-level settings loaded from config.yaml
decks.py deck registry: scan, normalize, query, zip containers
pack.py offline-maps-pack deck directory → .deck file
verify.py offline-maps-verify inspect a received deck
sync.py offline-maps-sync DeFlock region tiles → the alpr deck
sync_osm.py offline-maps-sync-osm full OSM records → meta.parquet
sync_photos.py offline-maps-sync-photos local photo cache + thumbnails
web/
app.py Flask app + routes (entry point: offline-maps)
templates/ Jinja page
static/
vendor/ maplibre-gl.js/.css, pmtiles.js (vendored)
styles/ basemap.json (generated)
glyphs/ Noto Sans PBF ranges (vendored)
sprites/ Protomaps light sprite (vendored)
js/, css/ app code
data/ git-ignored, rebuildable artifacts
basemap.pmtiles scripts/fetch-tiles.sh
decks/ waypoint decks — anything dropped here is selectable
alpr/ the reference deck, maintained by the sync tools
- Code in this repo: MIT.
- ALPR locations and tags come from OpenStreetMap — © OpenStreetMap
contributors, ODbL — via DeFlock,
the upstream cache the sync mirrors. The deck's
deck.yamlcarries this attribution, and packing keeps it in the.deck, so it travels with the data; decks you build from other ODbL sources should do the same. - Basemap tiles are built by Protomaps from OpenStreetMap data (also ODbL).
- Cached photos are variously licensed (Commons / Mapillary / Panoramax are
CC; bare
image=URLs are unknown) — fine as a local cache; attribute if you redistribute them, including packing them into a deck you trade. - Vendored libraries and assets keep their own licenses: MapLibre GL JS (BSD-3), pmtiles.js (BSD-3), the Protomaps basemap theme (BSD-3), Noto Sans (OFL).
