Offline Maps Server
  • Go 96.4%
  • HTML 1.8%
  • Shell 1%
  • Python 0.6%
  • Makefile 0.2%
Find a file
Sergio Rubio d5529b60de Index named isolated dwellings and farms as settlements
Searching "can biel" on a Catalonia index never returned the masia near
Llinars del Vallès that Nominatim finds: OSM maps it as a bare
place=isolated_dwelling node and pogo.Classify only accepted the eight
settlement types from locality to city, so the node was dropped at import.

place=isolated_dwelling and place=farm are now settlements with importance 1,
equal to localities. The existing importance numbers are kept because they
are stored per document: shifting them would rank sources imported before
and after this change inconsistently. The two new values are checked after
the POI families, so a farm tagged tourism=guest_house stays a guest house
and is still found by category and exact-type searches. place=square and
administrative places stay out.

Reimport every source to pick up the new places (docs/releases.md).
Liechtenstein fixture: 5417 -> 5428 features.

Both tests fail on the unfixed code: "indexed 2, want 4" and Classify
rejecting both tag values. Mutations, each caught by the named assertion:
dropping isolated_dwelling or farm from dwellingTypes fails "indexed 3,
want 4" and the matching Classify case; giving dwellings importance 5 flips
the exact-name order in TestRuralDwellingsAreSettlements/can_biel; gating
dwellings before the POI families fails /mas_agro, /guest_house and the
"POI family wins" Classify case; deleting the unnamed-place guard fails
"indexed 5, want 4"; shifting hamlet to 7 fails "hamlet keeps its
importance".

Kata: zq3p
2026-10-25 21:27:57 +00:00
assets Add Vibecoded banner to README 2026-10-01 15:58:22 +02:00
cmd/oms oms status: say why the place index is unavailable 2026-10-25 17:38:29 +00:00
docs Index named isolated dwellings and farms as settlements 2026-10-25 21:27:57 +00:00
internal Index named isolated dwellings and farms as settlements 2026-10-25 21:27:57 +00:00
pkg/pogo Index named isolated dwellings and farms as settlements 2026-10-25 21:27:57 +00:00
scripts Add scripts/refresh-pbf to re-download mirrored extracts in place 2026-10-18 19:05:33 +00:00
testdata Fetch fixtures and run integration tests with make test 2026-09-28 00:28:22 +02:00
.gitattributes Add Pogo incremental geocoding library 2026-09-27 22:49:28 +02:00
.gitignore update gitignore 2026-10-18 22:34:50 +02:00
.kata.toml administrivia 2026-09-27 15:49:25 +02:00
AGENTS.md better agents 2026-09-27 23:14:30 +02:00
go.mod Add Pogo incremental geocoding library 2026-09-27 22:49:28 +02:00
go.sum Add Pogo incremental geocoding library 2026-09-27 22:49:28 +02:00
LICENSE license 2026-09-27 12:36:35 +02:00
Makefile Fetch fixtures and run integration tests with make test 2026-09-28 00:28:22 +02:00
Nakedfile Updated Nakedfile 2026-10-01 15:07:10 +02:00
README.md Pick the demo map style with ?style= instead of a drop-down 2026-10-18 20:37:59 +00:00

oms

Vibecoded: some or all of this code was written by AI and accepted on vibes

Offline mirror and server for maplibre-gl vector maps and broom routing data. oms downloads what a mapping application needs and serves it from a single small binary, so the app renders maps and calculates routes with no internet access.

  • Maps from OpenFreeMap: style, TileJSON, sprites, glyphs, tiles for a region and the maplibre-gl client library.
  • Routing for broom: Geofabrik OSM extracts with checksums and Skadi elevation tiles, exposed exactly the way broom's provider options expect.
  • Elevation for map clients: the Terrarium terrain-RGB tile pyramid used for elevation profiles, slope colouring and hillshading.
  • Place and POI search: offline cities, towns, restaurants, fuel stations, shops, parks and other amenities, with category and nearby search.

It is built to run on low-memory, low-power boxes: objects are served straight from the filesystem with sendfile, tiles stay gzip-compressed on disk and on the wire, and the server sits at about 12 MB of RSS idle and under 25 MB under load.

