# Navigator A custom web application that serves **OpenStreetMap (OSM) data**. It has a build process that: 1. **Pulls** OSM data from the [Overpass API](https://overpass-api.de/) for configurable geographic areas and feature types. 2. **Merges** hand-authored custom layers — **zones** (polygons) and **points of interest** — from `scripts/custom-layers.mjs`. 3. **Converts** everything to static **GeoJSON** files. 4. **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](https://svelte.dev/kit) (Svelte 5, runes mode) - [@sveltejs/adapter-static](https://kit.svelte.dev/docs/adapter-static) — outputs a static site - [Leaflet](https://leafletjs.com/) — interactive map on the frontend - [Overpass API](https://overpass-api.de/) — OSM data source ## Quick Start ```bash 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 ```bash 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) ├── .geojson # One FeatureCollection per Overpass area └── custom/ └── .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`: 1. Downloads a configured `.osm.pbf` URL (cached under `scripts/.cache/`, so repeated runs skip the network). 2. Stream-parses it with `osm-pbf-parser`. 3. Clips to a bounding box and extracts: - **POI features** (from `poiTags`) as points → `static/data/-poi.geojson` - A **routable road graph** (`coords` + weighted `adj`) from road ways → `static/data/-graph.json` 4. Registers a `category: 'dump'` layer in the manifest with `graphPath`. 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. ```bash npm run build:dump # build all dump sources node scripts/osm-dump.mjs --list # list configured sources ``` > The downloaded `.pbf` lives in `scripts/.cache/` (git-ignored); only the > generated `static/data/*.geojson` and `-graph.json` outputs 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: ```js 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 legend - `layerType` — `'poi'` (point), `'zone'` (polygon), or `'path'` (LineString) - `color` — hex color used for markers/fill/lines - `features` — array of feature objects POI (point) — optional `address`, `bearing`, and `fov` fields: ```js { 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 popup - `bearing` — compass direction the POI faces; accepts a compass point (`'N'`, `'NNE'`, `'NE'`, ...) or a numeric bearing in degrees - `fov` — 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): ```js { 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): ```js { 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](https://nominatim.openstreetmap.org/) 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. `philbrick` finds `Philbrook`). - Ranks name matches above address matches above description matches. ## Preferences & Units Click **⚙ Preferences** in the top bar to choose the **unit system** — **Metric** or **Imperial**. The choice is saved to `localStorage`, so it persists across reloads and is shared by the Directions panel. 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`). ## 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; distances auto-switch between m/ft and km/mi at the km/mile boundary), 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. ```ts 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: ```bash 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 checks - `npm test` — data-integrity + fuzzy-search regression suite against committed data - `npm run build:static` — static site build (uses committed `static/data`) Requires a registered Gitea Actions runner (label `host`). ## Repository Hosted on Gitea: https://gitea.thecookiejar.me/exlim/navigator