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