Install

go install code.rbel.co/rubiojr/oms/cmd/oms@latest

Or make cross for static linux/arm64, linux/arm/7 and linux/riscv64 binaries.

Quick start

# Maps: everything for Madrid up to zoom 14, plus the client library.
oms maps all --style liberty --bbox -3.9,40.3,-3.5,40.55 --maxzoom 14

# Routing: the Madrid extract, its checksum and the elevation tiles it needs.
oms routing all --region madrid

# Serve it. --offline never touches the network.
oms serve --listen 0.0.0.0:8080 --offline

Open http://<host>:8080/ for a demo map; ?style=NAME picks one of the mirrored styles (liberty by default). In your app:

const map = new maplibregl.Map({
  container: 'map',
  style: 'http://<host>:8080/maps/styles/liberty',
});
manager, err := routing.New(routing.Options{
    Provider: routing.ProviderOptions{
        IndexURL:         "http://<host>:8080/routing/osm/index-v1.json",
        MetadataIndexURL: "http://<host>:8080/routing/osm/index-v1-nogeom.json",
        PBFBaseURL:       "http://<host>:8080/routing/osm",
        DEMBaseURL:       "http://<host>:8080/routing/skadi",
    },
})

GET /api/routing returns that provider block ready to use, and GET /api/maps returns the style URL template and the library entry point, so an app only needs the server address.

Import an existing Geofabrik PBF, then search it locally or through the server:

oms geocoding import --source madrid \
  ~/.local/share/oms/routing/osm/europe/spain/madrid-latest.osm.pbf
oms geocoding search cerce
oms geocoding search --lat 40.74 --lon -4.06 --radius 25000 pizza
curl 'http://localhost:8080/api/geocoding/search?q=cerce&limit=5'

Use the actual PBF path under your --data-dir (or --cache-dir for an on-demand download). Import reads local files and does not download anything. The Pogo index is stored in geocoding.pogo/ under --state-dir; use the same state directory for import, search and serve. Upgrading from geocoding.db requires reimporting the PBFs. The old database format is no longer used.

The index covers settlements, amenities, shops, food and drink, fuel/EV charging, parks, tourism, historic sites, healthcare, transport and more. It includes OSM nodes, ways and relations. Search by name, brand, cuisine or category: pizza, gas station, gasolinera, parks, pharmacy. Matching ignores case and Latin accents, with prefix matching on the final word. CLI search prints a JSON array; --limit defaults to 10 and accepts 1–50. Add --lat and --lon to prefer nearby POIs, and --radius (meters, up to 200000) to restrict the search area.

Rerun import after refreshing a PBF. Keep the same --source identity to replace that extract's entries; when omitted, the absolute file path is the identity. Pogo stores only the geometry that indexed places depend on, scans each snapshot in relation, way and node passes, and reuses unchanged geometry/documents. Pre-commit failures preserve the previous snapshot; a post-commit publication error is resumed with oms geocoding recover. Running servers see completed imports without restarting. Overlapping extracts share objects by OSM type and ID, with version/timestamp precedence. Inspect them with oms geocoding sources and remove one with oms geocoding remove SOURCE; empty imports are rejected. Use --decode-workers, --decode-buffer and other import controls to tune indexing, and --json to inspect incremental work counters. Imports are serialized by SQLite's writer lock; run them one at a time.

Existing settlement indexes upgrade automatically; reimport your PBFs after deploying to add POIs. The PBF reader targets Geofabrik extracts. POI imports take additional time and temporary disk space to resolve mapped areas. The retained index is a small fraction of the PBF's objects; see docs/pogo.md for sizes.

The public search endpoint works with --offline and --no-api, and uses the server's normal rate limit. Before any import it returns an empty array. See the API reference for the response format.

The demo map at / has a top-center place search box. Type at least two characters, then click a suggestion or use the arrow keys and Enter to move the map to that place. Searches use the map center to order nearby POIs; check Within 25 km of map center to limit the area. Clear removes the marker. See Place and POI search for coverage and examples, and release notes for upgrade considerations.

Layout

Everything is served under three prefixes, each a verbatim mirror of one upstream's URL tree:

