nearby
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
Reference in New Issue
Block a user