#!/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(', '));