Prefix Upstream Holds
/maps/ https://tiles.openfreemap.org styles/<name>, planet (TileJSON), tiles, sprites/, fonts/, lib/
/routing/osm/ https://download.geofabrik.de index-v1.json, index-v1-nogeom.json, europe/spain/madrid-latest.osm.pbf and .md5
/routing/skadi/ https://s3.amazonaws.com/elevation-tiles-prod/skadi N40/N40W004.hgt.gz one-degree elevation tiles
/elevation/terrarium/ https://s3.amazonaws.com/elevation-tiles-prod/terrarium {z}/{x}/{y}.png terrain-RGB tiles, zoom 0 to 15

The served style has every upstream URL rewritten to <public base>/maps, so sprites, glyphs and tiles all resolve locally. The Geofabrik index is served untouched because broom rewrites extract URLs itself through PBFBaseURL. The client library lives at /maps/lib/maplibre-gl.mjs, /maps/lib/maplibre-gl.css and /maps/lib/mapbox-gl-rtl-text.js (maplibre-gl 5 and older mirror the UMD maplibre-gl.js instead).

Mirroring

Maps

Command Mirrors
oms maps all one shot: all of the below
oms maps style [name...] style JSON, its TileJSON, sprites and fonts (no tiles)
oms maps sprites .json, .png and @2x variants of the style's sprite
oms maps fonts 256 glyph ranges for every text-font in the style
oms maps tiles tiles covering --bbox between --minzoom and --maxzoom for every tile source
oms maps lib maplibre-gl js/css and the RTL plugin from jsdelivr
oms maps estimate tile counts per source and zoom, --json for machines

OpenFreeMap styles: liberty, bright, positron, fiord, dark.

