This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/scripts/build-cities.mjs
T
2026-08-01 17:58:03 +02:00

120 lines
4.4 KiB
JavaScript

/**
* Regenerates static/data/cities/ - the city lists the "nearby cities" section
* on the week page picks from.
*
* Source: GeoNames (CC BY 4.0) via the cities500 mirror.
*
* The full list of towns down to 20k inhabitants is ~1 MB, far too much to ship
* to a phone for one section, so it is cut into 10x10 degree tiles. A tile
* holds every town within NEAR_RADIUS_KM of anywhere inside it, plus the larger
* cities within FAR_RADIUS_KM - so one small fetch answers both "what is around
* the corner" and "what is the nearest place you would recognise" (which is all
* a location in, say, the Australian outback can offer). Tiles overlap by
* design; duplicating a few rows is cheaper than a second round trip.
*
* The ids are GeoNames ids, which is what Open-Meteo's geocoding API returns,
* so a row can be turned into a location route without another lookup.
*
* Run with: node scripts/build-cities.mjs
*/
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const SOURCE = 'https://raw.githubusercontent.com/lmfmaier/cities-json/master/cities500.json';
const MIN_POPULATION = 20_000;
/** every city of any size within this range of the tile */
const NEAR_RADIUS_KM = 400;
/** only cities above FAR_MIN_POPULATION out to here */
const FAR_RADIUS_KM = 1500;
const FAR_MIN_POPULATION = 300_000;
const TILE_DEGREES = 10;
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'data', 'cities');
// CITIES_SOURCE_FILE lets you build from an already-downloaded copy, which is
// also the only way to run this behind a proxy that node doesn't pick up.
const localSource = process.env.CITIES_SOURCE_FILE;
const source = localSource
? JSON.parse(await readFile(localSource, 'utf8'))
: await fetch(SOURCE).then((res) => {
if (!res.ok) throw new Error(`source fetch failed: ${res.status}`);
return res.json();
});
const cities = source
.map((c) => ({
id: Number(c.id),
name: c.name,
country: c.country,
lat: Number(c.lat),
lon: Number(c.lon),
pop: Number(c.pop ?? 0)
}))
.filter((c) => Number.isFinite(c.lat) && Number.isFinite(c.lon) && c.pop >= MIN_POPULATION)
// biggest first, so a tile that gets truncated keeps the recognisable names
.sort((a, b) => b.pop - a.pop);
const toRad = Math.PI / 180;
/** Distance from a point to the nearest point of a lat/lon rectangle. */
function distanceToTileKm(lat, lon, latMin, latMax, lonMin, lonMax) {
const clampedLat = Math.min(latMax, Math.max(latMin, lat));
// longitude distance shrinks towards the poles, measured at the closest latitude
let dLon = 0;
if (lon < lonMin) dLon = lonMin - lon;
else if (lon > lonMax) dLon = lon - lonMax;
if (dLon > 180) dLon = 360 - dLon;
const dLat = Math.abs(clampedLat - lat);
const lonKm = dLon * 111.32 * Math.cos(clampedLat * toRad);
return Math.hypot(dLat * 111.32, lonKm);
}
const tiles = new Map();
for (let latIndex = 0; latIndex < 180 / TILE_DEGREES; latIndex++) {
for (let lonIndex = 0; lonIndex < 360 / TILE_DEGREES; lonIndex++) {
const latMin = -90 + latIndex * TILE_DEGREES;
const lonMin = -180 + lonIndex * TILE_DEGREES;
const bounds = [latMin, latMin + TILE_DEGREES, lonMin, lonMin + TILE_DEGREES];
const rows = [];
for (const c of cities) {
const dist = distanceToTileKm(c.lat, c.lon, ...bounds);
const inRange =
dist <= NEAR_RADIUS_KM || (c.pop >= FAR_MIN_POPULATION && dist <= FAR_RADIUS_KM);
if (!inRange) continue;
// tuples, not objects: same data, roughly half the bytes
rows.push([
c.id,
c.name,
c.country,
+c.lat.toFixed(3),
+c.lon.toFixed(3),
Math.round(c.pop / 1000)
]);
}
if (rows.length > 0) tiles.set(`${latIndex}_${lonIndex}`, rows);
}
}
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
let bytes = 0;
for (const [key, rows] of tiles) {
const json = JSON.stringify(rows);
bytes += json.length;
await writeFile(join(outDir, `${key}.json`), json, 'utf8');
}
// the index tells the client which tiles exist, so an empty ocean tile is a
// no-op instead of a 404
await writeFile(join(outDir, 'index.json'), JSON.stringify([...tiles.keys()]), 'utf8');
const largest = Math.max(...[...tiles.values()].map((r) => r.length));
console.log(
`wrote ${tiles.size} tiles (${(bytes / 1024 / 1024).toFixed(1)} MB total, ` +
`avg ${Math.round(bytes / tiles.size / 1024)} KB, largest ${largest} cities) to ${outDir}`
);