fix(atlas): show Ireland's 26 counties instead of its four provinces

geoBoundaries has no county layer for Ireland: its ADM1 is the four
provinces and its ADM2 the 166 local electoral areas, so the Atlas showed
Ireland as four provinces next to a UK of counties.

The Atlas builder can now take a country's regions from another source.
Ireland's come from Tailte Éireann's 26 counties (CC BY 4.0), with Dublin,
Cork, Galway and Limerick each one county, under their ISO 3166-2:IE
codes. Those outlines come at full detail, so the builder simplifies them
itself and keeps the borders two counties share identical on both sides,
so no point between them falls into neither. ATLAS_ONLY rebuilds just the
named countries without downloading the whole world.

A migration clears Ireland's cached place regions so they are worked out
again as counties, moves visited rows that name a county onto its code,
and hides each county of a province a user had hidden. Provinces a user
marked as visited are kept as they are.

Fixes #2922
This commit is contained in:
Maurice
2026-11-11 21:26:31 +00:00
parent bac968c0bf
commit 948c9ef57b
12 changed files with 796 additions and 18 deletions
+147 -14
View File
@@ -14,7 +14,13 @@
// FeatureCollections that the server serves at runtime (no network at boot): one for
// every country's borders, and one per country for its regions.
//
// A few countries take their regions from another source instead (REGION_SOURCES below:
// Ireland's counties from Tailte Éireann).
//
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
// Tailte Éireann: CC BY 4.0 — https://data-osi.opendata.arcgis.com/ (attribution required).
import { simplifySharedBorders } from './lib/shared-border-simplify.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -317,13 +323,86 @@ const A3_TO_A2 = {
*/
const ADM_LEVEL = { GBR: 'ADM2' };
/**
* The 26 counties of Ireland, keyed by the COUNTY field of Tailte Éireann's layer, with
* their ISO 3166-2:IE codes (the source carries none).
*/
const IE_COUNTIES = {
CARLOW: ['IE-CW', 'Carlow'],
CAVAN: ['IE-CN', 'Cavan'],
CLARE: ['IE-CE', 'Clare'],
CORK: ['IE-CO', 'Cork'],
DONEGAL: ['IE-DL', 'Donegal'],
DUBLIN: ['IE-D', 'Dublin'],
GALWAY: ['IE-G', 'Galway'],
KERRY: ['IE-KY', 'Kerry'],
KILDARE: ['IE-KE', 'Kildare'],
KILKENNY: ['IE-KK', 'Kilkenny'],
LAOIS: ['IE-LS', 'Laois'],
LEITRIM: ['IE-LM', 'Leitrim'],
LIMERICK: ['IE-LK', 'Limerick'],
LONGFORD: ['IE-LD', 'Longford'],
LOUTH: ['IE-LH', 'Louth'],
MAYO: ['IE-MO', 'Mayo'],
MEATH: ['IE-MH', 'Meath'],
MONAGHAN: ['IE-MN', 'Monaghan'],
OFFALY: ['IE-OY', 'Offaly'],
ROSCOMMON: ['IE-RN', 'Roscommon'],
SLIGO: ['IE-SO', 'Sligo'],
TIPPERARY: ['IE-TA', 'Tipperary'],
WATERFORD: ['IE-WD', 'Waterford'],
WESTMEATH: ['IE-WH', 'Westmeath'],
WEXFORD: ['IE-WX', 'Wexford'],
WICKLOW: ['IE-WW', 'Wicklow'],
};
/**
* Countries whose regions come from somewhere other than geoBoundaries, because no
* geoBoundaries level is the one people think in.
*
* IRL: ADM1 is the four provinces and ADM2 the 166 local electoral areas; there is no
* county layer at all (#2922). Tailte Éireann (CC BY 4.0) publishes the 26 counties,
* with Dublin, Cork, Galway and Limerick each one county (city and county together),
* which is what ISO 3166-2:IE lists.
*
* Each entry names the download, the file it is cached under, how many features it must
* yield, the tolerance its full-detail outlines are simplified with (see
* lib/shared-border-simplify.mjs) and how one feature's properties become the
* geoBoundaries `shapeName`/`shapeISO` that normalizeAdm1 reads, so everything after the
* download treats it like any other country. A feature it cannot place fails the build.
*/
const REGION_SOURCES = {
IRL: {
url:
'https://services-eu1.arcgis.com/FH5XCsx8rYXqnjF5/arcgis/rest/services/' +
'Counties___OSi_National_Statutory_Boundaries___Generalised_20m/FeatureServer/0/query' +
'?where=1%3D1&outFields=COUNTY&outSR=4326&f=geojson',
cacheFile: 'tailte-eireann-counties-2019-generalised-20m.geojson',
count: 26,
tolerance: 0.002,
toShape: ({ COUNTY }) => {
const county = IE_COUNTIES[COUNTY];
return county && { shapeISO: county[0], shapeName: county[1] };
},
},
};
const COUNTRIES = Object.keys(A3_TO_A2); // every ISO alpha-3 code (ADM1 fetch list)
// ATLAS_ONLY=IRL[,GBR…] rebuilds just those countries' regions: their admin1/<A2> files
// are rewritten and their features swapped into admin1.geojson.gz in place, while admin0
// and every other country stay as they are. Saves fetching the whole world for one fix.
const ONLY = (process.env.ATLAS_ONLY || '')
.split(',')
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
for (const a3 of ONLY) if (!A3_TO_A2[a3]) throw new Error(`ATLAS_ONLY: unknown alpha-3 code ${a3}`);
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF);
async function fetchGeo(url) {
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0]);
async function fetchGeo(url, cacheName = url.split('/').slice(-1)[0]) {
const cacheFile = path.join(CACHE_DIR, cacheName);
if (fs.existsSync(cacheFile)) {
const cached = fs.readFileSync(cacheFile, 'utf8');
return cached === '' ? null : JSON.parse(cached);
@@ -422,6 +501,30 @@ function normalizeAdm0Feature(f) {
};
}
// A country's raw region layer: from its REGION_SOURCES entry when it has one, else the
// geoBoundaries level ADM_LEVEL names (ADM1 by default).
async function fetchRegions(a3) {
const source = REGION_SOURCES[a3];
if (!source) return fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1'));
const geo = await fetchGeo(source.url, source.cacheFile);
if (!geo?.features) throw new Error(`${a3}: region source could not be downloaded`);
const features = geo.features.map((f) => {
const shape = source.toShape(f.properties || {});
if (!shape) throw new Error(`${a3}: region source has a feature it cannot place: ${JSON.stringify(f.properties)}`);
return { type: 'Feature', properties: shape, geometry: quantizeGeometry(f.geometry, ADM1_DECIMALS) };
});
const codes = new Set(features.map((f) => f.properties.shapeISO));
if (features.length !== source.count || codes.size !== source.count) {
throw new Error(
`${a3}: expected ${source.count} distinct regions, the source has ${features.length} (${codes.size} distinct)`,
);
}
// By code, so the output does not depend on the order the service happens to answer in.
features.sort((a, b) => (a.properties.shapeISO < b.properties.shapeISO ? -1 : 1));
return { features: simplifySharedBorders(features, source.tolerance) };
}
function normalizeAdm1(geo, a3, countryName) {
if (!geo?.features) return [];
const a2 = A3_TO_A2[a3] || null;
@@ -477,7 +580,46 @@ function normalizeAdm1(geo, a3, countryName) {
.filter(Boolean);
}
const write = (name, features) => {
const fc = { type: 'FeatureCollection', features };
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 });
const file = path.join(OUT_DIR, `${name}.geojson.gz`);
fs.writeFileSync(file, gz);
console.log(
`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`,
);
};
const readShipped = (name) =>
JSON.parse(zlib.gunzipSync(fs.readFileSync(path.join(OUT_DIR, `${name}.geojson.gz`))).toString('utf8')).features;
// The ATLAS_ONLY path. A full build writes the countries in COUNTRIES order, each one's
// features in a single run, so a rebuilt country goes back where its run was, or where
// it would have been, and admin1.geojson.gz ends up as a full build would leave it.
async function rebuildOnly(countries) {
console.log(`[atlas-geo] rebuilding the regions of ${countries.join(', ')} only`);
fs.mkdirSync(CACHE_DIR, { recursive: true });
const a3ToName = Object.fromEntries(readShipped('admin0').map((f) => [f.properties.ADM0_A3, f.properties.NAME]));
const order = new Map(COUNTRIES.map((a3, i) => [A3_TO_A2[a3], i]));
let bundle = readShipped('admin1');
for (const a3 of countries) {
const a2 = A3_TO_A2[a3];
const features = normalizeAdm1(await fetchRegions(a3), a3, a3ToName[a3] || a3);
if (!features.length) throw new Error(`${a3}: no regions to write`);
const first = bundle.findIndex((f) => f.properties.iso_a2 === a2);
const at = first !== -1 ? first : bundle.findIndex((f) => order.get(f.properties.iso_a2) > order.get(a2));
const rest = bundle.filter((f) => f.properties.iso_a2 !== a2);
const cut = at === -1 ? rest.length : at;
bundle = [...rest.slice(0, cut), ...features, ...rest.slice(cut)];
write(`admin1/${a2}`, features);
}
write('admin1', bundle);
}
async function main() {
if (ONLY.length) return rebuildOnly(ONLY);
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`);
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -496,9 +638,10 @@ async function main() {
}
}
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`),
// or the country's own source (REGION_SOURCES).
console.log('[atlas-geo] downloading ADM1 (regions)…');
const adm1Raw = await pool(COUNTRIES, (a3) => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')));
const adm1Raw = await pool(COUNTRIES, fetchRegions);
const adm1Features = [];
let withCodes = 0;
COUNTRIES.forEach((a3, idx) => {
@@ -507,16 +650,6 @@ async function main() {
adm1Features.push(...feats);
});
const write = (name, features) => {
const fc = { type: 'FeatureCollection', features };
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 });
const file = path.join(OUT_DIR, `${name}.geojson.gz`);
fs.writeFileSync(file, gz);
console.log(
`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`,
);
};
write('admin0', adm0Features);
// Read only by two shipped migrations (Migration20200101021500/…031500), whose bodies
// never change; the server itself reads the per-country files below.
@@ -0,0 +1,159 @@
/*
* Simplify the polygons of one country's regions without opening gaps between them.
*
* geoBoundaries ships a "_simplified" file per country, so the Atlas builder normally
* never simplifies anything itself. A region source that only offers full-detail
* outlines (Tailte Éireann's counties, #2922) needs it done here, and simplifying each
* polygon on its own would not do: two neighbours would each drop different points of
* the border they share, leaving slivers that belong to neither county, and a point in
* a sliver resolves to no region at all.
*
* So the rings are cut into arcs the way TopoJSON does it. A vertex is a junction when
* it does not have the same two neighbours everywhere it appears: where a shared border
* starts or ends, or where three regions meet. Between two junctions both sides hold
* the same run of points, and each arc is simplified (Douglas-Peucker) in one canonical
* direction, so both sides get the same points back. A ring with no junction at all (an
* island, or an enclave shared whole with its surroundings) is rotated to a canonical
* start first, for the same reason.
*
* Expects rings that are already quantized (identical borders carry identical
* coordinates) and closed. A ring left with fewer than three distinct points is dropped,
* a polygon whose outer ring is dropped goes with its holes, and a geometry with nothing
* left becomes null.
*/
const key = (pt) => `${pt[0]},${pt[1]}`;
// Squared distance from p to the segment a-b.
function segmentDistance2(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 === 0 ? 0 : ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const x = a[0] + t * dx - p[0];
const y = a[1] + t * dy - p[1];
return x * x + y * y;
}
/** Douglas-Peucker over an open polyline; both ends are always kept. */
export function douglasPeucker(points, tolerance) {
if (points.length <= 2) return points.slice();
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const tol2 = tolerance * tolerance;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [first, last] = stack.pop();
let maxDist = -1;
let index = -1;
for (let i = first + 1; i < last; i++) {
const d = segmentDistance2(points[i], points[first], points[last]);
if (d > maxDist) {
maxDist = d;
index = i;
}
}
if (index !== -1 && maxDist > tol2) {
keep[index] = 1;
stack.push([first, index], [index, last]);
}
}
return points.filter((_, i) => keep[i]);
}
// An arc simplified in its canonical direction, handed back in the direction it came in.
function simplifyArc(arc, tolerance) {
const head = key(arc[0]);
const tail = key(arc[arc.length - 1]);
const reversed = head === tail ? key(arc[1]) > key(arc[arc.length - 2]) : head > tail;
const canonical = reversed ? arc.slice().reverse() : arc;
const out = douglasPeucker(canonical, tolerance);
return reversed ? out.reverse() : out;
}
function polygonsOf(geometry) {
if (!geometry) return [];
return geometry.type === 'Polygon'
? [geometry.coordinates]
: geometry.type === 'MultiPolygon'
? geometry.coordinates
: [];
}
// A closed ring without its repeated closing point.
const open = (ring) => (ring.length > 1 && key(ring[0]) === key(ring[ring.length - 1]) ? ring.slice(0, -1) : ring);
/** Every vertex whose neighbours differ between the rings (or places) it appears in. */
function findJunctions(rings) {
const neighbours = new Map();
const junctions = new Set();
for (const ring of rings) {
const n = ring.length;
for (let i = 0; i < n; i++) {
const k = key(ring[i]);
const a = key(ring[(i - 1 + n) % n]);
const b = key(ring[(i + 1) % n]);
const pair = a < b ? `${a}|${b}` : `${b}|${a}`;
const seen = neighbours.get(k);
if (seen === undefined) neighbours.set(k, pair);
else if (seen !== pair) junctions.add(k);
}
}
return junctions;
}
function simplifyRing(ring, junctions, tolerance) {
const n = ring.length;
const cuts = [];
for (let i = 0; i < n; i++) if (junctions.has(key(ring[i]))) cuts.push(i);
let points;
if (cuts.length === 0) {
// No junction: start at the smallest vertex so a twin ring starts at the same place.
let start = 0;
for (let i = 1; i < n; i++) if (key(ring[i]) < key(ring[start])) start = i;
const rotated = [...ring.slice(start), ...ring.slice(0, start), ring[start]];
points = simplifyArc(rotated, tolerance).slice(0, -1);
} else {
points = [];
for (let c = 0; c < cuts.length; c++) {
const from = cuts[c];
const to = cuts[(c + 1) % cuts.length];
const arc = [];
for (let i = from; ; i = (i + 1) % n) {
arc.push(ring[i]);
if (arc.length > 1 && i === to) break;
}
points.push(...simplifyArc(arc, tolerance).slice(0, -1));
}
}
if (new Set(points.map(key)).size < 3) return null;
return [...points, points[0]];
}
/**
* Simplify every feature's (Multi)Polygon with the given tolerance (in coordinate
* units), keeping the borders that features share identical on both sides. Returns new
* features; properties are passed through untouched.
*/
export function simplifySharedBorders(features, tolerance) {
const rings = features.flatMap((f) => polygonsOf(f.geometry).flatMap((poly) => poly.map(open)));
const junctions = findJunctions(rings);
return features.map((f) => {
const polygons = polygonsOf(f.geometry)
.map((poly) => {
const [outer, ...holes] = poly.map((ring) => simplifyRing(open(ring), junctions, tolerance));
return outer ? [outer, ...holes.filter(Boolean)] : null;
})
.filter(Boolean);
const geometry =
polygons.length === 0
? null
: polygons.length === 1
? { type: 'Polygon', coordinates: polygons[0] }
: { type: 'MultiPolygon', coordinates: polygons };
return { ...f, geometry };
});
}