Flag Meaning
--style, -s NAME style name, path or upstream URL; repeatable (default liberty). maps style takes names as arguments instead
--bbox, -b minLon,minLat,maxLon,maxLat region; repeatable, world is accepted
--minzoom N, --maxzoom, -z N zoom range for tiles (default 0 to 14, capped by each source's own range)
--source ID only tiles of this style source; repeatable
--glyph-ranges RANGES all (default, 256 files per font), latin (six ranges) or a list like 0-255,256-511
--maplibre-version, --rtl-version library versions (default 6.11.2 and 0.3.0)
--max-tiles N refuse plans above this many tiles (default 2,000,000)
--yes, -y skip the confirmation asked for more than 10,000 tiles
--no-tiles, --no-lib maps all only

Routing

Command Mirrors
oms routing all one shot: index, extracts with checksums, elevation tiles
oms routing index the two Geofabrik index documents
oms routing pbf <region>-latest.osm.pbf and its .md5 for each --region
oms routing dem Skadi tiles covering the regions' extents and any --bbox
oms routing estimate resolved regions, extract sizes (from a HEAD request) and tile counts
oms routing regions [query] search the index: oms routing regions spain
Flag Meaning
--region, -r REF Geofabrik id (madrid), path (europe/spain/madrid) or extract path; repeatable
--bbox, -b … extra elevation coverage beyond the regions' extents
--no-dem routing all only
--yes, -y skip the confirmation asked above 1 GiB of extracts

Elevation tiles are one degree square and about 10 MB each; Spain needs 112 of them. Extracts are -latest files that Geofabrik rebuilds daily; rerun with --refresh to pick up a new build together with its checksum.

Elevation

Command Mirrors
oms elevation tiles Terrarium terrain tiles covering --bbox between --minzoom and --maxzoom (default 0 to 12)
oms elevation estimate tile counts per zoom and an approximate size

Terrain tiles average about 50 KB, thirty times a vector tile, and the pyramid ends at zoom 15. Prefetch the zooms a client needs for overviews and let on-demand caching fill in the rest: a tile requested through /elevation/terrarium/{z}/{x}/{y}.png that is not mirrored is fetched once and kept. GET /api/elevation returns the tile URL template for clients.

Shared by both: --refresh re-downloads existing objects, --concurrency, -c N sets parallel downloads (default 8), --quiet, -q silences progress. Everything is idempotent: rerunning skips what is present.

Directories

Flag Default Holds
--data-dir $XDG_DATA_HOME/oms (~/.local/share/oms) objects you explicitly mirrored
--cache-dir $XDG_CACHE_HOME/oms (~/.cache/oms) objects fetched on demand while serving

Both hold the same tree as the served paths, so /maps/fonts/Noto Sans Regular/0-255.pbf lives at exactly that path. Extension-less documents such as /maps/planet are stored as maps/planet/.index. oms cache clear empties the cache; oms cache promote moves cached objects into the data dir. oms status shows dirs, styles, extracts, tile counts and disk usage (--json, --no-usage to skip the walk).

Global flags and their environment variables:

Flag Env Default
--data-dir, --cache-dir OMS_DATA_DIR, OMS_CACHE_DIR XDG dirs above
--state-dir OMS_STATE_DIR $XDG_STATE_HOME/oms (~/.local/state/oms), holds accounting.db
--upstream, -u OMS_UPSTREAM https://tiles.openfreemap.org
--osm-upstream OMS_OSM_UPSTREAM https://download.geofabrik.de; empty disables routing
--dem-upstream OMS_DEM_UPSTREAM https://s3.amazonaws.com/elevation-tiles-prod/skadi
--terrarium-upstream OMS_TERRARIUM_UPSTREAM https://s3.amazonaws.com/elevation-tiles-prod/terrarium; empty disables elevation
--port OMS_PORT unset; when given, serve listens on 127.0.0.1:<port> instead of --listen, for process managers that dictate the port
--log-level OMS_LOG_LEVEL info

Serving

oms serve [--listen 127.0.0.1:8080] [--offline] [--public-url URL]
          [--no-api] [--api-token TOKEN] [--max-inflight 4] [--no-accounting]
          [--rate-limit 1200] [--fetch-budget-objects 200000]
          [--fetch-budget-bytes 21474836480] [--cache-max-bytes 42949672960]

Lookup order is data dir, cache dir, then the prefix's upstream (unless --offline). Objects fetched from upstream land in the cache dir. Upstream 404s are remembered for five minutes, identical concurrent misses are coalesced, and at most --max-inflight upstream fetches run at once.

Routing objects are large, so a cache miss under /routing/ streams the upstream body to the client while it is written to the cache, with the upstream Content-Length forwarded. The download finishes even if the client gives up, and the next request is served from disk with range support.

Behind a reverse proxy set --public-url https://maps.example.org or forward X-Forwarded-Proto and X-Forwarded-Host; they decide the base URL written into styles and reported by the API. OMS_LISTEN, OMS_PUBLIC_URL, OMS_OFFLINE and OMS_API_TOKEN set the corresponding flags.

Empty tiles are answered with 204. A comma-joined font stack such as Noto Sans Regular,Noto Sans Bold falls back to the first member that is mirrored. .hgt.gz elevation tiles are files, not a transfer encoding, and are served byte for byte.

Set GOMEMLIMIT (for example GOMEMLIMIT=48MiB) to cap the Go heap on tiny boxes.

Request and cache limits

serve enables these limits by default. Pass 0 to disable an individual limit. Byte flags take integer byte counts, not size suffixes.

Flag Default Meaning
--rate-limit 1200 requests per client IP per minute, sliding window; excess requests receive 429 with Retry-After: 60
--fetch-budget-objects 200000 completed on-demand downloads per UTC day
--fetch-budget-bytes 21474836480 (20 GiB) bytes cached by on-demand downloads per UTC day
--cache-max-bytes 42949672960 (40 GiB) stop starting on-demand downloads once the cache reaches this size

The rate limit covers cached responses and the API too; /healthz is exempt. Forwarded client IPs are trusted only from a loopback proxy such as Caddy. Clients sharing a public IP share its rate limit.

When either fetch budget or the cache threshold is reached, new cache misses receive 503 with Retry-After; mirrored and cached objects remain available within the client's rate limit. Downloads already in flight finish, so these are admission thresholds, not hard byte or object ceilings. Budgets count successful downloads, reset at midnight UTC, and are held in memory: a server restart resets usage too. Explicit CLI mirrors and API mirror jobs write to the data dir and are outside these budgets and the cache cap.

Nothing is evicted. Cache size is measured in the background at startup and hourly, with completed downloads added between measurements. oms cache clear or oms cache promote frees space; the next measurement notices the change. Budget/cache 503 responses currently advertise seconds until midnight UTC, but a full cache still needs space freed. Inspect /api/stats → limits for usage, thresholds, reset time and rejections; requests.limited and accounting's limited field count 429 and budget/cache 503 responses separately from server errors.

HTTP API

The server exposes the same information and mirror plans over HTTP: read-only status for maps and routing clients (GET /api/maps, GET /api/routing), estimates, mirror jobs (POST /api/maps/mirror, POST /api/routing/mirror, /api/jobs) and server stats (GET /api/stats: memory, disk, request and upstream counters). See docs/api.md for every endpoint, the plan schemas and the job lifecycle.

Benchmarks

make bench runs the Go microbenchmarks. scripts/loadgen replays random tiles from a data dir against a running server:

go run ./scripts/loadgen -data ~/.local/share/oms -url http://127.0.0.1:8080 -c 32 -n 50000

Measured on an x86 workstation against a Madrid mirror (456 tiles, 31 MB, zoom 0 to 14), loadgen requesting random tiles with 32 clients:

Server constraints Throughput p50 / p99 latency Peak RSS
16 cores, no limit 31.9k tiles/s, 1.97 GB/s 0.83 ms / 2.98 ms 22 MB
2 cores, GOMEMLIMIT=48MiB 32.4k tiles/s, 2.01 GB/s 0.84 ms / 3.18 ms 19 MB
1 core (taskset), GOMEMLIMIT=32MiB 22.8k tiles/s, 1.43 GB/s 1.39 ms / 3.18 ms 18 MB

Single-URL ab -k -c 32 on the same box: 22.5k req/s for a 100 KB gzip tile, 60k req/s for the rewritten style document, 155k req/s for offline 404s. In the handler a gzip tile hit costs about 6 µs and 3.6 KB of allocations; inflating a tile for a client without gzip support costs 25 µs and 80 KB. A Raspberry-class ARM board will be slower, but the work per request is tiny and the memory ceiling holds.

Deployment

oms --port <port> serve --public-url https://<domain> is the shape a process manager wants: bind loopback on the port it hands out and let the reverse proxy terminate TLS. A naked deploy needs nothing else; see scripts/deploy (untracked, it carries host and token) for the one used for oms.rbel.co.

Geofabrik republishes extracts daily. scripts/refresh-pbf re-downloads the extracts an instance already mirrors over ssh on the naked host (NAKED_HOST and NAKED_DOMAIN from the environment or naked vars), or locally with --local. Each extract is replaced atomically, so the server never serves a partial file and a failed download keeps the old one; extracts whose upstream .md5 is unchanged are skipped unless --force is given, and an optional pattern such as madrid limits the run. Reimport geocoding afterwards.

That deployment explicitly sets the same limits as the CLI defaults: 1,200 requests/client/minute, 200,000 objects and 20 GiB fetched per UTC day, and a 40 GiB cache threshold.

Agent skill

oms install-skill writes an embedded agent skill to ~/.agents/skills/oms-user/ (--print to stdout, --dest DIR, --force to overwrite local edits) so an AI agent can operate oms, its API and a deployed instance without network access. The bundled reference/api.md is a copy of docs/api.md; go generate ./internal/skill refreshes it.

Development

Install Go 1.27.1, Git LFS, Make and a C compiler for the race detector.

make test     # fetch PBF fixtures, then run all tests once with race detection and coverage
make lint     # gofmt, vet, staticcheck
make vuln     # govulncheck

make test downloads missing Git LFS PBF fixtures and includes the real-country integration tests. Fixture-fetch errors stop the run before testing. The country imports take several minutes under the race detector; the default timeout is 15 minutes per package. Use make test TEST_TIMEOUT=30m on slower machines. make test-fixtures fetches just the required PBFs without running tests. Style and routing JSON fixtures are already in Git; synthetic fixtures are generated by the tests. Benchmark source archives are not needed by this target.

License

MIT, see LICENSE.