Add OSM data-dump ingestion + in-browser routing with POI avoidance
All checks were successful
CI / test-and-build (push) Successful in 1m3s

- 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.
This commit is contained in:
hermes-explorigin 2026-08-09 15:19:35 +00:00
parent 0753527d69
commit db292409f1
12 changed files with 1451 additions and 9 deletions

3
.gitignore vendored
View File

@ -21,3 +21,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# OSM dump cache (downloaded .pbf extracts, not source)
/scripts/.cache/

View File

@ -49,8 +49,9 @@ npm run check # Type + Svelte checks
| Command | What it does | | Command | What it does |
| -------------------- | ---------------------------------------------------------------- | | -------------------- | ---------------------------------------------------------------- |
| `npm run build:data` | Runs `scripts/osm-data.mjs` to fetch OSM data → `static/data/` | | `npm run build:data` | Overpass API fetch (area POIs/roads) via `scripts/osm-data.mjs``static/data/` |
| `npm run build` | `build:data` followed by the SvelteKit static build → `build/` | | `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 | | `npm run preview` | Serves the `build/` output locally |
### Data layout ### Data layout
@ -67,6 +68,35 @@ 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 renders it as a toggleable overlay with a legend (top-right). Every layer can be
shown/hidden independently. 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`:
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/<id>-poi.geojson`
- A **routable road graph** (`coords` + weighted `adj`) from road ways →
`static/data/<id>-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 ## Configuring Areas & Queries
Edit **`scripts/osm-data.mjs`** — the `AREAS` object describes each area: Edit **`scripts/osm-data.mjs`** — the `AREAS` object describes each area:
@ -191,6 +221,30 @@ The matcher:
matches still hit (e.g. `philbrick` finds `Philbrook`). matches still hit (e.g. `philbrick` finds `Philbrook`).
- Ranks name matches above address matches above description matches. - 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.
```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
```
## Deployment ## Deployment
The `build/` directory is entirely static. Serve it directly: The `build/` directory is entirely static. Serve it directly:

630
package-lock.json generated
View File

