Add OSM data-dump ingestion + in-browser routing with POI avoidance
All checks were successful
CI / test-and-build (push) Successful in 1m3s
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:
parent
0753527d69
commit
db292409f1
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
58
README.md
58
README.md
@ -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
630
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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
266
scripts/osm-dump.mjs
Normal 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);
|
||||||
|
});
|
||||||
@ -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 () => ({
|
||||||
|
|||||||
190
src/lib/components/DirectionsPanel.svelte
Normal file
190
src/lib/components/DirectionsPanel.svelte
Normal 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 & 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>
|
||||||
@ -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
242
src/lib/routing.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
1
static/data/tulsa-dump-graph.json
Normal file
1
static/data/tulsa-dump-graph.json
Normal file
File diff suppressed because one or more lines are too long
1
static/data/tulsa-dump-poi.geojson
Normal file
1
static/data/tulsa-dump-poi.geojson
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user