- 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
203 lines
7.2 KiB
JavaScript
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);
|
|
}); |