Skip to content

Repository files navigation

Offline Maps

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.)

San Francisco in the viewer: clustered ALPR points over the Protomaps basemap, with a popup showing one camera's operator, manufacturer, and OSM provenance

How it works

  • Basemap — a single .pmtiles vector-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 at decks.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 name becomes 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.

Setup

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:5000

Waypoint decks

A 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).

The basemap

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.83

If 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 alpr deck

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.

Optional: full OSM metadata

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.

Optional: local photo cache

~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.

Rebuilding the style (optional)

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.

Layout

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

Licensing & attribution

  • 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.yaml carries 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).

About

Minimalist fully-offline map viewer — vendored Protomaps basemap + MapLibre GL, with DeFlock's ALPR dataset as a local GeoParquet point layer

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages