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 & Units
Click ⚙ Preferences in the top bar to choose Metric or Imperial units.
Your choice (and the granular unit override) is saved to localStorage, so it
persists across reloads and is shared by the Directions panel.
Directions & Routing
When a dump layer is present (it carries the routable graph), a Directions button appears in the top bar. Opening it lets you:
- Set start (A) and end (B) by clicking the map.
- Avoid certain POI categories along the route (schools, fire stations, fuel stations, parking, restaurants, …).
- Compute a route and draw it on the map.
- Choose a unit system (Metric or Imperial) and units (
m/kmorft/mi, orAuto— which uses the small unit for short routes and the large one for long routes), plus a travel mode (Drive or Walk). - See the route's distance and an estimated travel time (computed from the mode's average speed).
- Turn-by-turn directions (Head north, Turn right, Arrive at destination, …) are generated for Drive mode and listed beneath the result; a live progress bar shows while the route computes.
- Robustness: if A/B are far from the routable road network, or the destination is disconnected / fully blocked by avoided categories, the app reports why and still draws the closest reachable portion of a route (flagged as a partial path) instead of failing silently.
Routing is done entirely in the browser (src/lib/routing.ts): an
A*-search over the graph built from the OSM dump. Point A/B are snapped to the
nearest graph node. When you enable a category to avoid, edges within a radius
of POIs in that category are heavily penalized (or blocked) so the route routes
around them.
import { route } from '$lib/routing';
const r = route(graph, [latA, lonB], [latB, lonB], pois, [
{ category: 'amenity', value: 'school', radiusM: 300, block: true }
]);
// r.found, r.path ([[lat,lon],...]), r.distanceM
// r.partial (best-effort partial route when the destination is unreachable),
// r.fromSnapM / r.toSnapM (distance from A/B to nearest road),
// r.reason (human explanation when no full route exists),
// r.turns (turn-by-turn steps for driving mode)
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