#!/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 merges any hand-authored custom * zones / points of interest from custom-layers.mjs. Everything is converted * into static GeoJSON files under static/data/ and bundled into the SvelteKit * build as plain static files. * * 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'; import { CUSTOM_LAYERS } from './custom-layers.mjs'; 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; `, roads: ` way["highway"~"motorway|trunk|primary|secondary|tertiary"]({{bbox}}); out geom tags; ` } } }; // --------------------------------------------------------------------------- // 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, category: 'osm', layerType: 'mixed', bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` }; } // --------------------------------------------------------------------------- // Custom layers: convert hand-authored POIs / zones / paths to GeoJSON. // --------------------------------------------------------------------------- function customFeatureToGeoJSON(f, layerId) { if (f.lon !== undefined && f.lat !== undefined) { // Point of interest const props = { name: f.name }; if (f.desc) props.description = f.desc; if (f.address) props.address = f.address; if (f.bearing !== undefined) props.bearing = normalizeBearing(f.bearing); if (f.fov !== undefined) props.fov = Number(f.fov); return { type: 'Feature', id: `custom/${layerId}/${f.name}`, properties: props, geometry: { type: 'Point', coordinates: [f.lon, f.lat] } }; } if (Array.isArray(f.line) && f.line.length >= 2) { // Path / route — LineString. const coords = f.line.map(([lon, lat]) => [lon, lat]); const props = { name: f.name }; if (f.desc) props.description = f.desc; if (f.address) props.address = f.address; return { type: 'Feature', id: `custom/${layerId}/${f.name}`, properties: props, geometry: { type: 'LineString', coordinates: coords } }; } if (Array.isArray(f.polygon) && f.polygon.length >= 3) { // Polygon / zone: close the ring automatically if not closed. const ring = f.polygon.map(([lon, lat]) => [lon, lat]); if (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]) { ring.push([...ring[0]]); } const props = { name: f.name }; if (f.desc) props.description = f.desc; if (f.address) props.address = f.address; return { type: 'Feature', id: `custom/${layerId}/${f.name}`, properties: props, geometry: { type: 'Polygon', coordinates: [ring] } }; } throw new Error(`Feature "${f.name}" in layer "${layerId}" is missing lon/lat (POI), polygon[>=3] (zone), or line[>=2] (path)`); } // Normalize a compass/bearing value to a canonical numeric bearing in degrees // (0-360). Accepts compass points (N, NNE, NE, NE-by-N, SE, ...), 16-point rose // abbreviations, or a numeric degree value. Returns null if unrecognized. function normalizeBearing(val) { const COMPASS = { N: 0, 'N-by-E': 11.25, NNE: 22.5, 'NE-by-N': 33.75, NE: 45, 'NE-by-E': 56.25, ENE: 67.5, 'E-by-N': 78.75, E: 90, 'E-by-S': 101.25, ESE: 112.5, 'SE-by-E': 123.75, SE: 135, 'SE-by-S': 146.25, SSE: 157.5, 'S-by-E': 168.75, S: 180, 'S-by-W': 191.25, SSW: 202.5, 'SW-by-S': 213.75, SW: 225, 'SW-by-W': 236.25, WSW: 247.5, 'W-by-S': 258.75, W: 270, 'W-by-N': 281.25, WNW: 292.5, 'NW-by-W': 303.75, NW: 315, 'NW-by-N': 326.25, NNW: 337.5, 'N-by-W': 348.75 }; if (typeof val === 'number') { const n = ((Number(val) % 360) + 360) % 360; return Math.round(n * 10) / 10; } const raw = String(val).trim().toUpperCase(); if (raw in COMPASS) return COMPASS[raw]; // Maybe a numeric string like "45" or "45.0" const num = Number(val); if (!Number.isNaN(num)) return Math.round(((num % 360) + 360) % 360 * 10) / 10; return null; } async function buildCustomLayers() { const entries = []; for (const layer of CUSTOM_LAYERS) { if (!layer?.id || !layer?.name) { console.warn(' [warn] skipping custom layer: missing id or name'); continue; } const features = (layer.features || []).map((f) => customFeatureToGeoJSON(f, layer.id)); const fc = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features }; const dir = resolve(OUT_DIR, 'custom'); await mkdir(dir, { recursive: true }); const outPath = resolve(dir, `${layer.id}.geojson`); await writeFile(outPath, JSON.stringify(fc)); entries.push({ name: layer.id, label: layer.name, category: 'custom', layerType: layer.layerType === 'path' ? 'path' : (layer.layerType === 'zone' ? 'zone' : 'poi'), color: layer.color || '#e11d48', featureCount: features.length, path: `/data/custom/${layer.id}.geojson` }); console.log(` custom layer "${layer.name}" -> ${features.length} features -> ${outPath}`); } return entries; } // --------------------------------------------------------------------------- // 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)); } console.log('Merging custom zones / POIs...'); manifest.push(...await buildCustomLayers()); 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); });