/** * The Backpacker Map's places for the whole world, read from OpenStreetMap. * * npm run places:osm every country (an hour or two; resumable) * npm run places:osm -- --countries=turkiye,japan * npm run places:osm -- --fresh forget what was read and start over * * Writes, for each country with places, src/features/backpacker-map/data/osm/.json * - rows of [latitude, longitude, name, kind] - and the index that loads * them on demand (osm-index.generated.ts). Commit both. * * What is read is free and open: OpenStreetMap's own data through the public * Overpass API, ODbL-licensed, credited on the /credits screen and on every * place card. Nothing about anyone is sent. The public server asks to be * used gently, so this reads one kind of place in one country at a time, * waits between reads, and keeps every answer in scripts/.osm-cache (not * committed) so an interrupted run carries on where it stopped. * * A country is kept to the best of each kind - places with a name, a web * page or a phone number first - so the map shows places worth going to * rather than every bench with a tap. */ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { request } from 'node:https'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { COUNTRIES, countryIsoCode } from '../src/features/passport/data/countries.ts'; import { OSM_KINDS } from '../src/features/backpacker-map/data/osm-kinds.ts'; import { keepCell, nearKept, osmScore } from '../src/features/backpacker-map/data/osm-score.ts'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const outDir = join(root, 'src/features/backpacker-map/data/osm'); const cacheDir = join(root, 'scripts/.osm-cache'); // The public Overpass servers share one data set. When one turns a reader away - // it limits how fast it may be asked - the next answers, and the build comes // back round to the first later. const ENDPOINTS = ['overpass-api.de', 'overpass.kumi.systems', 'overpass.private.coffee']; const PAUSE_MS = 4000; let endpointIndex = 0; const args = process.argv.slice(2); const only = args.find((arg) => arg.startsWith('--countries='))?.slice('--countries='.length).split(','); const fresh = args.includes('--fresh'); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function overpass(query) { const host = ENDPOINTS[endpointIndex % ENDPOINTS.length]; return new Promise((resolve, reject) => { const body = `data=${encodeURIComponent(query)}`; const req = request( { host, path: '/api/interpreter', method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': Buffer.byteLength(body), 'user-agent': '197-app place build (197.world)' }, }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); if (res.statusCode !== 200) reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 120)}`)); else { try { resolve(JSON.parse(text)); } catch { reject(new Error('not JSON')); } } }); }, ); req.setTimeout(150_000, () => req.destroy(new Error('timeout'))); req.on('error', (error) => reject(new Error(`${host}: ${error.code ?? error.errors?.[0]?.code ?? error.message ?? 'no answer'}`))); req.end(body); }); } async function read(iso, kind) { const file = join(cacheDir, `${iso}-${kind.code}.json`); if (!fresh && existsSync(file)) { const kept = JSON.parse(readFileSync(file, 'utf8')); console.log(` ${iso} ${kind.code}: ${kept.length} (kept from before)`); return kept; } const started = Date.now(); const query = `[out:json][timeout:120];area["ISO3166-1"="${iso}"]["admin_level"="2"]->.a;(${kind.selectors .map((selector) => `nwr(area.a)${selector};`) .join('')});out center tags ${kind.read};`; let lastError; for (let attempt = 0; attempt < 6; attempt += 1) { try { const answer = await overpass(query); // A query that ran out of time or memory still answers 200, with what it had. if (/runtime error|out of memory|timed out/i.test(answer.remark ?? '')) throw new Error(`partial answer: ${answer.remark}`); mkdirSync(cacheDir, { recursive: true }); writeFileSync(file, JSON.stringify(answer.elements ?? [])); console.log(` ${iso} ${kind.code}: ${(answer.elements ?? []).length} read in ${Math.round((Date.now() - started) / 1000)}s`); await sleep(PAUSE_MS); return answer.elements ?? []; } catch (error) { lastError = error; console.log(` ${iso} ${kind.code}: try ${attempt + 1} failed (${error.message}); trying the next server`); endpointIndex += 1; await sleep(8_000 * (attempt + 1)); } } throw new Error(`${iso} ${kind.code}: ${lastError?.message}`); } function nameOf(tags) { const name = tags['name:en'] ?? tags.name ?? tags.brand ?? tags.operator ?? ''; return name.replace(/\s+/g, ' ').trim().slice(0, 80); } /** * The best of one kind in one country: described places only (osm-score.ts), * best first, and never two within about 350 m of each other - a street of * hostels is one pin worth tapping, not forty. */ function rowsFor(elements, kind) { const candidates = []; const seen = new Set(); for (const element of elements) { const latitude = element.lat ?? element.center?.lat; const longitude = element.lon ?? element.center?.lon; if (typeof latitude !== 'number' || typeof longitude !== 'number') continue; const tags = element.tags ?? {}; const name = nameOf(tags); if (kind.needsName && !name) continue; const score = osmScore(tags); if (score < kind.minScore) continue; const key = `${name}|${latitude.toFixed(3)}|${longitude.toFixed(3)}`; if (seen.has(key)) continue; seen.add(key); candidates.push({ score, row: [Number(latitude.toFixed(5)), Number(longitude.toFixed(5)), name, kind.code] }); } candidates.sort((a, b) => b.score - a.score); const kept = new Set(); const rows = []; for (const { row } of candidates) { if (rows.length >= kind.keep) break; if (nearKept(kept, row[0], row[1])) continue; keepCell(kept, row[0], row[1]); rows.push(row); } return rows; } mkdirSync(outDir, { recursive: true }); process.on('SIGINT', () => { writeIndex(); process.exit(130); }); const written = []; for (const country of COUNTRIES) { const iso = countryIsoCode(country); if (!iso || (only && !only.includes(country.id))) continue; const rows = []; console.log(`${country.id} (${iso})`); for (const kind of OSM_KINDS) { try { rows.push(...rowsFor(await read(iso, kind), kind)); } catch (error) { console.error(`skipped ${country.id} ${kind.code}: ${error.message}`); } } if (rows.length === 0) { // A country that no longer has places worth a pin loses its file. rmSync(join(outDir, `${country.id}.json`), { force: true }); continue; } writeFileSync(join(outDir, `${country.id}.json`), `${JSON.stringify(rows)}\n`); written.push(country.id); console.log(`${country.id}: ${rows.length} places`); } /** * The index covers every country with a file, including the ones written by * an earlier run - and is written whenever the build stops, so a run that is * interrupted (Ctrl+C) still leaves the map knowing every country it has. */ function writeIndex() { const present = readdirSync(outDir).filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)).sort(); writeFileSync( join(root, 'src/features/backpacker-map/data/osm-index.generated.ts'), `// Written by scripts/build-backpacker-places.mjs - do not edit by hand.\n// OpenStreetMap contributors, ODbL: https://www.openstreetmap.org/copyright\nimport type { OsmRow } from './osm-kinds';\n\n/** Each country's places, read only when its map is opened. */\nexport const OSM_COUNTRY_LOADERS: Readonly readonly OsmRow[]>> = {\n${present .map((id) => ` ${JSON.stringify(id)}: () => require('./osm/${id}.json') as readonly OsmRow[],`) .join('\n')}\n};\n`, ); console.log(`index: ${present.length} countries`); } writeIndex();