- scripts/osm-dump.mjs: download & stream-parse regional .osm.pbf extracts (Geofabrik/BBBike etc). Clips to bbox, extracts POI features (poiTags), and builds a routable road graph (coords + weighted adj) from road ways. Caches the downloaded .pbf under scripts/.cache/ (git-ignored). Default: Geofabrik Oklahoma extract -> tulsa-dump layer (12.8k POIs, 55k-node graph). - src/lib/routing.ts: client-side A* routing over the graph. Snaps A/B to nearest nodes, supports avoid-rules (category + radius, block or penalty) built from nearby POIs; returns waypoints + distance. - src/lib/components/DirectionsPanel.svelte: Directions UI — pick A/B by clicking the map, choose POI categories to avoid, compute & draw the route. - MapView: adds a Directions toggle when a dump layer with a graph is present. - package.json: add 'build:dump'; build now runs data + dump + static. - scripts/test.mjs: validate dump manifest entries + routing graph structure. - README: document dump ingestion and the routing/avoid feature.
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.
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.
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, with total driving distance.
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
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/hermes-explorigin/navigator