@ -9,7 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"leaflet": "^1.9.4" "leaflet": "^1.9.4",
"osm-pbf-parser": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@ -954,6 +955,36 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/brfs": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/brfs/-/brfs-1.6.1.tgz",
"integrity": "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ==",
"license": "MIT",
"dependencies": {
"quote-stream": "^1.0.1",
"resolve": "^1.1.5",
"static-module": "^2.2.0",
"through2": "^2.0.0"
},
"bin": {
"brfs": "bin/cmd.js"
}
},
"node_modules/buffer-equal": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz",
"integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -980,6 +1011,27 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@ -990,6 +1042,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT"
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -1017,6 +1081,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -1059,6 +1141,28 @@
"@esbuild/win32-x64": "0.28.1" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escodegen": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.1.tgz",
"integrity": "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^3.1.3",
"estraverse": "^4.2.0",
"esutils": "^2.0.2",
"optionator": "^0.8.1"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=4.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@ -1066,6 +1170,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esprima": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz",
"integrity": "sha512-AWwVMNxwhN8+NIPQzAQZCm7RkLC4RbM3B1OobMuyp3i+w73X57KCKaVIxaRZb+DYCojq7rspo+fmuQfAboyhFg==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz",
@ -1084,6 +1201,61 @@
} }
} }
}, },
"node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/falafel": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz",
"integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==",
"license": "MIT",
"dependencies": {
"acorn": "^7.1.1",
"isarray": "^2.0.1"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/falafel/node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/falafel/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1117,6 +1289,81 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generate-object-property": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz",
"integrity": "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.0"
}
},
"node_modules/has": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz",
"integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -1127,6 +1374,12 @@
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -1143,6 +1396,19 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/levn": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
"integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
"license": "MIT",
"dependencies": {
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -1421,6 +1687,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/merge-source-map": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz",
"integrity": "sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA==",
"license": "MIT",
"dependencies": {
"source-map": "^0.5.6"
}
},
"node_modules/merge-source-map/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -1460,6 +1753,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/object-inspect": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz",
"integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==",
"license": "MIT"
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
@ -1474,6 +1773,42 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/optionator": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
"integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
"license": "MIT",
"dependencies": {
"deep-is": "~0.1.3",
"fast-levenshtein": "~2.0.6",
"levn": "~0.3.0",
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2",
"word-wrap": "~1.2.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/osm-pbf-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/osm-pbf-parser/-/osm-pbf-parser-2.3.0.tgz",
"integrity": "sha512-JzrfMf4OyoTWnIJa9Ah/pTDVSq4BCKYw7gf34d6MCMvdgAWOPIyFcEDaXdBD7MvPgrFVAzt3leYug7qX1p7pLw==",
"license": "MIT and LGPL",
"dependencies": {
"brfs": "^1.4.1",
"inherits": "^2.0.1",
"protocol-buffers": "^3.1.3",
"readable-stream": "^2.0.4",
"stream-combiner2": "^1.1.1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1523,6 +1858,69 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/protocol-buffers": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/protocol-buffers/-/protocol-buffers-3.2.1.tgz",
"integrity": "sha512-p2PyLiXzAqC3MO23/+H/CSLZ24xLl0zkGSlQJBmvS854OTmaQ4cqDqthoikOAj2yQQraC4WrOcdz2br6exdtLQ==",
"license": "MIT",
"dependencies": {
"brfs": "^1.4.0",
"generate-function": "^2.0.0",
"generate-object-property": "^1.2.0",
"protocol-buffers-schema": "^3.1.1",
"signed-varint": "^2.0.0",
"varint": "^5.0.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quote-stream": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz",
"integrity": "sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ==",
"license": "MIT",
"dependencies": {
"buffer-equal": "0.0.1",
"minimist": "^1.1.3",
"through2": "^2.0.0"
},
"bin": {
"quote-stream": "bin/cmd.js"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -1537,6 +1935,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@ -1583,6 +2002,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@ -1590,6 +2015,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/shallow-copy": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz",
"integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==",
"license": "MIT"
},
"node_modules/signed-varint": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz",
"integrity": "sha512-abgDPg1106vuZZOvw7cFwdCABddfJRz5akcCcchzTbhyhYnsG31y4AlZEgp315T7W3nQq5P4xeOm186ZiPVFzw==",
"license": "MIT",
"dependencies": {
"varint": "~5.0.0"
}
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@ -1605,6 +2045,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1615,6 +2065,120 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/static-eval": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz",
"integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==",
"license": "MIT",
"dependencies": {
"escodegen": "^2.1.0"
}
},
"node_modules/static-eval/node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/static-eval/node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/static-eval/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/static-module": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/static-module/-/static-module-2.2.5.tgz",
"integrity": "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==",
"license": "MIT",
"dependencies": {
"concat-stream": "~1.6.0",
"convert-source-map": "^1.5.1",
"duplexer2": "~0.1.4",
"escodegen": "~1.9.0",
"falafel": "^2.1.0",
"has": "^1.0.1",
"magic-string": "^0.22.4",
"merge-source-map": "1.0.4",
"object-inspect": "~1.4.0",
"quote-stream": "~1.0.2",
"readable-stream": "~2.3.3",
"shallow-copy": "~0.0.1",
"static-eval": "^2.0.0",
"through2": "~2.0.3"
}
},
"node_modules/static-module/node_modules/magic-string": {
"version": "0.22.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz",
"integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==",
"license": "MIT",
"dependencies": {
"vlq": "^0.2.2"
}
},
"node_modules/stream-combiner2": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
"integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==",
"license": "MIT",
"dependencies": {
"duplexer2": "~0.1.0",
"readable-stream": "^2.0.2"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.56.8", "version": "5.56.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
@ -1668,6 +2232,16 @@
"typescript": "^5.0.0 || ^6.0.0" "typescript": "^5.0.0 || ^6.0.0"
} }
}, },
"node_modules/through2": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
"license": "MIT",
"dependencies": {
"readable-stream": "~2.3.6",
"xtend": "~4.0.1"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -1714,6 +2288,24 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
"integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
"license": "MIT",
"dependencies": {
"prelude-ls": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@ -1728,6 +2320,18 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/varint": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz",
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
@ -1826,6 +2430,30 @@
} }
} }
}, },
"node_modules/vlq": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz",
"integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==",
"license": "MIT"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View File

@ -8,7 +8,8 @@
"data": "node scripts/osm-data.mjs", "data": "node scripts/osm-data.mjs",
"data:area": "node scripts/osm-data.mjs", "data:area": "node scripts/osm-data.mjs",
"build:data": "node scripts/osm-data.mjs", "build:data": "node scripts/osm-data.mjs",
"build": "npm run build:data && vite build", "build:dump": "node scripts/osm-dump.mjs",
"build": "npm run build:data && npm run build:dump && vite build",
"build:static": "vite build", "build:static": "vite build",
"test": "tsx scripts/test.mjs", "test": "tsx scripts/test.mjs",
"preview": "vite preview", "preview": "vite preview",
@ -22,12 +23,13 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tsx": "^4.23.11", "tsx": "^4.19.2",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"leaflet": "^1.9.4" "leaflet": "^1.9.4",
"osm-pbf-parser": "^2.3.0"
} }
} }

266
scripts/osm-dump.mjs Normal file
View File

