Initial Navigator app: SvelteKit static build + OSM Overpass data pipeline
- SvelteKit project with @sveltejs/adapter-static (runs fully static) - scripts/osm-data.mjs: pulls OSM data from Overpass API and converts to static GeoJSON under static/data/ with per-area manifest - Leaflet map frontend that loads the static data and renders features with popups and area selection dropdown - npm run build fetches data then produces a self-contained static site
This commit is contained in:
parent
492916e0c5
commit
3413b7053f
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
102
README.md
102
README.md
@ -1,3 +1,101 @@
|
|||||||
# navigator
|
# Navigator
|
||||||
|
|
||||||
Software project repository
|
A custom web application that serves **OpenStreetMap (OSM) data**. It has a build
|
||||||
|
process that:
|
||||||
|
|
||||||
|
1. **Pulls** OSM data from the [Overpass API](https://overpass-api.de/) for
|
||||||
|
configurable geographic areas and feature types.
|
||||||
|
2. **Converts** it to static **GeoJSON** files.
|
||||||
|
3. **Builds** a **SvelteKit** frontend (with the static adapter) into a plain
|
||||||
|
static site that renders the data on an interactive **Leaflet** map.
|
||||||
|
|
||||||
|
The final output in `build/` is a fully self-contained static site — no server
|
||||||
|
runtime required. You can serve it with any static HTTP server (nginx, Apache,
|
||||||
|
`python -m http.server`, S3/Cloudflare Pages, etc.).
|
||||||
|
|
||||||
|
## Built With
|
||||||
|
|
||||||
|
- [SvelteKit](https://svelte.dev/kit) (Svelte 5, runes mode)
|
||||||
|
- [@sveltejs/adapter-static](https://kit.svelte.dev/docs/adapter-static) — outputs a static site
|
||||||
|
- [Leaflet](https://leafletjs.com/) — interactive map on the frontend
|
||||||
|
- [Overpass API](https://overpass-api.de/) — OSM data source
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Fetch & convert OSM data into static/data/*.geojson
|
||||||
|
npm run build:data
|
||||||
|
|
||||||
|
# Full production build: (fetch data) + (static site) -> build/
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview the production build locally
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite dev server (your data in static/data is served too)
|
||||||
|
npm run check # Type + Svelte checks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Process
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| -------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `npm run build:data` | Runs `scripts/osm-data.mjs` to fetch OSM data → `static/data/` |
|
||||||
|
| `npm run build` | `build:data` followed by the SvelteKit static build → `build/` |
|
||||||
|
| `npm run preview` | Serves the `build/` output locally |
|
||||||
|
|
||||||
|
### Data layout
|
||||||
|
|
||||||
|
```
|
||||||
|
static/data/
|
||||||
|
├── _index.json # Manifest of all built areas (drives the UI dropdown)
|
||||||
|
└── <area>.geojson # One FeatureCollection per configured area
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend loads `/data/_index.json`, then fetches the GeoJSON file(s) for the
|
||||||
|
selected area(s) and renders them on the map with popups.
|
||||||
|
|
||||||
|
## Configuring Areas & Queries
|
||||||
|
|
||||||
|
Edit **`scripts/osm-data.mjs`** — the `AREAS` object describes each area:
|
||||||
|
|
||||||
|
- `bbox` — `[south, west, north, east]` in decimal degrees.
|
||||||
|
- `queries` — a map of name → Overpass QL snippet. Use `{{bbox}}` as the
|
||||||
|
placeholder for the area's bounding box.
|
||||||
|
- The script falls back across multiple public Overpass mirrors if one is
|
||||||
|
overloaded, and is polite to the API (short delay between queries).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
parks: `
|
||||||
|
way["leisure"="park"]({{bbox}});
|
||||||
|
out center tags geom;
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
List configured queries with `npm run data -- --queries`, or build a single area
|
||||||
|
with `npm run data -- --area=tulsa`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The `build/` directory is entirely static. Serve it directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd build
|
||||||
|
python3 -m http.server 8080
|
||||||
|
# or: nginx -s listen 8080; root /path/to/build; (with a fallback to index.html)
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the site uses a static adapter with prerendering, everything can be
|
||||||
|
hosted on any static file host or CDN.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
Hosted on Gitea: https://gitea.thecookiejar.me/hermes-explorigin/navigator
|
||||||
1333
package-lock.json
generated
Normal file
1333
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "navigator",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"data": "node scripts/osm-data.mjs",
|
||||||
|
"data:area": "node scripts/osm-data.mjs",
|
||||||
|
"build:data": "node scripts/osm-data.mjs",
|
||||||
|
"build": "npm run build:data && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
203
scripts/osm-data.mjs
Normal file
203
scripts/osm-data.mjs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* OSM Data Build Script
|
||||||
|
* ---------------------
|
||||||
|
* Queries the OpenStreetMap Overpass API for one or more configurable areas,
|
||||||
|
* fetches the requested feature types, and converts the results into static
|
||||||
|
* GeoJSON files under static/data/. These are bundled into the SvelteKit
|
||||||
|
* build and served as plain static files by the frontend.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/osm-data.mjs # build all areas
|
||||||
|
* node scripts/osm-data.mjs --area=tulsa # build a single area
|
||||||
|
* node scripts/osm-data.mjs --queries # list available queries
|
||||||
|
*
|
||||||
|
* Output layout:
|
||||||
|
* static/data/<area>.geojson # merged FeatureCollection per area
|
||||||
|
* static/data/_index.json # manifest of all built areas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = resolve(__dirname, '..');
|
||||||
|
const OUT_DIR = resolve(ROOT, 'static', 'data');
|
||||||
|
|
||||||
|
const OVERPASS_ENDPOINTS = [
|
||||||
|
'https://overpass-api.de/api/interpreter',
|
||||||
|
'https://overpass.kumi.systems/api/interpreter',
|
||||||
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration: edit this to describe the areas and features you want.
|
||||||
|
// Each query is an Overpass QL snippet; the request is wrapped with the
|
||||||
|
// [out:json] output format. Use {{bbox}} as a placeholder for the area bbox.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const AREAS = {
|
||||||
|
tulsa: {
|
||||||
|
label: 'Tulsa, Oklahoma',
|
||||||
|
bbox: [35.9, -96.1, 36.3, -95.7], // [south, west, north, east]
|
||||||
|
queries: {
|
||||||
|
schools: `
|
||||||
|
(
|
||||||
|
node["amenity"="school"]({{bbox}});
|
||||||
|
way["amenity"="school"]({{bbox}});
|
||||||
|
relation["amenity"="school"]({{bbox}});
|
||||||
|
);
|
||||||
|
out center tags;
|
||||||
|
`,
|
||||||
|
firestations: `
|
||||||
|
(
|
||||||
|
node["amenity"="fire_station"]({{bbox}});
|
||||||
|
way["amenity"="fire_station"]({{bbox}});
|
||||||
|
);
|
||||||
|
out center tags;
|
||||||
|
`,
|
||||||
|
parks: `
|
||||||
|
way["leisure"="park"]({{bbox}});
|
||||||
|
out center tags geom;
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overpass API client with retry/fallback across endpoints.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function overpassFetch(query, signal) {
|
||||||
|
const body = new URLSearchParams({ data: query });
|
||||||
|
let lastErr;
|
||||||
|
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||||
|
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} from ${endpoint}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
console.warn(` [warn] endpoint failed: ${endpoint} -> ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`All Overpass endpoints failed. Last error: ${lastErr?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms, signal) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overpass JSON -> GeoJSON conversion helpers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function coordsFor(el) {
|
||||||
|
if (el.type === 'node') return [el.lon, el.lat];
|
||||||
|
if (el.center) return [el.center.lon, el.center.lat]; // out center
|
||||||
|
if (el.geometry && el.geometry.length) { // way geom outline
|
||||||
|
return el.geometry.map((p) => [p.lon, p.lat]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryFor(el) {
|
||||||
|
const coords = coordsFor(el);
|
||||||
|
if (!coords) return null;
|
||||||
|
// Preserve polygons for closed ways when we have full geometry.
|
||||||
|
if (el.type === 'way' && el.geometry && el.geometry.length > 2 &&
|
||||||
|
coords[0][0] === coords[coords.length - 1][0] &&
|
||||||
|
coords[0][1] === coords[coords.length - 1][1]) {
|
||||||
|
return { type: 'Polygon', coordinates: [coords] };
|
||||||
|
}
|
||||||
|
if (coords.length === 1) return { type: 'Point', coordinates: coords[0] };
|
||||||
|
return { type: 'LineString', coordinates: coords };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFeature(el) {
|
||||||
|
const geom = geometryFor(el);
|
||||||
|
if (!geom) return null;
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
id: `${el.type}/${el.id}`,
|
||||||
|
properties: { ...(el.tags || {}), _type: el.type, _id: el.id },
|
||||||
|
geometry: geom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build a single area.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function buildArea(name, cfg, signal) {
|
||||||
|
console.log(`\n==> Building area "${name}" (${cfg.label})`);
|
||||||
|
const [south, west, north, east] = cfg.bbox;
|
||||||
|
const bboxStr = `${south},${west},${north},${east}`;
|
||||||
|
const features = [];
|
||||||
|
|
||||||
|
for (const [qname, qbody] of Object.entries(cfg.queries)) {
|
||||||
|
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||||
|
const ql = `[out:json][timeout:180];\n${qbody.replaceAll('{{bbox}}', bboxStr)}`;
|
||||||
|
console.log(` - query: ${qname} (${bboxStr})`);
|
||||||
|
try {
|
||||||
|
const json = await overpassFetch(ql, signal);
|
||||||
|
const els = json.elements || [];
|
||||||
|
let count = 0;
|
||||||
|
for (const el of els) {
|
||||||
|
const f = toFeature(el);
|
||||||
|
if (f) { features.push(f); count++; }
|
||||||
|
}
|
||||||
|
console.log(` fetched ${els.length} elements, converted ${count} features`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` [error] query ${qname} failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
await wait(1500, signal); // be polite to the Overpass API
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features };
|
||||||
|
const outPath = resolve(OUT_DIR, `${name}.geojson`);
|
||||||
|
await writeFile(outPath, JSON.stringify(fc));
|
||||||
|
console.log(` wrote ${features.length} features -> ${outPath}`);
|
||||||
|
return { name, label: cfg.label, bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--queries')) {
|
||||||
|
console.log('Configured areas and queries:\n');
|
||||||
|
for (const [name, cfg] of Object.entries(AREAS)) {
|
||||||
|
console.log(` ${name} (${cfg.label})`);
|
||||||
|
for (const q of Object.keys(cfg.queries)) console.log(` - ${q}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlyArg = args.find((a) => a.startsWith('--area='));
|
||||||
|
const only = onlyArg ? onlyArg.split('=')[1] : null;
|
||||||
|
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const manifest = [];
|
||||||
|
for (const [name, cfg] of Object.entries(AREAS)) {
|
||||||
|
if (only && name !== only) continue;
|
||||||
|
manifest.push(await buildArea(name, cfg, signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(resolve(OUT_DIR, '_index.json'), JSON.stringify(manifest, null, 2));
|
||||||
|
console.log(`\nWrote manifest -> ${resolve(OUT_DIR, '_index.json')}`);
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
149
src/lib/components/MapView.svelte
Normal file
149
src/lib/components/MapView.svelte
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
|
|
||||||
|
type Feature = {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
geometry: { type: string; coordinates: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureCollection = {
|
||||||
|
type: string;
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** URL(s) to one or more static GeoJSON files to load. */
|
||||||
|
dataUrls: string[];
|
||||||
|
/** Center [lat, lon]. */
|
||||||
|
center?: [number, number];
|
||||||
|
/** Initial zoom. */
|
||||||
|
zoom?: number;
|
||||||
|
/** Title shown in the header. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
dataUrls = [],
|
||||||
|
center = [36.1, -95.9],
|
||||||
|
zoom = 12,
|
||||||
|
title = 'Navigator'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let map: LeafletMap = $state() as LeafletMap;
|
||||||
|
let markers = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
let L;
|
||||||
|
try {
|
||||||
|
L = (await import('leaflet')).default;
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load Leaflet: ${(e as Error).message}`;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map = L.map(container).setView(center, zoom);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const allLayers: L.Layer[] = [];
|
||||||
|
try {
|
||||||
|
for (const url of dataUrls) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`fetch ${url}: HTTP ${res.status}`);
|
||||||
|
const fc: FeatureCollection = await res.json();
|
||||||
|
const layer = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
|
||||||
|
pointToLayer: (_f, latlng) => L.circleMarker(latlng, {
|
||||||
|
radius: 6,
|
||||||
|
fillColor: '#e11d48',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}),
|
||||||
|
style: {
|
||||||
|
color: '#0284c7',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: '#38bdf8',
|
||||||
|
fillOpacity: 0.25
|
||||||
|
},
|
||||||
|
onEachFeature: (f, lay) => {
|
||||||
|
markers++;
|
||||||
|
lay.bindPopup(popupHtml(f));
|
||||||
|
}
|
||||||
|
}).addTo(map);
|
||||||
|
allLayers.push(layer);
|
||||||
|
}
|
||||||
|
if (allLayers.length) {
|
||||||
|
const group = L.featureGroup(allLayers);
|
||||||
|
map.fitBounds(group.getBounds(), { padding: [40, 40] });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function popupHtml(f: GeoJSON.Feature): string {
|
||||||
|
const props = (f.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const name = (props.name as string | undefined) ?? String(f.id ?? '');
|
||||||
|
const rows = Object.entries(props)
|
||||||
|
.filter(([k]) => !k.startsWith('_'))
|
||||||
|
.map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
|
||||||
|
.join('');
|
||||||
|
return `
|
||||||
|
<strong>${name}</strong>
|
||||||
|
<table class="popup">${rows}</table>`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<span class="meta">
|
||||||
|
{#if markers > 0}
|
||||||
|
{markers} features loaded
|
||||||
|
{:else}OSM data
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}<div class="notice">Loading map & data…</div>{/if}
|
||||||
|
{#if error}<div class="notice error">Error: {error}</div>{/if}
|
||||||
|
|
||||||
|
<div class="map" bind:this={container}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap { display: grid; grid-template-rows: auto 1fr; height: 100vh; }
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 1.25rem; height: 56px;
|
||||||
|
background: #0f172a; color: #fff; font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
|
||||||
|
.meta { font-size: 0.8rem; color: #94a3b8; }
|
||||||
|
.map { width: 100%; height: 100%; z-index: 0; }
|
||||||
|
.notice {
|
||||||
|
position: absolute; top: 66px; left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 1000; background: #0f172a; color: #fff;
|
||||||
|
padding: 6px 14px; border-radius: 999px; font: 0.8rem system-ui, sans-serif;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
.notice.error { background: #b91c1c; }
|
||||||
|
:global(.popup) { border-collapse: collapse; margin-top: 4px; font-size: 0.75rem; }
|
||||||
|
:global(.popup th) { text-align: left; padding-right: 10px; color: #64748b; }
|
||||||
|
:global(.popup td) { padding: 1px 0; }
|
||||||
|
</style>
|
||||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
11
src/routes/+layout.svelte
Normal file
11
src/routes/+layout.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
2
src/routes/+layout.ts
Normal file
2
src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const prerender = true;
|
||||||
|
export const ssr = false;
|
||||||
95
src/routes/+page.svelte
Normal file
95
src/routes/+page.svelte
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import MapView from '$lib/components/MapView.svelte';
|
||||||
|
|
||||||
|
type AreaManifest = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
bbox: number[];
|
||||||
|
featureCount: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let areas = $state<AreaManifest[]>([]);
|
||||||
|
let selected = $state('all');
|
||||||
|
let loading = $state(true);
|
||||||
|
let dataUrls = $state<string[]>([]);
|
||||||
|
let manifestError = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/data/_index.json');
|
||||||
|
if (!res.ok) throw new Error(`manifest HTTP ${res.status}`);
|
||||||
|
areas = await res.json();
|
||||||
|
// Default view: all areas on the map.
|
||||||
|
dataUrls = areas.map((a) => a.path);
|
||||||
|
} catch (e) {
|
||||||
|
manifestError = (e as Error).message;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSelect() {
|
||||||
|
if (selected === 'all') {
|
||||||
|
dataUrls = areas.map((a) => a.path);
|
||||||
|
} else {
|
||||||
|
const found = areas.find((a) => a.name === selected);
|
||||||
|
dataUrls = found ? [found.path] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFeatures = $derived(areas.reduce((n, a) => n + a.featureCount, 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Navigator — OSM Map</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Area
|
||||||
|
<select bind:value={selected} onchange={() => onSelect()}>
|
||||||
|
<option value="all">All areas</option>
|
||||||
|
{#each areas as a (a.name)}
|
||||||
|
<option value={a.name}>{a.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if areas.length}
|
||||||
|
<span class="stats">{totalFeatures} total features across {areas.length} area(s)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if manifestError}
|
||||||
|
<div class="error">
|
||||||
|
Could not load data manifest (<code>/data/_index.json</code>).<br />
|
||||||
|
Run <code>npm run build:data</code> to fetch OSM data, then rebuild.
|
||||||
|
<p class="detail">{manifestError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if dataUrls.length}
|
||||||
|
<MapView {dataUrls} title="Navigator" />
|
||||||
|
{:else if !loading}
|
||||||
|
<div class="error">No built data areas found.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app { display: grid; grid-template-rows: auto 1fr; height: 100vh; }
|
||||||
|
.controls {
|
||||||
|
display: flex; align-items: center; gap: 1.25rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #1e293b; color: #e2e8f0;
|
||||||
|
font: 0.85rem system-ui, sans-serif; z-index: 500;
|
||||||
|
}
|
||||||
|
.controls label { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
select {
|
||||||
|
background: #0f172a; color: #fff; border: 1px solid #475569;
|
||||||
|
border-radius: 6px; padding: 4px 8px; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.stats { color: #94a3b8; margin-left: auto; }
|
||||||
|
.error {
|
||||||
|
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
|
||||||
|
}
|
||||||
|
.detail { color: #64748b; font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
14
static/data/_index.json
Normal file
14
static/data/_index.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "tulsa",
|
||||||
|
"label": "Tulsa, Oklahoma",
|
||||||
|
"bbox": [
|
||||||
|
35.9,
|
||||||
|
-96.1,
|
||||||
|
36.3,
|
||||||
|
-95.7
|
||||||
|
],
|
||||||
|
"featureCount": 512,
|
||||||
|
"path": "/data/tulsa.geojson"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
static/data/tulsa.geojson
Normal file
1
static/data/tulsa.geojson
Normal file
File diff suppressed because one or more lines are too long
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
sveltekit({
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) =>
|
||||||
|
filename.split(/[\\/]/).includes('node_modules') ? undefined : true
|
||||||
|
},
|
||||||
|
// Static adapter: builds the app into static HTML/JS/CSS that can be
|
||||||
|
// served by any static HTTP server (nginx, python -m http.server, etc).
|
||||||
|
adapter: adapter()
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user