navigator/scripts/osm-data.mjs
hermes-explorigin 3413b7053f 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
2026-08-08 15:29:51 +00:00

203 lines
7.2 KiB
JavaScript

#!/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);
});