@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* osm-dump.mjs ingest OSM data from .osm.pbf data dumps (an alternative to
* the live Overpass API in osm-data.mjs).
*
* For each configured source it:
* 1. Downloads (and caches) a regional .osm.pbf extract (Geofabrik, BBBike,
* etc. any URL works).
* 2. Stream-parses it with osm-pbf-parser.
* 3. Clips to a bounding box:
* - Collects POI features (amenity / shop / tourism / etc.) as Points.
* - Collects road ways and builds a routable ROAD GRAPH.
* 4. Writes static/data/<id>-poi.geojson + <id>-graph.json and registers
* them in the manifest.
*
* Usage:
* node scripts/osm-dump.mjs # build all configured dumps
* node scripts/osm-dump.mjs --source=tulsa # build one source
* node scripts/osm-dump.mjs --list # list configured sources
*
* NOTE: This script intentionally never hits the Overpass API. The full
* regional dump is cached under scripts/.cache/ so repeated runs skip the
* network download.
*/
import { mkdir, writeFile, stat } from 'node:fs/promises';
import { createReadStream, existsSync } from 'node:fs';
import { dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import parseOSM from 'osm-pbf-parser';
import { pipeline } from 'node:stream/promises';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const OUT_DIR = resolve(ROOT, 'static', 'data');
const CACHE_DIR = resolve(__dirname, '.cache');
// ---------------------------------------------------------------------------
// Configuration. Each source: { id, name, url (a .osm.pbf), bbox, poiTags }.
// Roads (highway=*) are always extracted for the graph.
// ---------------------------------------------------------------------------
export const DUMPS = [
{
id: 'tulsa-dump',
name: 'Tulsa OSM (dump)',
url: 'https://download.geofabrik.de/north-america/us/oklahoma-latest.osm.pbf',
bbox: [35.9, -96.1, 36.3, -95.7], // [south, west, north, east]
// Road classes included in the routing graph. Major classes keep the graph
// small enough to fetch in the browser; service/paths/appended local roads
// are excluded by default.
graphHighways: ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'],
poiTags: {
amenity: ['school', 'fire_station', 'hospital', 'restaurant', 'cafe', 'fuel', 'parking'],
shop: ['supermarket', 'convenience'],
tourism: ['museum', 'attraction', 'hotel']
}
}
];
// ---------------------------------------------------------------------------
// Download helpers (with on-disk cache).
// ---------------------------------------------------------------------------
async function download(url, dest) {
console.log(` downloading ${url}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(dest, buf);
console.log(` cached ${buf.length} bytes -> ${dest}`);
}
async function ensureDump(cfg) {
const cachedPath = resolve(CACHE_DIR, basename(new URL(cfg.url).pathname));
await mkdir(dirname(cachedPath), { recursive: true });
if (existsSync(cachedPath)) {
const st = await stat(cachedPath);
if (st.size > 0) {
console.log(` using cached dump: ${cachedPath}`);
return cachedPath;
}
}
await download(cfg.url, cachedPath);
return cachedPath;
}
// Haversine distance in meters.
function haversine(a, b) {
const R = 6371000;
const [lata, lona] = a;
const [latb, lonb] = b;
const dLat = ((latb - lata) * Math.PI) / 180;
const dLon = ((lonb - lona) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((lata * Math.PI) / 180) * Math.cos((latb * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function inBbox(lat, lon, [s, w, n, e], margin = 0.02) {
return lat >= s - margin && lat <= n + margin && lon >= w - margin && lon <= e + margin;
}
// ---------------------------------------------------------------------------
// Stream-parse the dump and build POI features + a road graph for the bbox.
// ---------------------------------------------------------------------------
async function buildFromDump(cfg) {
const parser = parseOSM();
const pois = [];
const nodeCoords = new Map(); // osm nodeId -> [lat, lon]
const nodeIdx = new Map(); // osm nodeId -> compact index
const coords = []; // coords[i] = [lat, lon]
const adj = []; // adj[i] = [[neighborIdx, meters], ...]
const poiTagSet = new Set(
Object.entries(cfg.poiTags).flatMap(([k, vals]) => vals.map((v) => `${k}=${v}`))
);
const parserInput = createReadStream(await ensureDump(cfg));
await new Promise((resolvePromise, reject) => {
parser
.on('data', (items) => {
for (const it of items) {
if (it.type === 'node') {
if (inBbox(it.lat, it.lon, cfg.bbox)) {
nodeCoords.set(it.id, [it.lat, it.lon]);
const key = Object.keys(it.tags || {}).find((k) =>
poiTagSet.has(`${k}=${it.tags[k]}`));
if (key) {
pois.push({
type: 'Feature',
id: `dump/${cfg.id}/node/${it.id}`,
properties: { ...it.tags, name: it.tags?.name, _type: 'node', _id: it.id },
geometry: { type: 'Point', coordinates: [it.lon, it.lat] }
});
}
}
} else if (it.type === 'way') {
const hw = it.tags?.highway;
if (hw && cfg.graphHighways && !cfg.graphHighways.includes(hw)) continue;
// POI footprints (non-road ways with a targeted tag).
if (!hw) {
const key = Object.keys(it.tags || {}).find((k) =>
poiTagSet.has(`${k}=${it.tags[k]}`));
if (key) {
const pts = it.refs.map((rid) => nodeCoords.get(rid)).filter(Boolean);
if (pts.length) {
const lat = pts.reduce((a, p) => a + p[0], 0) / pts.length;
const lon = pts.reduce((a, p) => a + p[1], 0) / pts.length;
if (inBbox(lat, lon, cfg.bbox)) {
pois.push({
type: 'Feature',
id: `dump/${cfg.id}/way/${it.id}`,
properties: { ...it.tags, name: it.tags?.name, _type: 'way', _id: it.id },
geometry: { type: 'Point', coordinates: [lon, lat] }
});
}
}
}
continue;
}
// Road: snap refs into the compact graph.
const pts = [];
for (const rid of it.refs) {
const c = nodeCoords.get(rid);
if (!c) continue;
if (!nodeIdx.has(rid)) {
nodeIdx.set(rid, coords.length);
coords.push(c);
adj.push([]);
}
pts.push(nodeIdx.get(rid));
}
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
if (a === b) continue;
const d = haversine(coords[a], coords[b]);
adj[a].push([b, d]);
adj[b].push([a, d]);
}
}
}
})
.on('end', resolvePromise)
.on('error', reject);
pipeline(parserInput, parser).catch(reject);
});
const edgeCount = Math.round(adj.reduce((a, x) => a + x.length, 0) / 2);
console.log(` parsed ${coords.length} graph nodes, ${edgeCount} road edges, ${pois.length} POIs`);
// --- write POI GeoJSON ---
const poiFc = {
type: 'FeatureCollection',
crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } },
features: pois
};
const poiPath = resolve(OUT_DIR, `${cfg.id}-poi.geojson`);
await writeFile(poiPath, JSON.stringify(poiFc));
console.log(` wrote ${pois.length} POIs -> ${poiPath}`);
// --- write routing graph ---
const graph = {
meta: { source: cfg.url, bbox: cfg.bbox, nodeCount: coords.length, edgeCount },
coords,
adj
};
const graphPath = resolve(OUT_DIR, `${cfg.id}-graph.json`);
await writeFile(graphPath, JSON.stringify(graph));
console.log(` wrote routing graph -> ${graphPath}`);
return {
name: cfg.id,
label: cfg.name,
category: 'dump',
layerType: 'poi',
color: '#8b5cf6',
featureCount: pois.length,
path: `/data/${cfg.id}-poi.geojson`,
poiPath: `/data/${cfg.id}-poi.geojson`,
graphPath: `/data/${cfg.id}-graph.json`,
graphNodeCount: coords.length
};
}
// ---------------------------------------------------------------------------
// Manifest handling + main.
// ---------------------------------------------------------------------------
async function main() {
const args = process.argv.slice(2);
if (args.includes('--list')) {
console.log('Configured dump sources:\n');
for (const d of DUMPS) console.log(` ${d.id}: ${d.name} (${d.url})`);
return;
}
const onlyArg = args.find((a) => a.startsWith('--source='));
const only = onlyArg ? onlyArg.split('=')[1] : null;
await mkdir(OUT_DIR, { recursive: true });
let manifest = [];
const { existsSync: exists } = await import('node:fs');
const manifestPath = resolve(OUT_DIR, '_index.json');
if (exists(manifestPath)) {
const { readFile } = await import('node:fs/promises');
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
}
for (const cfg of DUMPS) {
if (only && cfg.id !== only) continue;
console.log(`\n==> Building dump source "${cfg.id}"`);
const entry = await buildFromDump(cfg);
manifest = manifest.filter((m) => !(m.name === cfg.id && m.category === 'dump'));
manifest.push(entry);
}
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`\nWrote manifest -> ${manifestPath}`);
console.log('Done.');
}
main().catch((e) => {
console.error('Fatal:', e.message);
process.exit(1);
});

View File

@ -76,7 +76,7 @@ async function run() {
const rel = layer.path.replace(/^\/data\//, ''); const rel = layer.path.replace(/^\/data\//, '');
const abs = resolve(STATIC, rel); const abs = resolve(STATIC, rel);
check(statSync(abs, { throwIfNoEntry: false })?.isFile(), `layer "${layer.name}" file exists (${rel})`); check(statSync(abs, { throwIfNoEntry: false })?.isFile(), `layer "${layer.name}" file exists (${rel})`);
check(['osm', 'custom'].includes(layer.category), `layer "${layer.name}" has valid category`); check(['osm', 'custom', 'dump'].includes(layer.category), `layer "${layer.name}" has valid category`);
check(['poi', 'zone', 'path', 'mixed'].includes(layer.layerType), `layer "${layer.name}" has valid layerType "${layer.layerType}"`); check(['poi', 'zone', 'path', 'mixed'].includes(layer.layerType), `layer "${layer.name}" has valid layerType "${layer.layerType}"`);
} }
@ -90,6 +90,23 @@ async function run() {
} }
} }
// 2.5 Routing graph validation (dump sources).
for (const layer of manifest) {
if (layer.category !== 'dump') continue;
check(!!layer.graphPath, `dump layer "${layer.name}" has graphPath`);
check(!!layer.poiPath, `dump layer "${layer.name}" has poiPath`);
const g = loadJson(resolve(STATIC, layer.graphPath.replace(/^\/data\//, '')));
check(Array.isArray(g.coords) && g.coords.length > 0, `graph "${layer.name}" has coords (${g.coords.length})`);
check(Array.isArray(g.adj) && g.adj.length === g.coords.length, `graph "${layer.name}" adj size matches coords (${g.adj.length})`);
const edgeCount = g.adj.reduce((a, x) => a + x.length, 0);
check(edgeCount > 0, `graph "${layer.name}" has edges (${Math.round(edgeCount / 2)})`);
if (g.coords.length) {
const ok = g.coords.every((c) => Array.isArray(c) && c.length === 2 && typeof c[0] === 'number');
check(ok, `graph "${layer.name}" coords are [lat,lon] pairs`);
}
}
// 3. Search engine expectations against committed data. // 3. Search engine expectations against committed data.
const { search, all } = await buildSearchables(); const { search, all } = await buildSearchables();
const build = async () => ({ const build = async () => ({

View File

@ -0,0 +1,190 @@
<script lang="ts">
import type { Map as LeafletMap } from 'leaflet';
import { onMount } from 'svelte';
import { route, type RouteGraph, type AvoidRule, type RouteResult } from '$lib/routing';
interface Props {
map: LeafletMap;
/** Dump sources that carry a graphPath (from the manifest). */
dumpLayers: { name: string; label: string; graphPath: string; poiPath: string }[];
}
let { map, dumpLayers = [] }: Props = $props();
let graph = $state<RouteGraph | null>(null);
let pois = $state<GeoJSON.Feature[]>([]);
let loadingData = $state(false);
let loadError = $state<string | null>(null);
let from = $state<[number, number] | null>(null);
let to = $state<[number, number] | null>(null);
let result = $state<RouteResult | null>(null);
let computing = $state(false);
let picking: 'from' | 'to' | null = $state(null);
let routeLayer: L.LayerGroup | null = null;
const CATEGORIES = [
{ key: 'school', value: 'school', label: 'Schools' },
{ key: 'fire_station', value: 'fire_station', label: 'Fire stations' },
{ key: 'hospital', value: 'hospital', label: 'Hospitals' },
{ key: 'fuel', value: 'fuel', label: 'Fuel stations' },
{ key: 'parking', value: 'parking', label: 'Parking' },
{ key: 'restaurant', value: 'restaurant', label: 'Restaurants' }
];
const selected = $state<Record<string, boolean>>({});
async function loadData() {
if (loadingData || graph) return;
loadingData = true;
loadError = null;
try {
const layer = dumpLayers[0];
if (!layer) throw new Error('No dump/graph source configured.');
const [g, p] = await Promise.all([fetch(layer.graphPath), fetch(layer.poiPath)]);
if (!g.ok) throw new Error(`graph HTTP ${g.status}`);
if (!p.ok) throw new Error(`poi HTTP ${p.status}`);
graph = await g.json();
const pc: { features: GeoJSON.Feature[] } = await p.json();
pois = pc.features;
} catch (e) {
loadError = (e as Error).message;
}
loadingData = false;
}
// Replace the map click handler when picking A/B.
$effect(() => {
if (!map || !picking) return;
const onClick = (e: L.LeafletMouseEvent) => {
const ll: [number, number] = [e.latlng.lat, e.latlng.lng];
if (picking === 'from') from = ll;
else to = ll;
picking = null;
drawEndpoints();
};
map.on('click', onClick);
return () => map.off('click', onClick);
});
function drawEndpoints() {
// Recompute/refresh route if both set.
if (from && to && !computing) compute();
}
function clearRoute() {
if (routeLayer) { routeLayer.remove(); routeLayer = null; }
result = null;
}
async function drawResult(r: RouteResult) {
const L = (await import('leaflet')).default;
clearRoute();
if (!map) return;
routeLayer = L.layerGroup().addTo(map);
if (r.found && r.path.length >= 2) {
const latlngs = r.path.map(([lat, lon]) => [lat, lon] as [number, number]);
L.polyline(latlngs, { color: '#0ea5e9', weight: 5, opacity: 0.85 }).addTo(routeLayer);
}
if (r.from) L.circleMarker(r.from as [number, number], { radius: 8, color: '#16a34a', fillColor: '#16a34a', fillOpacity: 1, weight: 2 })
.addTo(routeLayer).bindPopup('Start');
if (r.to) L.circleMarker(r.to as [number, number], { radius: 8, color: '#dc2626', fillColor: '#dc2626', fillOpacity: 1, weight: 2 })
.addTo(routeLayer).bindPopup('End');
if (r.found && r.path.length) {
const b = L.latLngBounds(r.path.map(([lat, lon]) => [lat, lon] as [number, number]));
if (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
}
}
async function compute() {
if (!graph || !from || !to) return;
computing = true;
const rules = CATEGORIES.filter((c) => selected[c.key])
.map((c) => ({ category: 'amenity', value: c.value, radiusM: 300, block: true } as AvoidRule));
try {
result = route(graph, from, to, pois, rules);
await drawResult(result);
} finally {
computing = false;
}
}
onMount(loadData);
</script>
<div class="dirs">
<div class="dir-head">Directions</div>
{#if loadError}
<div class="err">{loadError}</div>
{:else if !graph}
<div class="hint">{loadingData ? 'Loading road network…' : 'No network source.'}</div>
{/if}
{#if graph}
{#if from}
<div class="point"><span class="dot a"></span> A · {from[0].toFixed(4)}, {from[1].toFixed(4)}</div>
{:else}
<button class="pick" onclick={() => (picking = 'from')} disabled={!!picking}>
{picking === 'from' ? 'Click map for A…' : 'Set start (A)'}
</button>
{/if}
{#if to}
<div class="point"><span class="dot b"></span> B · {to[0].toFixed(4)}, {to[1].toFixed(4)}</div>
{:else}
<button class="pick" onclick={() => (picking = 'to')} disabled={!!picking}>
{picking === 'to' ? 'Click map for B…' : 'Set end (B)'}
</button>
{/if}
<div class="avoid">
<strong>Avoid</strong>
{#each CATEGORIES as c (c.key)}
<label><input type="checkbox" bind:checked={selected[c.key]} /> {c.label}</label>
{/each}
</div>
{#if from && to}
<button class="go" onclick={() => compute()} disabled={computing}>
{computing ? 'Computing…' : 'Compute route'}
</button>
{/if}
{#if result}
{#if result.found}
<div class="res">
Distance: <strong>{(result.distanceM / 1000).toFixed(2)} km</strong>
<br /><em>{result.nodesVisited.toLocaleString()} nodes searched</em>
</div>
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
{:else}
<div class="res warn">No route found (blocked or disconnected).</div>
{/if}
{/if}
<div class="hint small">Set A &amp; B by clicking the map, pick categories to avoid, then compute.</div>
{/if}
</div>
<style>
.dirs { background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.25); padding: 12px; width: 250px; font: 0.8rem system-ui, sans-serif; color: #0f172a; }
.dir-head { font-weight: 700; margin-bottom: 8px; }
.point { padding: 6px 8px; background: #f1f5f9; border-radius: 6px; margin: 4px 0; display: flex; align-items: center; gap: 6px; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot.a { background: #16a34a; } .dot.b { background: #dc2626; }
.pick, .go, .clear { width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1; background: #fff; cursor: pointer; margin: 4px 0; font-size: 0.8rem; }
.pick:hover, .go:hover, .clear:hover { background: #f1f5f9; }
.pick:disabled { opacity: .4; cursor: default; }
.go { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
.go:disabled { opacity: .5; cursor: default; }
.avoid { margin: 8px 0; display: grid; gap: 3px; }
.avoid strong { display: block; margin-bottom: 2px; }
.avoid label { display: flex; align-items: center; gap: 6px; }
.res { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px; border-radius: 6px; margin-top: 6px; }
.res em { display: block; color: #64748b; font-size: 0.7rem; margin-top: 2px; }
.res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.err { color: #b91c1c; margin: 6px 0; }
.hint { color: #64748b; }
.hint.small { font-size: 0.7rem; margin-top: 6px; color: #94a3b8; }
</style>

View File

@ -4,6 +4,7 @@
import type { Map as LeafletMap } from 'leaflet'; import type { Map as LeafletMap } from 'leaflet';
import { search, type Searchable, type SearchResult } from '$lib/search'; import { search, type Searchable, type SearchResult } from '$lib/search';
import { geocode, type GeocodedPlace } from '$lib/geocode'; import { geocode, type GeocodedPlace } from '$lib/geocode';
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
type FeatureCollection = { type FeatureCollection = {
type: string; type: string;
@ -13,11 +14,13 @@
export interface MapLayer { export interface MapLayer {
name: string; // id name: string; // id
label: string; // display name label: string; // display name
category: 'osm' | 'custom'; category: 'osm' | 'custom' | 'dump';
layerType: 'poi' | 'zone' | 'path' | 'mixed'; layerType: 'poi' | 'zone' | 'path' | 'mixed';
color: string; color: string;
featureCount: number; featureCount: number;
path: string; path: string;
graphPath?: string;
poiPath?: string;
} }
interface Props { interface Props {
@ -48,6 +51,11 @@
let visible = $state<Record<string, boolean>>({}); let visible = $state<Record<string, boolean>>({});
let totalFeatures = $state(0); let totalFeatures = $state(0);
// --- directions state ---
const dumpLayers = $derived(
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
);
let showDirections = $state(false);
// --- search state --- // --- search state ---
const searchables: Searchable[] = []; const searchables: Searchable[] = [];
let query = $state(''); let query = $state('');
@ -324,6 +332,11 @@
</ul> </ul>
{/if} {/if}
</div> </div>
{#if dumpLayers.length}
<button class="dirbtn" onclick={() => (showDirections = !showDirections)}>
{showDirections ? 'Hide Directions' : 'Directions'}
</button>
{/if}
<span class="meta">{totalFeatures} features</span> <span class="meta">{totalFeatures} features</span>
</header> </header>
@ -341,6 +354,12 @@
{/each} {/each}
</div> </div>
{#if showDirections && dumpLayers.length}
<div class="dirs-wrap">
<DirectionsPanel {map} dumpLayers={dumpLayers as { name: string; label: string; graphPath: string; poiPath: string }[]} />
</div>
{/if}
<div class="map" bind:this={container}></div> <div class="map" bind:this={container}></div>
</div> </div>
@ -390,6 +409,13 @@
} }
.notice.error { background: #b91c1c; } .notice.error { background: #b91c1c; }
.dirbtn {
padding: 6px 14px; border-radius: 8px; border: 1px solid #475569;
background: #1e293b; color: #e2e8f0; font-size: 0.8rem; cursor: pointer;
}
.dirbtn:hover { background: #334155; }
.dirs-wrap { position: absolute; bottom: 16px; left: 12px; z-index: 1500; }
.legend { .legend {
position: absolute; top: 66px; right: 12px; z-index: 1500; position: absolute; top: 66px; right: 12px; z-index: 1500;
background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.25); background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.25);

242
src/lib/routing.ts Normal file
View File

@ -0,0 +1,242 @@
/**
* routing.ts client-side road routing over the graph built from the OSM
* dump (static/data/<id>-graph.json).
*
* The graph is `{ meta, coords: [[lat,lon],...], adj: [[[nodeIdx,meters],...]] }`.
* This module:
* - loads the graph,
* - snaps arbitrary lat/lon points to the nearest reachable node(s),
* - runs an A* / Dijkstra shortest-path search (great-circle heuristic),
* - supports "avoid" categories: edges whose midpoint falls within a radius
* of a POI in a chosen category get a heavy cost penalty (or are blocked),
* - returns the route as an ordered list of [lat, lon] waypoints + distance.
*/
export interface RouteGraph {
meta: {
nodeCount: number;
edgeCount: number;
bbox?: [number, number, number, number];
};
coords: [number, number][];
adj: [number, number][][];
}
export interface AvoidRule {
category: string; // e.g. 'school', 'fuel', 'parking'
value?: string; // optional specific value; undefined = whole category
radiusM: number; // radius around avoided POIs to penalize
block?: boolean; // true = untraversable, false/default = heavy penalty
}
export interface RouteOptions {
avoid?: AvoidRule[];
}
export interface RouteResult {
found: boolean;
path: [number, number][]; // [lat, lon] waypoints
distanceM: number;
nodesVisited: number;
from: [number, number];
to: [number, number];
}
// --- geometry helpers ---
function haversineM(aLonLat: number[], bLonLat: number[]): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(bLonLat[1] - aLonLat[1]);
const dLon = toRad(bLonLat[0] - aLonLat[0]);
const lat1 = toRad(aLonLat[1]);
const lat2 = toRad(bLonLat[1]);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
// Nearest node index to a [lat, lon] point (linear scan; fine for ~50k nodes).
export function nearestNode(graph: RouteGraph, lat: number, lon: number): number {
let best = -1;
let bestD = Infinity;
const target = [lon, lat];
for (let i = 0; i < graph.coords.length; i++) {
const c = graph.coords[i];
const d = haversineM(target, [c[1], c[0]]);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
// Precompute per-node avoid penalties once, given loaded POIs and avoid rules.
// Returns an edge-penalty map: "a|b" -> costMultiplier (>=1). Blocked edges are
// excluded from consideration entirely by returning Infinity.
export function buildEdgePenalties(
graph: RouteGraph,
pois: GeoJSON.Feature[],
avoid: AvoidRule[]
): Map<string, number> {
const penalty = new Map<string, number>();
if (!avoid?.length) return penalty;
const rules = avoid; // categories to penalize
const selectedPois = pois.filter((f) => {
const p = (f.properties ?? {}) as Record<string, unknown>;
return rules.some((r) => {
const v = p[r.category];
if (v === undefined || v === null) return false;
if (r.value !== undefined) return String(v) === String(r.value);
return true;
});
});
if (!selectedPois.length) return penalty;
// For each avoided POI, check edges near it.
for (const poi of selectedPois) {
const geom = poi.geometry as { type: string; coordinates: number[] };
if (!geom || geom.type !== 'Point') continue;
const [plon, plat] = geom.coordinates;
const R = Math.max(...rules.map((r) => r.radiusM));
// Bounding-box prune over nodes.
for (let a = 0; a < graph.coords.length; a++) {
const [alat, alon] = graph.coords[a];
const dist = haversineM([plon, plat], [alon, alat]);
if (dist > R) continue;
for (const [b, base] of graph.adj[a]) {
// midpoint of a-b
const [blat, blon] = graph.coords[b];
const mLat = (alat + blat) / 2;
const mLon = (alon + blon) / 2;
const md = haversineM([plon, plat], [mLon, mLat]);
if (md <= R) {
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
const blocked = rules.some(
(r) => r.value !== undefined && r.block && String((poi.properties as Record<string, unknown>)[r.category]) === String(r.value)
) || rules.some((r) => r.block && r.value === undefined);
penalty.set(key, blocked ? Infinity : Math.max(penalty.get(key) ?? 1, 10));
}
}
}
}
return penalty;
}
// A* / Dijkstra shortest path. Returns list of node indices + distance.
function shortestPath(
graph: RouteGraph,
start: number,
goal: number,
penalty: Map<string, number>
): { path: number[]; distance: number; visited: number } | null {
if (start === goal) return { path: [start], distance: 0, visited: 1 };
const n = graph.coords.length;
const gScore = new Float64Array(n).fill(Infinity);
const fScore = new Float64Array(n).fill(Infinity);
const cameFrom = new Int32Array(n).fill(-1);
const closed = new Uint8Array(n);
const goalCoord = graph.coords[goal];
const h = (i: number) => haversineM([graph.coords[i][1], graph.coords[i][0]], [goalCoord[1], goalCoord[0]]);
// Simple binary-min-heap keyed by fScore storing node indices.
const heap: number[] = [];
const pos = new Map<number, number>();
const push = (idx: number) => {
heap.push(idx);
let i = heap.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (fScore[heap[parent]] <= fScore[heap[i]]) break;
[heap[parent], heap[i]] = [heap[i], heap[parent]];
i = parent;
}
};
const pop = () => {
const top = heap[0] as number;
const last = heap.pop() as number;
if (heap.length) {
heap[0] = last;
let i = 0;
for (;;) {
const l = 2 * i + 1;
const r = 2 * i + 2;
let smallest = i;
if (l < heap.length && fScore[heap[l]] < fScore[heap[smallest]]) smallest = l;
if (r < heap.length && fScore[heap[r]] < fScore[heap[smallest]]) smallest = r;
if (smallest === i) break;
[heap[i], heap[smallest]] = [heap[smallest], heap[i]];
i = smallest;
}
}
return top;
};
gScore[start] = 0;
fScore[start] = h(start);
push(start);
let visited = 0;
while (heap.length) {
const current = pop();
if (closed[current]) continue;
closed[current] = 1;
visited++;
if (current === goal) {
const path: number[] = [];
let cur: number = goal;
while (cur !== -1) {
path.push(cur);
cur = cameFrom[cur];
}
return { path: path.reverse(), distance: gScore[goal], visited };
}
for (const [nb, baseM] of graph.adj[current]) {
if (closed[nb]) continue;
const key = current < nb ? `${current}|${nb}` : `${nb}|${current}`;
const mult = penalty.get(key) ?? 1;
if (mult === Infinity) continue; // blocked
const cost = baseM * mult;
const tentative = gScore[current] + cost;
if (tentative < gScore[nb]) {
cameFrom[nb] = current;
gScore[nb] = tentative;
fScore[nb] = tentative + h(nb);
// re-insert (heap allows duplicates; closed guard handles it)
push(nb);
}
}
}
return null;
}
export function route(
graph: RouteGraph,
fromLatLon: [number, number],
toLatLon: [number, number],
pois: GeoJSON.Feature[] = [],
avoid: AvoidRule[] = []
): RouteResult {
const start = nearestNode(graph, fromLatLon[0], fromLatLon[1]);
const goal = nearestNode(graph, toLatLon[0], toLatLon[1]);
if (start < 0 || goal < 0) {
return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon };
}
const penalty = buildEdgePenalties(graph, pois, avoid);
const res = shortestPath(graph, start, goal, penalty);
if (!res) {
return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon };
}
const path = res.path.map((i) => graph.coords[i] as [number, number]);
return {
found: true,
path,
distanceM: Math.round(res.distance),
nodesVisited: res.visited,
from: fromLatLon,
to: toLatLon
};
}

View File

@ -48,5 +48,17 @@
"color": "#f59e0b", "color": "#f59e0b",
"featureCount": 7, "featureCount": 7,
"path": "/data/custom/rogers-county.geojson" "path": "/data/custom/rogers-county.geojson"
},
{
"name": "tulsa-dump",
"label": "Tulsa OSM (dump)",
"category": "dump",
"layerType": "poi",
"color": "#8b5cf6",
"featureCount": 12853,
"path": "/data/tulsa-dump-poi.geojson",
"poiPath": "/data/tulsa-dump-poi.geojson",
"graphPath": "/data/tulsa-dump-graph.json",
"graphNodeCount": 55334
} }
] ]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long