120 lines
4.4 KiB
JavaScript
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}`
|
|
);
|