fuzzyworld

when-am-i / files / scripts / gen-cities.mts

gen-cities.mts

Blame: the commit that last changed each line · file · history

998b11ce 2026-06-21 Pavel Safronov1// 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
16import { writeFile, readFile, mkdtemp } from 'node:fs/promises';
17import { execFileSync } from 'node:child_process';
18import path from 'node:path';
19import os from 'node:os';
20import { fileURLToPath } from 'node:url';
21
22const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23const OUT = path.join(REPO_ROOT, 'src/data/cities.json');
24const SOURCE_URL = 'https://download.geonames.org/export/dump/cities15000.zip';
25const MIN_POPULATION = 50000;
26
27// GeoNames tab-separated columns we use (0-indexed).
28const COL = { name: 1, lat: 4, lng: 5, country: 8, population: 14 };
29
30async 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
41type City = [string, string, number, number]; // [name, countryCode, lat, lng]
42
43async 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
71main().catch((err) => {
72 console.error(err);
73 process.exit(1);
74});