Some checks failed
CI / test-and-build (push) Failing after 1m18s
- .gitea/workflows/ci.yml: on push/PR, runs npm ci, svelte-check, offline regression tests, and a static (no-Overpass) build, then verifies output - scripts/test.mjs: offline test suite — validates manifest + GeoJSON structure and asserts fuzzy-search regressions against committed data - package.json: add 'test' and 'build:static' scripts - osm-data.mjs: tag OSM layers layerType='mixed' in the manifest
310 lines
12 KiB
JavaScript
310 lines
12 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 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/<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';
|
|
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;
|
|
`
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}); |