add user accounts and OSM peak catalog with zoom-based notability

This commit is contained in:
Vincent van der Wal
2026-07-22 11:10:59 +02:00
parent 196f30521c
commit b17f56b194
26 changed files with 1019 additions and 191 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/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(', '));