Files
gods-eye-view/scripts/build-wikidata-cities.mjs
T
Manjunath PaparajuandSameh Khamis 1dc5a67518 Bundle Wikidata cities for offline place lookups
A pack of the world's settlements of 15,000 people or more (31,827 places,
1.6 MB, CC0) built from the Wikidata Query Service by
scripts/build-wikidata-cities.mjs. src/data/wikidataCities.js reads it on the
first lookup and answers the nearest city to a point, the cities around it,
a city by name ("Paris, France", or the nearest namesake in view) and a
country's name in the page language. Names match without accents or case.

The build leaves out groupings of settlements (metropolitan areas,
agglomerations) and places that no longer stand, keeps a city that is also a
narrower grouping kind (Istanbul) or a unit dissolved where nothing else
names the spot (the Vietnamese provincial cities of 2025), fills a country
from the area a place lies in, splits a population band the service cannot
answer in time, and fails if well-known cities are missing or a known
grouping gets in. The app never calls Wikidata itself.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 19:27:46 -08:00

456 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Build the bundled world cities pack read by `src/data/wikidataCities.js`:
*
* src/data/local_data/wikidata_cities/cities.json
*
* Source: Wikidata (CC0), read once from the public query service at build
* time. The app itself never calls Wikidata: it reads this file.
*
* What goes in: items that are a human settlement (an instance of any subclass
* of Q486972), have coordinates (P625) and a population (P1082) at or above
* the cut-off. Each row keeps the English label, coordinates to three
* decimals, the largest stated population, the ISO 3166-1 alpha-2 country
* code and the Wikidata id. Groupings of settlements (metropolitan areas,
* agglomerations, conurbations) and places that no longer stand are left out.
*
* The query service limits each query to 60 s, so the population range is
* read in bands without labels, the settlement test runs here against the
* class list, and labels and countries are read afterwards in batches. One
* request runs at a time, with a pause between requests, and a refusal's
* Retry-After is honoured.
*
* Usage:
* node scripts/build-wikidata-cities.mjs [--min-population 15000]
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ENDPOINT = 'https://query.wikidata.org/sparql';
const USER_AGENT =
'gods-eye-view-places-builder/0.2 (offline data pack; one-off build; https://github.com/bilawalsidhu/gods-eye-view)';
const SETTLEMENT = 'Q486972';
const PAUSE_MS = 1500;
const LABEL_BATCH = 400;
const args = process.argv.slice(2);
const flag = (name) =>
args.includes(name) ? Number(args[args.indexOf(name) + 1]) : NaN;
const minPopulation = flag('--min-population') || 15000;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outDir = path.join(root, 'src/data/local_data/wikidata_cities');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const qid = (uri) => uri.slice(uri.lastIndexOf('/') + 1);
/** The service ran out of time on a query: a smaller one may still answer. */
class QueryTimeout extends Error {}
async function sparql(query, attempt = 1) {
let response;
try {
response = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
Accept: 'application/sparql-results+json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `query=${encodeURIComponent(query)}`,
// The service stops a query at 60 s; past that the answer is lost.
signal: AbortSignal.timeout(90000),
});
} catch (error) {
// Our own time limit: the query is too big, as with a 504.
if (error.name === 'TimeoutError' && attempt >= 2)
throw new QueryTimeout('Wikidata did not answer in time');
if (attempt >= 4) throw error;
console.warn(` ${error.message}; waiting ${20 * attempt} s`);
await sleep(20000 * attempt);
return sparql(query, attempt + 1);
}
if (response.status === 504 && attempt >= 2)
throw new QueryTimeout('Wikidata answered 504');
// A malformed query fails the same way every time.
if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
)
throw new Error(
`Wikidata answered ${response.status}: ${(await response.text()).slice(0, 300)}`,
);
if (!response.ok) {
if (attempt >= 4) throw new Error(`Wikidata answered ${response.status}`);
const wait =
Number(response.headers.get('retry-after')) * 1000 || 20000 * attempt;
console.warn(` ${response.status}; waiting ${Math.round(wait / 1000)} s`);
await sleep(wait);
return sparql(query, attempt + 1);
}
// The service can stop a long answer partway; that arrives as broken JSON.
let rows;
try {
rows = JSON.parse(await response.text()).results.bindings;
} catch (error) {
// The service stops a query that runs too long partway through its answer.
if (attempt >= 2)
throw new QueryTimeout(`Wikidata answer was cut short: ${error.message}`);
console.warn(` answer cut short; waiting ${20 * attempt} s`);
await sleep(20000 * attempt);
return sparql(query, attempt + 1);
}
await sleep(PAUSE_MS);
return rows;
}
/**
* Population bands, narrow where places are many, so each answer stays well
* under the service's 60 s limit (about 35,000 places a band).
*/
function bands(min, max) {
const stepAt = (pop) =>
pop < 2000
? 200
: pop < 5000
? 500
: pop < 10000
? 1000
: pop < 20000
? 2500
: pop < 30000
? 10000
: pop < 50000
? 20000
: pop < 100000
? 50000
: Infinity;
const list = [];
for (let low = min; low < max;) {
const high = Math.min(max, low + stepAt(low));
list.push([low, high]);
low = high;
}
return list;
}
console.log('Settlement classes…');
const classes = new Set(
(await sparql(`SELECT ?c WHERE { ?c wdt:P279* wd:${SETTLEMENT} }`)).map(
(row) => qid(row.c.value),
),
);
console.log(` ${classes.size} classes`);
// Groupings of settlements (metropolitan areas, agglomerations, conurbations,
// capital regions)
// are settlements in Wikidata too, and are left out so a view is named after
// the city rather than the area around it. An item stated as one of these
// classes goes, whatever else it is ("New York metropolitan area" is also a
// megacity). An item of a narrower kind only goes when it is nothing else:
// Istanbul is a "metropolitan municipality in Turkey", a kind of
// metropolitan area, and a megacity, so it stays.
const GROUPING_TREES = [
'Q1907114', // metropolitan area
'Q159313', // urban agglomeration
'Q245260', // conurbation
'Q1062177', // capital region
'Q1768043', // metropolitan statistical area
'Q3175539', // functional urban area
];
// Left out as stated only: "city in the United States" is a subclass of
// these, so their subclasses must stay.
const GROUPING_CLASSES = [
'Q702492', // urban area
'Q2342385', // urban area (statistical)
'Q5951278', // urban area of the United States
];
// Places that no longer stand as settlements (former entities, ghost towns,
// abandoned villages, archaeological sites) are settlements in Wikidata too.
// An item typed only as one of these, with every subclass, is left out; one
// that is also a living settlement (a city that is also a former capital)
// stays.
const HISTORICAL_TREES = [
'Q15893266', // former entity
'Q19953632', // former administrative territorial entity
'Q74047', // ghost town
'Q350895', // abandoned village
'Q839954', // archaeological site
];
const former = new Set(
(
await sparql(
`SELECT ?c WHERE { VALUES ?top { ${HISTORICAL_TREES.map((id) => `wd:${id}`).join(' ')} } ?c wdt:P279* ?top }`,
)
).map((row) => qid(row.c.value)),
);
console.log(` ${former.size} former-place classes`);
const groupings = new Set([
...(
await sparql(
`SELECT ?c WHERE { VALUES ?top { ${GROUPING_TREES.map((id) => `wd:${id}`).join(' ')} } ?c wdt:P279* ?top }`,
)
).map((row) => qid(row.c.value)),
...GROUPING_CLASSES,
]);
console.log(` ${groupings.size} grouping classes left out`);
// A band's rows; one the service cannot answer in time is asked again in two
// halves.
async function bandRows(low, high) {
const range = Number.isFinite(high)
? `?pop >= ${low} && ?pop < ${high}`
: `?pop >= ${low}`;
try {
return await sparql(
`SELECT ?item ?pop ?coord ?type WHERE { ?item wdt:P1082 ?pop . FILTER(${range}) ?item wdt:P625 ?coord ; wdt:P31 ?type . }`,
);
} catch (error) {
const mid = Number.isFinite(high) ? Math.round((low + high) / 2) : low * 4;
if (!(error instanceof QueryTimeout) || mid <= low || mid === high)
throw error;
console.warn(` splitting ${low}–${high} at ${mid}`);
return [...(await bandRows(low, mid)), ...(await bandRows(mid, high))];
}
}
const GROUPING_STATED = new Set([...GROUPING_TREES, ...GROUPING_CLASSES]);
const living = new Set();
const stated = new Set();
const grouped = new Set();
const settled = new Set();
const places = new Map();
for (const [low, high] of bands(minPopulation, Infinity)) {
console.log(`Population ${low}–${Number.isFinite(high) ? high : '∞'}…`);
const rows = await bandRows(low, high);
let kept = 0;
for (const row of rows) {
const type = qid(row.type.value);
if (GROUPING_STATED.has(type)) stated.add(qid(row.item.value));
if (groupings.has(type)) grouped.add(qid(row.item.value));
if (!classes.has(type)) continue;
if (!groupings.has(type) && !former.has(type))
settled.add(qid(row.item.value));
if (!former.has(type)) living.add(qid(row.item.value));
const match = /^Point\(([-\d.eE]+) ([-\d.eE]+)\)$/.exec(row.coord.value);
if (!match) continue;
const id = qid(row.item.value);
const pop = Math.round(Number(row.pop.value));
const known = places.get(id);
if (known) {
known.pop = Math.max(known.pop, pop);
// Of several stated coordinates, the same one on every build.
const lon = Number(match[1]);
const lat = Number(match[2]);
if (lat < known.lat || (lat === known.lat && lon < known.lon)) {
known.lat = lat;
known.lon = lon;
}
continue;
}
places.set(id, { id, pop, lon: Number(match[1]), lat: Number(match[2]) });
kept++;
}
console.log(` ${rows.length} rows, ${kept} new settlements`);
}
let groupingsLeftOut = 0;
for (const id of grouped) {
if ((settled.has(id) && !stated.has(id)) || !places.delete(id)) continue;
groupingsLeftOut++;
}
console.log(`${groupingsLeftOut} groupings left out`);
// A place typed only as a former kind is held until its end date is known: a
// unit dissolved in a recent reform still stands (below).
for (const [id, place] of places) if (!living.has(id)) place.formerOnly = true;
const ids = [...places.keys()];
console.log(`Labels and countries for ${ids.length} places…`);
for (let start = 0; start < ids.length; start += LABEL_BATCH) {
const values = ids
.slice(start, start + LABEL_BATCH)
.map((id) => `wd:${id}`)
.join(' ');
const rows = await sparql(
`SELECT ?item ?label ?iso ?ended WHERE { VALUES ?item { ${values} } OPTIONAL { ?item rdfs:label ?label . FILTER(LANG(?label) = "en") } OPTIONAL { ?item wdt:P17 ?country . ?country wdt:P297 ?iso } OPTIONAL { ?item wdt:P576 ?ended } }`,
);
for (const row of rows) {
const place = places.get(qid(row.item.value));
if (!place) continue;
if (row.label && !place.name) place.name = row.label.value;
// Of several stated countries, the same one on every build.
if (row.iso && (!place.cc || row.iso.value < place.cc))
place.cc = row.iso.value;
// Dissolved, abolished or merged as a unit (P576), and when.
if (row.ended) {
place.ended = true;
const year = Number(/^-?\d+/.exec(row.ended.value)?.[0]);
if (Number.isFinite(year))
place.endedYear = Math.max(place.endedYear ?? -Infinity, year);
}
}
if ((start / LABEL_BATCH) % 10 === 0)
console.log(
` ${Math.min(start + LABEL_BATCH, ids.length)} / ${ids.length}`,
);
}
// A dissolved place goes only where another place still names the spot (a
// city merged into its neighbour, a former district inside its city). Where
// nothing else does, the town is still there under its name and stays: many
// of Vietnam's provincial cities ended as units in 2025, and with them their
// class, so they are typed only as a former kind. A place typed only as a
// former kind stays only that way: dissolved within the last ten years with
// nothing else near it. Ghost towns and ancient cities go.
const SUCCESSOR_KM = 15;
const RECENT_YEARS = 10;
const recent = new Date().getUTCFullYear() - RECENT_YEARS;
const standing = [...places.values()].filter(
(place) => place.name && !place.ended && !place.formerOnly,
);
const near = (a, b) => {
const rad = Math.PI / 180;
const h =
Math.sin(((b.lat - a.lat) * rad) / 2) ** 2 +
Math.cos(a.lat * rad) *
Math.cos(b.lat * rad) *
Math.sin(((b.lon - a.lon) * rad) / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h))) <= SUCCESSOR_KM;
};
const byCell = new Map();
const cellOf = (lat, lon) => `${Math.floor(lat)},${Math.floor(lon)}`;
for (const place of standing) {
const key = cellOf(place.lat, place.lon);
if (!byCell.has(key)) byCell.set(key, []);
byCell.get(key).push(place);
}
let dissolved = 0;
let formerOnly = 0;
for (const [id, place] of places) {
if (place.formerOnly && !(place.ended && place.endedYear >= recent)) {
places.delete(id);
formerOnly++;
continue;
}
if (!place.ended) continue;
let succeeded = false;
for (let dLat = -1; dLat <= 1 && !succeeded; dLat++)
for (let dLon = -1; dLon <= 1 && !succeeded; dLon++)
succeeded = (
byCell.get(cellOf(place.lat + dLat, place.lon + dLon)) || []
).some((other) => near(place, other));
if (succeeded) dissolved++;
else place.ended = false;
}
console.log(`${formerOnly} former places left out`);
console.log(`${dissolved} dissolved places left out`);
// A place whose stated country is a former one (no ISO code) takes the
// country of the area it lies in, one area level up at a time (a path of
// several levels in one query runs past the service's time limit).
const COUNTRY_BATCH = 50;
const countryless = () =>
[...places.values()]
.filter((place) => place.name && !place.cc && !place.ended)
.map((place) => place.id);
for (const hops of [1, 2, 3]) {
const ids = countryless();
if (!ids.length) break;
console.log(`Countries ${hops} area level(s) up for ${ids.length} places…`);
const pathToArea = Array.from({ length: hops }, () => 'wdt:P131').join('/');
for (let start = 0; start < ids.length; start += COUNTRY_BATCH) {
const values = ids
.slice(start, start + COUNTRY_BATCH)
.map((id) => `wd:${id}`)
.join(' ');
const rows = await sparql(
`SELECT ?item ?iso WHERE { VALUES ?item { ${values} } ?item ${pathToArea} ?area . ?area wdt:P17 ?country . ?country wdt:P297 ?iso }`,
);
for (const row of rows) {
const place = places.get(qid(row.item.value));
if (!place || (place.cc && place.hops !== hops)) continue;
if (!place.cc || row.iso.value < place.cc) {
place.cc = row.iso.value;
place.hops = hops;
}
}
}
}
console.log(` ${countryless().length} places still without a country`);
const rows = [...places.values()]
.filter(
(place) =>
place.name &&
!place.ended &&
Math.abs(place.lat) <= 90 &&
Math.abs(place.lon) <= 180,
)
.sort((a, b) => b.pop - a.pop || a.id.localeCompare(b.id))
.map((place) => [
place.name,
Number(place.lat.toFixed(3)),
Number(place.lon.toFixed(3)),
place.pop,
place.cc || '',
place.id,
]);
// A filter that is too wide drops real cities without any error; these must
// survive, and no settlement grouping may.
const MUST_HAVE = {
Q1490: 'Tokyo',
Q84: 'London',
Q16559: 'Austin',
Q16555: 'Houston',
Q1355: 'Bengaluru',
Q3630: 'Jakarta',
Q174: 'São Paulo',
Q34600: 'Kyoto',
Q656: 'Saint Petersburg',
Q406: 'Istanbul',
Q8678: 'Rio de Janeiro',
Q1345: 'Philadelphia',
Q586: 'Bonn',
Q1449: 'Genoa',
Q1563: 'Havana',
// Provincial cities dissolved as units in 2025 that still stand.
Q19491: 'Nha Trang',
Q36081: 'Quy Nhon',
};
// Groupings that are also typed as cities; none may get in.
const MUST_NOT = {
Q683705: 'New York metropolitan area',
Q665894: 'Greater Mexico City',
Q13580: 'Metro Manila',
Q3114019: 'Katowice urban area',
};
const present = new Set(rows.map((row) => row[5]));
const missing = Object.entries(MUST_HAVE).filter(([id]) => !present.has(id));
const intruders = Object.entries(MUST_NOT).filter(([id]) => present.has(id));
if (intruders.length) {
throw new Error(
`Groupings got in: ${intruders.map(([id, name]) => `${name} (${id})`).join(', ')}`,
);
}
if (missing.length) {
throw new Error(
`Expected cities are missing: ${missing.map(([id, name]) => `${name} (${id})`).join(', ')}`,
);
}
const meta = {
source: 'Wikidata (https://www.wikidata.org), via the Wikidata Query Service',
license: 'CC0 1.0',
built: new Date().toISOString(),
minPopulation,
count: rows.length,
columns: ['name', 'lat', 'lon', 'population', 'country', 'wikidata'],
};
await mkdir(outDir, { recursive: true });
const file = path.join(outDir, 'cities.json');
await writeFile(file, `${JSON.stringify({ meta, rows })}\n`);
console.log(`Wrote ${rows.length} places to ${path.relative(root, file)}`);