mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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]>
This commit is contained in:
1 parent
c2e42b5dfd
commit
1dc5a67518
8 files changed
+891
-1
No files matched your search
@@ -419,6 +419,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
|||||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||||
| **Natural Earth disputed areas** (11 of the source's 99 features, hand-picked; [list](src/data/local_data/natural_earth/README.md#disputed-areas-disputed_areasjson)) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
| **Natural Earth disputed areas** (11 of the source's 99 features, hand-picked; [list](src/data/local_data/natural_earth/README.md#disputed-areas-disputed_areasjson)) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||||
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||||
|
| **Wikidata cities** (31,827 settlements of 15,000 people or more: name, position, population, country) | `wikidata_cities/` | **CC0 1.0** (public domain dedication) | ✅ (no restrictions) | "City, town and village names from Wikidata (CC0)" (courtesy credit — not legally required) |
|
||||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||||
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||||
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
||||||
@@ -518,6 +519,20 @@ its width ("Sichuan, China · near Chengdu"), offline, with the states and provi
|
|||||||
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
||||||
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
||||||
|
|
||||||
|
### Wikidata cities (`wikidata_cities/`)
|
||||||
|
|
||||||
|
Settlements of 15,000 people or more that Wikidata states a population and coordinates for,
|
||||||
|
read once from the Wikidata Query Service by `scripts/build-wikidata-cities.mjs`: one row per
|
||||||
|
place with its English label, latitude and longitude to 3 decimals, largest stated population,
|
||||||
|
ISO 3166-1 country code and Wikidata id (31,827 places, 1.6 MB). The pack is read the
|
||||||
|
first time a place name is looked up and gives the nearest city, the places in view and a city
|
||||||
|
found by name (`findPlace`: "Paris, France", or the nearest namesake in view). Groupings of
|
||||||
|
settlements (metropolitan areas, agglomerations) and places that no longer stand are left out
|
||||||
|
unless they are also a living city. Names match without accents ("Sao Paulo" finds São Paulo).
|
||||||
|
CC0; credited "City, town and village names from Wikidata (CC0)" as a courtesy once a name from
|
||||||
|
the pack is shown. Rebuild: `node scripts/build-wikidata-cities.mjs`; provenance in the folder
|
||||||
|
README. The app never calls Wikidata itself.
|
||||||
|
|
||||||
### Sky background (`sky/`)
|
### Sky background (`sky/`)
|
||||||
|
|
||||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
#!/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)}`);
|
||||||
@@ -1465,5 +1465,8 @@
|
|||||||
"src/ui/styles/cockpit-panels.css",
|
"src/ui/styles/cockpit-panels.css",
|
||||||
"src/ui/styles/layer-section-index.css",
|
"src/ui/styles/layer-section-index.css",
|
||||||
"src/ui/styles/layer-finder.css",
|
"src/ui/styles/layer-finder.css",
|
||||||
"src/ui/layerFinder.test.mjs"
|
"src/ui/layerFinder.test.mjs",
|
||||||
|
"src/data/wikidataCities.js",
|
||||||
|
"src/data/wikidataCities.test.mjs",
|
||||||
|
"scripts/build-wikidata-cities.mjs"
|
||||||
]
|
]
|
||||||
@@ -700,6 +700,15 @@ export const NATURAL_EARTH_CREDIT = {
|
|||||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Registered when a name from the bundled Wikidata places packs is first
|
||||||
|
* shown (CC0 — no attribution required; credited as a courtesy). */
|
||||||
|
export const WIKIDATA_PLACES_CREDIT = {
|
||||||
|
key: 'wikidata-places',
|
||||||
|
html:
|
||||||
|
'City, town and village names from ' +
|
||||||
|
'<a href="https://www.wikidata.org" target="_blank" rel="noopener">Wikidata</a> (CC0)',
|
||||||
|
};
|
||||||
|
|
||||||
/** Registered when the first bundled US county or place outline resolves
|
/** Registered when the first bundled US county or place outline resolves
|
||||||
* (public domain — credited as a courtesy). */
|
* (public domain — credited as a courtesy). */
|
||||||
export const US_CENSUS_CREDIT = {
|
export const US_CENSUS_CREDIT = {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Wikidata cities pack
|
||||||
|
|
||||||
|
World cities and towns for naming a spot (the nearest city), the places
|
||||||
|
around it and finding a place by name, read by `src/data/wikidataCities.js`. The app reads this file; it
|
||||||
|
does not call Wikidata or any place service for these.
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `cities.json` | Human settlements with coordinates and a population at or above the cut-off (see `meta.minPopulation`), largest first |
|
||||||
|
|
||||||
|
Each row is `[name, lat, lon, population, country, wikidata]`: the English
|
||||||
|
label, coordinates to three decimals, the largest stated population, the
|
||||||
|
ISO 3166-1 alpha-2 country code (empty when Wikidata gives none) and the
|
||||||
|
Wikidata id.
|
||||||
|
|
||||||
|
**Source:** Wikidata, read once from the Wikidata Query Service by
|
||||||
|
`scripts/build-wikidata-cities.mjs`. The build time and row count are in the
|
||||||
|
file's `meta` header.
|
||||||
|
|
||||||
|
**License:** CC0 1.0 (https://www.wikidata.org/wiki/Wikidata:Licensing). No
|
||||||
|
attribution is legally required.
|
||||||
|
|
||||||
|
**What is left out:** metropolitan areas, urban agglomerations, conurbations
|
||||||
|
and similar groupings of settlements, so a view is named after the city
|
||||||
|
rather than the area around it (an item of a narrower grouping kind that is
|
||||||
|
also a city stays: Istanbul is a metropolitan municipality and a megacity);
|
||||||
|
places that no longer stand (former entities, ghost towns, abandoned
|
||||||
|
villages, archaeological sites) unless they are also a living settlement;
|
||||||
|
and places Wikidata states as dissolved or abolished (P576) where another
|
||||||
|
place within 15 km still names the spot. A dissolved unit with nothing
|
||||||
|
else near it stays: many of Vietnam's provincial cities ended as units in
|
||||||
|
2025 and still stand under their names. Also left out: places below the population cut-off and
|
||||||
|
places without an English label.
|
||||||
|
|
||||||
|
**Rebuild:** `node scripts/build-wikidata-cities.mjs [--min-population 15000]`.
|
||||||
|
The build runs one query at a time with pauses and honours the service's
|
||||||
|
Retry-After; it takes about ten minutes. It fails if a short list of
|
||||||
|
well-known cities is missing from the result, or a known grouping gets in.
|
||||||
|
|
||||||
|
**Limits:** populations are whatever Wikidata states and differ in date and
|
||||||
|
definition between places (city proper, municipality, urban district). Some
|
||||||
|
districts and suburbs appear as their own rows.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"meta":{"source":"Wikidata (https://www.wikidata.org), via the Wikidata Query Service","license":"CC0 1.0","built":"2026-11-15T02:59:26.673Z","minPopulation":15000,"count":31827,"columns":["name","lat","lon","population","country","wikidata"]},"rows":[["Chongqing",29.55,106.507,32054159,"CN","Q11725"],["Delhi",28.667,77.217,26495000,"IN","Q1353"],["Shanghai",31.233,121.469,24870895,"CN","Q8686"],["Beijing",39.904,116.408,21893095,"CN","Q956"],["Chengdu",30.66,104.063,20937757,"CN","Q30002"],["Guangzhou",23.13,113.26,18676605,"CN","Q16572"],["Shenzhen",22.547,114.054,17494398,"CN","Q15174"],["Dhaka",23.729,90.394,16800000,"BD","Q1354"],["Istanbul",41.01,28.96,15655924,"TR","Q406"],["Mumbai",19.076,72.877,15414288,"IN","Q1156"],["Karachi",24.86,67.01,14910352,"PK","Q8660"],["Kinshasa",-4.322,15.312,14565700,"CD","Q3838"],["Tokyo",35.689,139.692,14264798,"JP","Q1490"],["Ho Chi Minh City",10.776,106.702,14002598,"VN","Q1854"],["Tianjin",39.147,117.206,13866009,"CN","Q11736"],["Moscow",55.751,37.617,13274285,"RU","Q649"],["Xi'an",34.261,108.942,12952907,"CN","Q5826"],["Suzhou",31.3,120.619,12748262,"CN","Q42622"],["Zhengzhou",34.749,113.661,12600574,"CN","Q30340"],["Bengaluru",12.979,77.591,12327000,"IN","Q1355"],["Wuhan",30.595,114.297,12326518,"CN","Q11746"],["Hangzhou",30.25,120.168,11936010,"CN","Q4970"],["Baoding",38.867,115.485,11544036,"CN","Q58584"],["São Paulo",-23.55,-46.634,11451999,"BR","Q174"],["Jakarta",-6.175,106.827,11135191,"ID","Q3630"],["Lahore",31.55,74.344,11126285,"PK","Q11739"],["Linyi",35.104,118.35,11018365,"CN","Q373346"],["Shijiazhuang",38.042,114.51,10640458,"CN","Q58401"],["Dongguan",23.047,113.749,10466625,"CN","Q59218"],["Qingdao",36.117,120.4,10071722,"CN","Q170322"],["Changsha",28.199,112.971,10047914,"CN","Q174091"],["Harbin",45.75,126.633,10009854,"CN","Q42956"],["Lima",-12.06,-77.037,9943800,"PE","Q2868"],["Cairo",30.044,31.236,9801536,"EG","Q85"],["Nanyang",32.999,112.529,9713112,"CN","Q404763"],["Seoul",37.56,126.99,9668465,"KR","Q8684"],["Tokyo",35.684,139.774,9640742,"JP","Q7473516"],["Wenzhou",27.999,120.656,9572903,"CN","Q42635"],["Foshan",23.029,113.106,9498863,"CN","Q34412"],["Handan",36.612,114.489,9413990,"CN","Q58570"],["Ningbo",29.875,121.549,9404283,"CN","Q42780"],["Weifang",36.717,119.1,9386705,"CN","Q217698"],["Hefei",31.864,117.281,9369881,"CN","Q185684"],["Nanjing",32.061,118.779,9314685,"CN","Q16666"],["Mexico City",19.354,-99.136,9209944,"MX","Q1489"],["Jinan",36.667,116.983,9202432,"CN","Q170247"],["Hyderabad",17.362,78.475,9191000,"IN","Q1361"],["Xuzhou",34.261,117.186,9083790,"CN","Q57719"],["Shenyang",41.803,123.428,9070093,"CN","Q11720"],["Changchun",43.88,125.323,9066906,"CN","Q92161"],["Zhoukou",33.625,114.642,9026015,"CN","Q198061"],["Kuala Lumpur",3.148,101.695,9000280,"MY","Q1865"],["Ganzhou",25.829,114.934,8970014,"CN","Q363166"],["New York City",40.713,-74.006,8804190,"US","Q60"],["London",51.507,-0.128,8799728,"GB","Q84"],["Heze",35.233,115.433,8795939,"CN","Q332753"],["Quanzhou",24.914,118.586,8782285,"CN","Q68695"],["Nanning",22.819,108.315,8741584,"CN","Q179608"],["Hanoi",21.024,105.841,8717600,"VN","Q1858"],["Tehran",35.689,51.39,8693706,"IR","Q3616"],["Kunming",25.043,102.706,8460088,"CN","Q182852"],["Jining",35.4,116.567,8357897,"CN","Q372791"],["Fuzhou",26.077,119.292,8291268,"CN","Q68481"],["Fuyang",32.899,115.804,8200264,"CN","Q360584"],["Baghdad",33.315,44.366,8126755,"IQ","Q1530"],["Veracruz",19.19,-96.153,8062579,"MX","Q173270"],["Bogotá",4.61,-74.082,8034649,"CO","Q2841"],["Shangqiu",34.426,115.647,7816831,"CN","Q404817"],["Nantong",31.983,120.887,7726635,"CN","Q57947"],["Tangshan",39.629,118.174,7717983,"CN","Q58422"],["Ahmedabad",23.022,72.58,7645000,"IN","Q1070"],["Wuxi",31.567,120.283,7462135,"CN","Q42651"],["Dalian",38.9,121.6,7450785,"CN","Q74881"],["Hong Kong",22.278,114.159,7413070,"CN","Q8646"],["Cangzhou",38.304,116.845,7300783,"CN","Q58695"],["Xingtai",37.066,114.475,7111106,"CN","Q58576"],["Yantai",37.464,121.448,7102116,"CN","Q210493"],["Luoyang",34.659,112.424,7056699,"CN","Q187136"],["Jinhua",29.105,119.649,7050683,"CN","Q58210"],["Riyadh",24.638,46.715,7009100,"SA","Q3692"],["Zhumadian",32.977,114.025,7008427,"CN","Q198240"],["Zhanjiang",21.197,110.403,6981236,"CN","Q59144"],["Bijie",27.302,105.286,6899636,"CN","Q486319"],["Yangon",16.795,96.16,6874000,"MM","Q37995"],["Yancheng",33.35,120.157,6709629,"CN","Q57866"],["Hengyang",26.897,112.586,6645243,"CN","Q144663"],["Taizhou",28.658,121.422,6622888,"CN","Q58273"],["Zunyi",27.705,106.934,6606675,"CN","Q28994"],["Chennai",13.082,80.275,6599000,"IN","Q1352"],["Shaoyang",27.242,111.472,6563520,"CN","Q194531"],["Shangrao",28.442,117.963,6491088,"CN","Q363479"],["Huanggang",30.45,114.875,6333000,"CN","Q71355"],["Moro",-6,144,6310129,"PG","Q103927888"],["Santiago",-33.438,-70.65,6257516,"CL","Q2887"],["Nanchang",28.684,115.887,6255007,"CN","Q171943"],["Xinxiang",35.299,113.885,6251929,"CN","Q404362"],["Xinyang",32.126,114.067,6234401,"CN","Q404873"],["Rio de Janeiro",-2Line truncated
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* The bundled world cities pack (Wikidata, CC0), read from
|
||||||
|
* `local_data/wikidata_cities/cities.json` (built by
|
||||||
|
* scripts/build-wikidata-cities.mjs). The file loads once, on first use, and
|
||||||
|
* every answer comes from it: no place service is called.
|
||||||
|
*
|
||||||
|
* Rows are `[name, lat, lon, population, country, wikidata]`, largest first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CELL_DEG = 1;
|
||||||
|
|
||||||
|
// Letters that are not a base letter plus an accent, so NFD leaves them.
|
||||||
|
const PLAIN_LETTERS = {
|
||||||
|
ł: 'l',
|
||||||
|
ø: 'o',
|
||||||
|
đ: 'd',
|
||||||
|
ð: 'd',
|
||||||
|
ı: 'i',
|
||||||
|
ß: 'ss',
|
||||||
|
æ: 'ae',
|
||||||
|
œ: 'oe',
|
||||||
|
þ: 'th',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name as the lookups compare it: lower case, without accents, so
|
||||||
|
* "sao paulo" finds São Paulo, "zurich" Zürich, "lodz" Łódź and "tromso"
|
||||||
|
* Tromsø.
|
||||||
|
*/
|
||||||
|
export const foldName = (name) =>
|
||||||
|
String(name || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{M}/gu, '')
|
||||||
|
.toLocaleLowerCase('en')
|
||||||
|
.replace(/[łøđðıßæœþ]/g, (letter) => PLAIN_LETTERS[letter])
|
||||||
|
.trim();
|
||||||
|
const EARTH_KM = 6371;
|
||||||
|
const rad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
function distanceKm(lat1, lon1, lat2, lon2) {
|
||||||
|
const a =
|
||||||
|
Math.sin(rad(lat2 - lat1) / 2) ** 2 +
|
||||||
|
Math.cos(rad(lat1)) *
|
||||||
|
Math.cos(rad(lat2)) *
|
||||||
|
Math.sin(rad(lon2 - lon1) / 2) ** 2;
|
||||||
|
return 2 * EARTH_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellKey = (lat, lon) =>
|
||||||
|
`${Math.floor(lat / CELL_DEG)},${Math.floor(((((lon % 360) + 540) % 360) - 180) / CELL_DEG)}`;
|
||||||
|
|
||||||
|
/** Group rows by one-degree cell so a lookup reads only the cells near it. */
|
||||||
|
export function indexCities(rows) {
|
||||||
|
const cells = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = cellKey(row[1], row[2]);
|
||||||
|
const cell = cells.get(key);
|
||||||
|
if (cell) cell.push(row);
|
||||||
|
else cells.set(key, [row]);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows by folded name, each list in the rows' own order (largest first). */
|
||||||
|
export function indexNames(rows) {
|
||||||
|
const names = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = foldName(row[0]);
|
||||||
|
const list = names.get(key);
|
||||||
|
if (list) list.push(row);
|
||||||
|
else names.set(key, [row]);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCity(row, km) {
|
||||||
|
return {
|
||||||
|
name: row[0],
|
||||||
|
latitude: row[1],
|
||||||
|
longitude: row[2],
|
||||||
|
population: row[3],
|
||||||
|
country: row[4] || null,
|
||||||
|
wikidata: row[5],
|
||||||
|
distanceKm: km,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Half the longitude a circle spans, in degrees: 180 when it reaches a pole,
|
||||||
|
* where every meridian passes through it.
|
||||||
|
*/
|
||||||
|
export function lonHalfSpan(latitude, radiusKm) {
|
||||||
|
const angle = radiusKm / EARTH_KM;
|
||||||
|
const fromPole = Math.PI / 2 - Math.abs(rad(latitude));
|
||||||
|
if (angle >= fromPole) return 180;
|
||||||
|
return (Math.asin(Math.sin(angle) / Math.cos(rad(latitude))) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
const latHalfSpan = (radiusKm) => ((radiusKm / EARTH_KM) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Rows within `radiusKm` of a point, each with its distance. */
|
||||||
|
export function citiesWithin(cells, latitude, longitude, radiusKm) {
|
||||||
|
const latSpan = Math.ceil(latHalfSpan(radiusKm) / CELL_DEG);
|
||||||
|
const lonSpan = Math.ceil(lonHalfSpan(latitude, radiusKm) / CELL_DEG);
|
||||||
|
const found = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let dLat = -latSpan; dLat <= latSpan; dLat++) {
|
||||||
|
const lat = latitude + dLat * CELL_DEG;
|
||||||
|
if (lat < -90 - CELL_DEG || lat > 90 + CELL_DEG) continue;
|
||||||
|
for (
|
||||||
|
let dLon = -Math.min(lonSpan, 180);
|
||||||
|
dLon <= Math.min(lonSpan, 180);
|
||||||
|
dLon++
|
||||||
|
) {
|
||||||
|
const key = cellKey(lat, longitude + dLon * CELL_DEG);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
for (const row of cells.get(key) || []) {
|
||||||
|
const km = distanceKm(latitude, longitude, row[1], row[2]);
|
||||||
|
if (km <= radiusKm) found.push(toCity(row, km));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each country in the pack by what a qualifier may call it: its code, its
|
||||||
|
// English name and its name in the page language, folded.
|
||||||
|
function countryNames(rows) {
|
||||||
|
const names = new Map();
|
||||||
|
for (const code of new Set(rows.map((row) => row[4]).filter(Boolean))) {
|
||||||
|
for (const name of [code, countryName(code, 'en'), countryName(code)])
|
||||||
|
names.set(foldName(name), code);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading = null;
|
||||||
|
/** The indexed pack; a failed load is retried on the next call. */
|
||||||
|
export function loadCities(fetchImpl = (...args) => globalThis.fetch(...args)) {
|
||||||
|
loading ??= fetchImpl(
|
||||||
|
new URL('./local_data/wikidata_cities/cities.json', import.meta.url),
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`cities pack: HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((pack) => ({
|
||||||
|
meta: pack.meta,
|
||||||
|
rows: pack.rows,
|
||||||
|
cells: indexCities(pack.rows),
|
||||||
|
names: indexNames(pack.rows),
|
||||||
|
countries: countryNames(pack.rows),
|
||||||
|
}))
|
||||||
|
.catch((error) => {
|
||||||
|
loading = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The city a point is in or nearest to: the largest within `nearKm`, else the
|
||||||
|
* nearest within `radiusKm`, else null. Preferring the largest nearby keeps a
|
||||||
|
* view over a metropolis from being named after one of its suburbs.
|
||||||
|
*/
|
||||||
|
export async function nearestCity(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
{ radiusKm = 60, nearKm = 12 } = {},
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
|
const { cells } = await loadCities();
|
||||||
|
const found = citiesWithin(cells, latitude, longitude, radiusKm);
|
||||||
|
if (!found.length) return null;
|
||||||
|
const near = found.filter((city) => city.distanceKm <= nearKm);
|
||||||
|
if (near.length)
|
||||||
|
return near.reduce((a, b) => (b.population > a.population ? b : a));
|
||||||
|
return found.reduce((a, b) => (b.distanceKm < a.distanceKm ? b : a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cities around a point, most populous first. */
|
||||||
|
export async function citiesAround({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radiusKm = 50,
|
||||||
|
limit = 8,
|
||||||
|
minPopulation = 0,
|
||||||
|
}) {
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
||||||
|
const { cells } = await loadCities();
|
||||||
|
return citiesWithin(cells, latitude, longitude, radiusKm)
|
||||||
|
.filter((city) => city.population >= minPopulation)
|
||||||
|
.sort((a, b) => b.population - a.population || a.distanceKm - b.distanceKm)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A country's name in the page language, from its ISO code. */
|
||||||
|
export function countryName(
|
||||||
|
code,
|
||||||
|
language = globalThis.document?.documentElement?.lang || 'en',
|
||||||
|
) {
|
||||||
|
if (!code) return null;
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
new Intl.DisplayNames([language], { type: 'region' }).of(code) || code
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The city with this name, or null. A qualifier after a comma that names a
|
||||||
|
* country ("Paris, France", "Paris, US") keeps only cities there, and finds
|
||||||
|
* nothing when none is; one that names no country ("Paris, Texas") is
|
||||||
|
* ignored. A city of that name within `radiusKm` of `near` (the view) wins,
|
||||||
|
* the nearest first, else the largest city of that name anywhere.
|
||||||
|
*/
|
||||||
|
export async function findPlace(
|
||||||
|
name,
|
||||||
|
{ near = null, radiusKm = 150 } = {},
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
) {
|
||||||
|
const [head, ...rest] = String(name || '').split(',');
|
||||||
|
const wanted = foldName(head);
|
||||||
|
if (!wanted) return null;
|
||||||
|
const qualifier = foldName(rest.join(','));
|
||||||
|
const { names, countries } = await loadCities(fetchImpl);
|
||||||
|
const country = qualifier ? countries.get(qualifier) : undefined;
|
||||||
|
const cities = (names.get(wanted) || []).filter(
|
||||||
|
(row) => !country || row[4] === country,
|
||||||
|
);
|
||||||
|
if (!cities.length) return null;
|
||||||
|
if (Number.isFinite(near?.latitude) && Number.isFinite(near?.longitude)) {
|
||||||
|
let best = null;
|
||||||
|
for (const row of cities) {
|
||||||
|
const km = distanceKm(near.latitude, near.longitude, row[1], row[2]);
|
||||||
|
if (km <= radiusKm && (!best || km < best.distanceKm))
|
||||||
|
best = toCity(row, km);
|
||||||
|
}
|
||||||
|
if (best) return best;
|
||||||
|
}
|
||||||
|
return toCity(cities[0], 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// src/data/wikidataCities.test.mjs — lookups over the bundled Wikidata
|
||||||
|
// places packs, with the packs served from memory.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
citiesWithin,
|
||||||
|
foldName,
|
||||||
|
indexCities,
|
||||||
|
lonHalfSpan,
|
||||||
|
} from './wikidataCities.js';
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
['São Paulo', -23.55, -46.633, 11451999, 'BR', 'Q174'],
|
||||||
|
['Austin', 30.267, -97.743, 961855, 'US', 'Q16559'],
|
||||||
|
['Round Rock', 30.508, -97.679, 119468, 'US', 'Q128269'],
|
||||||
|
['Zürich', 47.374, 8.541, 421878, 'CH', 'Q72'],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('names compare without case or accents', () => {
|
||||||
|
assert.equal(foldName('São Paulo'), 'sao paulo');
|
||||||
|
assert.equal(foldName(' ZÜRICH '), 'zurich');
|
||||||
|
assert.equal(foldName(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lookup reads the cells around a point and measures each place', () => {
|
||||||
|
const cells = indexCities(ROWS);
|
||||||
|
const near = citiesWithin(cells, 30.27, -97.74, 40).sort(
|
||||||
|
(a, b) => a.distanceKm - b.distanceKm,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
near.map((city) => city.name),
|
||||||
|
['Austin', 'Round Rock'],
|
||||||
|
);
|
||||||
|
assert.ok(near[0].distanceKm < 1);
|
||||||
|
assert.equal(near[1].country, 'US');
|
||||||
|
assert.deepEqual(citiesWithin(cells, 0, 0, 50), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findPlace finds a city by its name without accents', async () => {
|
||||||
|
// A fresh module, so its pack cache starts empty.
|
||||||
|
const { findPlace } = await import(`./wikidataCities.js?case=${Date.now()}`);
|
||||||
|
const fetchImpl = async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () =>
|
||||||
|
String(url).includes('cities.json') ? { meta: {}, rows: ROWS } : null,
|
||||||
|
});
|
||||||
|
const found = await findPlace('sao paulo, Brazil', {}, fetchImpl);
|
||||||
|
assert.equal(found?.wikidata, 'Q174');
|
||||||
|
assert.equal(await findPlace('Atlantis', {}, fetchImpl), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names fold letters that carry no separate accent', () => {
|
||||||
|
assert.equal(foldName('Łódź'), 'lodz');
|
||||||
|
assert.equal(foldName('Tromsø'), 'tromso');
|
||||||
|
assert.equal(foldName('Đà Nẵng'), 'da nang');
|
||||||
|
assert.equal(foldName('Diyarbakır'), 'diyarbakir');
|
||||||
|
assert.equal(foldName('Straße'), 'strasse');
|
||||||
|
});
|
||||||
|
|
||||||
|
const PARIS = [
|
||||||
|
['Paris', 48.857, 2.352, 2145906, 'FR', 'Q90'],
|
||||||
|
['Paris', 33.661, -95.556, 24476, 'US', 'Q830149'],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function freshPack(rows) {
|
||||||
|
const module = await import(
|
||||||
|
`./wikidataCities.js?case=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
const fetchImpl = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ meta: {}, rows }),
|
||||||
|
});
|
||||||
|
return { ...module, fetchImpl };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('findPlace takes a country qualifier and prefers a namesake in view', async () => {
|
||||||
|
const { findPlace, fetchImpl } = await freshPack(PARIS);
|
||||||
|
const texas = { latitude: 33.2, longitude: -96.0 };
|
||||||
|
assert.equal((await findPlace('Paris', {}, fetchImpl)).wikidata, 'Q90');
|
||||||
|
assert.equal(
|
||||||
|
(await findPlace('Paris', { near: texas }, fetchImpl)).wikidata,
|
||||||
|
'Q830149',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await findPlace('Paris, United States', {}, fetchImpl)).wikidata,
|
||||||
|
'Q830149',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await findPlace('paris, us', {}, fetchImpl)).wikidata,
|
||||||
|
'Q830149',
|
||||||
|
);
|
||||||
|
// A qualifier the packs cannot place is ignored.
|
||||||
|
assert.equal(
|
||||||
|
(await findPlace('Paris, Texas', { near: texas }, fetchImpl)).wikidata,
|
||||||
|
'Q830149',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a circle that reaches a pole spans every longitude', () => {
|
||||||
|
assert.equal(lonHalfSpan(89, 150), 180);
|
||||||
|
assert.ok(lonHalfSpan(60, 150) < 3);
|
||||||
|
const cells = indexCities([['Pole camp', 89.9, 179, 1, '', 'Q1']]);
|
||||||
|
assert.equal(citiesWithin(cells, 89, 0, 150).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a country qualifier that matches no namesake finds nothing', async () => {
|
||||||
|
const { findPlace, fetchImpl } = await freshPack([
|
||||||
|
...PARIS,
|
||||||
|
['Berlin', 52.52, 13.405, 3755251, 'DE', 'Q64'],
|
||||||
|
]);
|
||||||
|
assert.equal(await findPlace('Paris, Germany', {}, fetchImpl), null);
|
||||||
|
assert.equal(
|
||||||
|
(await findPlace('Paris, France', {}, fetchImpl)).wikidata,
|
||||||
|
'Q90',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lookup wraps at the antimeridian', () => {
|
||||||
|
const cells = indexCities([['Taveuni', -16.8, -179.9, 15000, 'FJ', 'Q2']]);
|
||||||
|
assert.equal(citiesWithin(cells, -16.8, 179.9, 40).length, 1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user