Navigator
A custom web application that serves OpenStreetMap (OSM) data. It has a build process that:
- Pulls OSM data from the Overpass API for configurable geographic areas and feature types.
- Merges hand-authored custom layers — zones (polygons) and points of
interest — from
scripts/custom-layers.mjs. - Converts everything to static GeoJSON files.
- Builds a SvelteKit frontend (with the static adapter) into a plain static site that renders each data source as a toggleable layer on an interactive Leaflet map.
The final output in build/ is a fully self-contained static site — no server
runtime required. You can serve it with any static HTTP server (nginx, Apache,
python -m http.server, S3/Cloudflare Pages, etc.).
Built With
- SvelteKit (Svelte 5, runes mode)
- @sveltejs/adapter-static — outputs a static site
- Leaflet — interactive map on the frontend
- Overpass API — OSM data source
Quick Start
npm install
# Fetch & convert OSM data into static/data/*.geojson
npm run build:data
# Full production build: (fetch data) + (static site) -> build/
npm run build
# Preview the production build locally
npm run preview
Development
npm run dev # Vite dev server (your data in static/data is served too)
npm run check # Type + Svelte checks
Build Process
| Command | What it does |
|---|---|
npm run build:data |
Overpass API fetch (area POIs/roads) via scripts/osm-data.mjs → static/data/ |
npm run build:dump |
OSM data-dump ingestion + routing graph via scripts/osm-dump.mjs |
npm run build |
build:data + build:dump + SvelteKit static build → build/ |
npm run preview |
Serves the build/ output locally |
Data layout
static/data/
├── _index.json # Manifest of all built layers (drives the UI)
├── <area>.geojson # One FeatureCollection per Overpass area
└── custom/
└── <layer>.geojson # One FeatureCollection per custom zone/POI layer
The frontend loads /data/_index.json, then fetches each layer's GeoJSON and
renders it as a toggleable overlay with a legend (top-right). Every layer can be
shown/hidden independently. By default no layers are shown on load — toggle the
ones you want from the legend.
OSM Data-Dump Ingestion (osm-dump.mjs)
As an alternative to the live Overpass API, you can ingest a regional
.osm.pbf data dump (the files Geofabrik, BBBike, etc. publish). This is
great for offline builds, large areas, and for building a routable road
graph (the Overpass path only fetches features).
scripts/osm-dump.mjs:
- Downloads a configured
.osm.pbfURL (cached underscripts/.cache/, so repeated runs skip the network). - Stream-parses it with
osm-pbf-parser. - Clips to a bounding box and extracts:
- POI features (from
poiTags) as points →static/data/<id>-poi.geojson - A routable road graph (
coords+ weightedadj) from road ways →static/data/<id>-graph.json
- POI features (from
- Registers a
category: 'dump'layer in the manifest withgraphPath.
Configure sources in the DUMPS array at the top of scripts/osm-dump.mjs
(url, bbox, poiTags, graphHighways). The default source is Geofabrik's
Oklahoma extract clipped to the Tulsa bbox.
npm run build:dump # build all dump sources
node scripts/osm-dump.mjs --list # list configured sources
The downloaded
.pbflives inscripts/.cache/(git-ignored); only the generatedstatic/data/*.geojsonand-graph.jsonoutputs are committed.
Configuring Areas & Queries
Edit scripts/osm-data.mjs — the AREAS object describes each area:
bbox—[south, west, north, east]in decimal degrees.queries— a map of name → Overpass QL snippet. Use{{bbox}}as the placeholder for the area's bounding box.- The script falls back across multiple public Overpass mirrors if one is overloaded, and is polite to the API (short delay between queries).
Example:
parks: `
way["leisure"="park"]({{bbox}});
out center tags geom;
`
By default the tulsa area also fetches major roads (highway =
motorway/trunk/primary/secondary/tertiary) as LineStrings, so the map shows
road network geometry alongside the POI/zones. (Tiny local roads like
residential/service are excluded to keep the output file size reasonable.)
Adjust the roads query in scripts/osm-data.mjs to include or exclude road
classes.
Note: the Overpass API is sometimes rate-limited; npm run build:data may
need to be re-run if a query returns 5xx. The committed static/data/ already
contains a full road dataset.
List configured queries with npm run data -- --queries, or build a single area
with npm run data -- --area=tulsa.
Custom Layers (Zones, Points of Interest & Paths)
Besides Overpass-fetched data, you can define your own layers by hand in
scripts/custom-layers.mjs. It ships with several example layers
(my-places POIs, districts zones, trails path, and rogers-county local
POIs) that you can extend or replace.
Each layer has:
id— unique slug (becomes the output filename)name— label shown in the UI legendlayerType—'poi'(point),'zone'(polygon), or'path'(LineString)color— hex color used for markers/fill/linesfeatures— array of feature objects
POI (point) — optional address, bearing, and fov fields:
{
name: 'Gathering Place',
desc: 'Riverside park',
address: '2650 S John Williams Way E, Tulsa, OK 74114',
bearing: 'SE', // compass point (N, NNE, NE, ...) OR numeric degrees (0-360)
fov: 140, // field of view in degrees (optional)
lon: -95.9791,
lat: 36.1098
}
address— street address shown in the popupbearing— compass direction the POI faces; accepts a compass point ('N','NNE','NE', ...) or a numeric bearing in degreesfov— field of view in degrees
The popup renders these as dedicated rows (Facing / Field of view), and a
directional arrow is drawn on the map at each POI that has a bearing,
rotated to the compass heading it faces.
Zone (polygon — the ring is closed for you):
{
name: 'Downtown Tulsa',
desc: 'Rough downtown core',
polygon: [
[-96.0000, 36.1300],
[-95.9900, 36.1300],
[-95.9900, 36.1600],
[-96.0000, 36.1600]
]
}
Path / route (LineString — an ordered list of points like a GPX track):
{
name: 'Claremore Lake Loop',
desc: 'Example walking route around the lake',
line: [
[-95.5649, 36.3415],
[-95.5700, 36.3430],
[-95.5750, 36.3420],
[-95.5720, 36.3360]
]
}
Paths render as dashed polylines and are searchable by name.
Coordinates are [longitude, latitude] (lon first, like GeoJSON). The file also
includes a copy-paste TEMPLATE block for adding new layers. After editing, run
npm run build:data (or npm run build) to regenerate the static data.
Search
The webapp includes a fuzzy search in the top bar. It matches your POIs, zones, and paths by name or address — and, as a fallback, queries the Nominatim OSM geocoder so you can also search for any location by name (cities, landmarks, addresses anywhere). A "Places (online)" section appears beneath the local matches; selecting a result pans/zooms the map to it (fit-to-bounds for places with a bounding box).
The matcher:
- Normalizes text (case, punctuation, and common address words —
st→street,rd→road,ave→avenue,n→north, etc.), so abbreviated or partial addresses resolve correctly. - Uses substring + Levenshtein edit-distance tolerance, so typos and near-
matches still hit (e.g.
philbrickfindsPhilbrook). - Ranks name matches above address matches above description matches.
Preferences
Click ⚙ Preferences in the top bar:
- Unit system — Metric or Imperial (saved to
localStorage). Units are auto-selected within the chosen system: m/ft under a kilometer/mile, otherwise km/mi (e.g.820 m,1.9 km;900 ft,1.2 mi). - Saved Points — Export / Import your browser-local saved points as a JSON file (see below). Imported points are merged (deduplicated by id); existing points are kept.
Directions & Routing
Everything starts from the map. Clicking one spot opens a context dialog; no click ever saves or routes automatically.
- Clicking an existing point (a POI/zone/path, or a Saved Point) shows its info and offers "Directions from here" - which sets it as the origin and opens the destination picker - plus "Directions to here" once an origin is set.
- Clicking empty space shows a "Save point..." form (with an optional label) to save it as a browser-local Saved Point, or the same "Directions from here" action.
From the opened Directions panel:
- Origin is already set (from "Directions from here"). Pick a destination - by clicking another point (and choosing "Directions to here"), searching a POI (local or online via Nominatim), or choosing a Saved Point.
- Calculate route enables once both points are set. The computation runs in a Web Worker (off the UI thread) with a live progress bar.
- On completion the panel shows the turn-by-turn directions for Drive mode, plus distance, estimated travel time, and the drawn route.
Turn-by-turn is street-aware: the routing graph stores road names, so steps read like "Head north on Pine St", "Turn right onto 21st St", "Sharp left onto OK-20" (falling back to a bare compass/turn label when a way is unnamed). Tiny micro-jogs on a straight street are merged so you don't get noisy phantom turns. The graph is directed: one-way streets and roundabouts are respected, so routes don't travel the wrong way down a one-way road.
Saved Points (browser-local)
Clicking an empty spot on the map opens a context dialog with a
"Save point..." button (plus an optional label). Saving creates a
Saved Point — a location you want to remember. Saved points are stored only
in your browser's localStorage and shown as a toggleable "Saved Points"
layer in the legend, with a distinct purple pin. They can be used as route
origins or destinations.
Because they live only in the browser, Export / Import (in ⚙ Preferences) lets you back them up as a JSON file and restore them (merged, deduplicated by id).
Deployment
The build/ directory is entirely static. Serve it directly:
cd build
python3 -m http.server 8080
# or: nginx -s listen 8080; root /path/to/build; (with a fallback to index.html)
Because the site uses a static adapter with prerendering, everything can be hosted on any static file host or CDN.
Continuous Integration
Gitea Actions runs tests on every commit/push (and on pull requests) via
.gitea/workflows/ci.yml. It runs offline (no Overpass/Nominatim calls) so it
won't trip API rate limits:
npm run check— Svelte/TypeScript checksnpm test— data-integrity + fuzzy-search regression suite against committed datanpm run build:static— static site build (uses committedstatic/data)
Requires a registered Gitea Actions runner (label host).
Repository
Hosted on Gitea: https://gitea.thecookiejar.me/exlim/navigator