138 lines
5.1 KiB
JavaScript
138 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Import all named OSM peaks in the Alpine region into the Streba database.
|
|
//
|
|
// node scripts/import-peaks.js [--db data/streba.db]
|
|
//
|
|
// Fetches natural=peak nodes from the Overpass API, scores them for
|
|
// notability, assigns per-zoom visibility (minzoom), upgrades the built-in
|
|
// seed peaks to their OSM counterparts (keeping everyone's ascents), and
|
|
// recomputes visibility across the whole catalog. Idempotent - safe to
|
|
// re-run to refresh the data.
|
|
|
|
import Database from 'better-sqlite3';
|
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
|
import { assignMinzoom, scorePeak } from '../src/lib/server/peak-score.js';
|
|
|
|
// honor HTTP(S)_PROXY/NO_PROXY env vars (no-op when unset)
|
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
|
|
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
|
// the Alps, generously: Nice to Vienna
|
|
const BBOX = '43.0,4.7,48.7,16.8';
|
|
const QUERY = `[out:json][timeout:600];node["natural"="peak"]["name"](${BBOX});out body;`;
|
|
|
|
const dbPath = process.argv.includes('--db')
|
|
? process.argv[process.argv.indexOf('--db') + 1]
|
|
: (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
|
|
|
|
function parseEle(raw) {
|
|
if (raw === undefined) return null;
|
|
const ele = parseFloat(String(raw).replace(',', '.'));
|
|
return Number.isFinite(ele) && ele > 0 && ele < 5000 ? ele : null;
|
|
}
|
|
|
|
console.log(`Fetching peaks from Overpass (bbox ${BBOX}) - this can take a few minutes…`);
|
|
const response = await fetch(OVERPASS_URL, {
|
|
method: 'POST',
|
|
body: 'data=' + encodeURIComponent(QUERY),
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': 'streba-peak-import/1.0 (self-hosted GPX analyser)'
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
console.error(`Overpass request failed: ${response.status} ${response.statusText}`);
|
|
process.exit(1);
|
|
}
|
|
const osm = await response.json();
|
|
console.log(`Received ${osm.elements.length} peaks.`);
|
|
|
|
const peaks = osm.elements
|
|
.filter((el) => el.type === 'node' && el.tags?.name)
|
|
.map((el) => ({
|
|
osm_id: `node:${el.id}`,
|
|
name: el.tags.name,
|
|
elevation_m: parseEle(el.tags.ele),
|
|
lat: el.lat,
|
|
lon: el.lon,
|
|
wikipedia: el.tags.wikipedia ?? null,
|
|
wikidata: el.tags.wikidata ?? null,
|
|
prominence_m: parseEle(el.tags.prominence),
|
|
score: 0,
|
|
minzoom: 14
|
|
}));
|
|
for (const p of peaks) p.score = scorePeak(p);
|
|
|
|
const db = new Database(dbPath);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
const hasPeaksTable = db
|
|
.prepare(`SELECT count(*) n FROM sqlite_master WHERE type = 'table' AND name = 'peaks'`)
|
|
.get().n;
|
|
if (!hasPeaksTable) {
|
|
console.error(`No peaks table in ${dbPath} - start the app once first so it creates the schema.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const upsert = db.prepare(`
|
|
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, wikipedia, wikidata, score, minzoom)
|
|
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @wikipedia, @wikidata, @score, @minzoom)
|
|
ON CONFLICT (osm_id) DO UPDATE SET
|
|
name = excluded.name, elevation_m = excluded.elevation_m,
|
|
lat = excluded.lat, lon = excluded.lon,
|
|
wikipedia = excluded.wikipedia, wikidata = excluded.wikidata,
|
|
score = excluded.score
|
|
`);
|
|
|
|
db.transaction(() => {
|
|
for (const p of peaks) upsert.run(p);
|
|
|
|
// upgrade curated seed rows to their OSM counterpart, keeping ascents.
|
|
// OSM names are often multilingual ("Mont Blanc / Monte Bianco"), so match
|
|
// by name containment first, then by plain summit proximity.
|
|
const seeds = db.prepare(`SELECT * FROM peaks WHERE osm_id LIKE 'seed:%'`).all();
|
|
const candidatesFor = db.prepare(
|
|
`SELECT id, name, lat, lon, elevation_m FROM peaks
|
|
WHERE osm_id LIKE 'node:%' AND abs(lat - ?) < 0.01 AND abs(lon - ?) < 0.015`
|
|
);
|
|
let upgraded = 0;
|
|
for (const seed of seeds) {
|
|
const candidates = candidatesFor.all(seed.lat, seed.lon);
|
|
const seedName = seed.name.toLowerCase();
|
|
const dist = (c) => Math.hypot(c.lat - seed.lat, (c.lon - seed.lon) * 0.7);
|
|
const byName = candidates
|
|
.filter((c) => {
|
|
const n = c.name.toLowerCase();
|
|
return n.includes(seedName) || seedName.includes(n);
|
|
})
|
|
.sort((a, b) => dist(a) - dist(b))[0];
|
|
const byProximity = candidates
|
|
.filter(
|
|
(c) =>
|
|
dist(c) < 0.004 &&
|
|
(c.elevation_m == null || Math.abs(c.elevation_m - seed.elevation_m) < 200)
|
|
)
|
|
.sort((a, b) => dist(a) - dist(b))[0];
|
|
const match = byName ?? byProximity;
|
|
if (match) {
|
|
db.prepare('UPDATE OR IGNORE ascents SET peak_id = ? WHERE peak_id = ?').run(match.id, seed.id);
|
|
db.prepare('DELETE FROM peaks WHERE id = ?').run(seed.id);
|
|
upgraded++;
|
|
}
|
|
}
|
|
console.log(`Upgraded ${upgraded}/${seeds.length} seed peaks to OSM nodes.`);
|
|
|
|
// recompute zoom visibility across the merged catalog
|
|
const all = db.prepare('SELECT id, lat, lon, score FROM peaks').all();
|
|
assignMinzoom(all);
|
|
const setZoom = db.prepare('UPDATE peaks SET minzoom = ? WHERE id = ?');
|
|
for (const p of all) setZoom.run(p.minzoom, p.id);
|
|
})();
|
|
|
|
const total = db.prepare('SELECT count(*) n FROM peaks').get().n;
|
|
const byZoom = db
|
|
.prepare('SELECT minzoom, count(*) n FROM peaks GROUP BY minzoom ORDER BY minzoom')
|
|
.all();
|
|
console.log(`Catalog now holds ${total} peaks.`);
|
|
console.log('Visible from zoom:', byZoom.map((r) => `${r.minzoom}: ${r.n}`).join(', '));
|