when-am-i / files / scripts / gen-cities.mts
gen-cities.mts
| 1 | // Generate the bundled offline city dataset used for the "near <city>, <country>" |
| 2 | // readout (see src/utils/nearestCity.ts). Data comes from GeoNames, which is the |
| 3 | // source of truth — we do not hand-author coordinates. |
| 4 | // |
| 5 | // npm run gen:cities |
| 6 | // |
| 7 | // It downloads GeoNames' `cities15000` dump (all cities with population ≥ 15,000; |
| 8 | // public domain coordinates under CC BY 4.0), keeps the larger cities (population |
| 9 | // ≥ MIN_POPULATION) so the readout names a recognizable "largest city", and |
| 10 | // writes a compact src/data/cities.json: each city as [name, ISO-3166 alpha-2 |
| 11 | // country code, lat, lng], coordinates rounded to ~110 m. Country *names* are |
| 12 | // derived at runtime via Intl.DisplayNames, so no country table is bundled. |
| 13 | // |
| 14 | // Attribution (required by the licence) lives in the README. |
| 15 | |
| 16 | import { writeFile, readFile, mkdtemp } from 'node:fs/promises'; |
| 17 | import { execFileSync } from 'node:child_process'; |
| 18 | import path from 'node:path'; |
| 19 | import os from 'node:os'; |
| 20 | import { fileURLToPath } from 'node:url'; |
| 21 | |
| 22 | const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 23 | const OUT = path.join(REPO_ROOT, 'src/data/cities.json'); |
| 24 | const SOURCE_URL = 'https://download.geonames.org/export/dump/cities15000.zip'; |
| 25 | const MIN_POPULATION = 50000; |
| 26 | |
| 27 | // GeoNames tab-separated columns we use (0-indexed). |
| 28 | const COL = { name: 1, lat: 4, lng: 5, country: 8, population: 14 }; |
| 29 | |
| 30 | async function loadGeoNames(): Promise<string> { |
| 31 | const tmp = await mkdtemp(path.join(os.tmpdir(), 'geonames-')); |
| 32 | const zip = path.join(tmp, 'cities15000.zip'); |
| 33 | console.log(`⬇️ ${SOURCE_URL}`); |
| 34 | const res = await fetch(SOURCE_URL); |
| 35 | if (!res.ok) throw new Error(`download failed: ${res.status} ${res.statusText}`); |
| 36 | await writeFile(zip, Buffer.from(await res.arrayBuffer())); |
| 37 | execFileSync('unzip', ['-o', zip, '-d', tmp], { stdio: 'ignore' }); |
| 38 | return readFile(path.join(tmp, 'cities15000.txt'), 'utf-8'); |
| 39 | } |
| 40 | |
| 41 | type City = [string, string, number, number]; // [name, countryCode, lat, lng] |
| 42 | |
| 43 | async function main() { |
| 44 | const text = await loadGeoNames(); |
| 45 | const cities: City[] = []; |
| 46 | for (const line of text.split('\n')) { |
| 47 | if (!line) continue; |
| 48 | const f = line.split('\t'); |
| 49 | const population = Number(f[COL.population]); |
| 50 | if (!Number.isFinite(population) || population < MIN_POPULATION) continue; |
| 51 | const name = f[COL.name]; |
| 52 | const country = f[COL.country]; |
| 53 | const lat = Number(f[COL.lat]); |
| 54 | const lng = Number(f[COL.lng]); |
| 55 | if (!name || !country || !Number.isFinite(lat) || !Number.isFinite(lng)) continue; |
| 56 | cities.push([name, country, Math.round(lat * 1000) / 1000, Math.round(lng * 1000) / 1000]); |
| 57 | } |
| 58 | cities.sort((a, b) => a[1].localeCompare(b[1]) || a[0].localeCompare(b[0])); |
| 59 | |
| 60 | const out = { |
| 61 | _source: 'GeoNames cities15000 (https://www.geonames.org/), CC BY 4.0', |
| 62 | _generatedBy: 'npm run gen:cities', |
| 63 | minPopulation: MIN_POPULATION, |
| 64 | cities, |
| 65 | }; |
| 66 | await writeFile(OUT, JSON.stringify(out) + '\n'); |
| 67 | const kb = ((await readFile(OUT)).byteLength / 1024).toFixed(0); |
| 68 | console.log(`✨ Wrote ${cities.length} cities (pop ≥ ${MIN_POPULATION}) to src/data/cities.json (${kb} KB)`); |
| 69 | } |
| 70 | |
| 71 | main().catch((err) => { |
| 72 | console.error(err); |
| 73 | process.exit(1); |
| 74 | }); |