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
+17 -1
View File
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
(gbOpen) release, except for Ireland (below).
## Tailte Éireann — counties of Ireland
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
layer for Ireland.
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source:
https://data-osi.opendata.arcgis.com/
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
generalised boundaries for reference only; they may not match the statutory
boundaries exactly.
## OpenStreetMap — geocoding, place details & descriptions
+3 -2
View File
@@ -439,8 +439,9 @@ Every variable, its default and what it does: see
The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
[**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
[NOTICE.md](NOTICE.md) for full third-party attributions.
<br />
Binary file not shown.
Binary file not shown.
+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 };
});
}
@@ -0,0 +1,110 @@
import { Migration } from '@mikro-orm/migrations';
interface RegionRow {
id: number;
user_id: number;
region_code: string;
region_name: string;
}
/** The 26 counties the Atlas now ships for Ireland: ISO 3166-2:IE code, name, province. */
const COUNTIES: ReadonlyArray<readonly [code: string, name: string, province: string]> = [
['IE-CW', 'Carlow', 'IE-L'],
['IE-D', 'Dublin', 'IE-L'],
['IE-KE', 'Kildare', 'IE-L'],
['IE-KK', 'Kilkenny', 'IE-L'],
['IE-LS', 'Laois', 'IE-L'],
['IE-LD', 'Longford', 'IE-L'],
['IE-LH', 'Louth', 'IE-L'],
['IE-MH', 'Meath', 'IE-L'],
['IE-OY', 'Offaly', 'IE-L'],
['IE-WH', 'Westmeath', 'IE-L'],
['IE-WX', 'Wexford', 'IE-L'],
['IE-WW', 'Wicklow', 'IE-L'],
['IE-CE', 'Clare', 'IE-M'],
['IE-CO', 'Cork', 'IE-M'],
['IE-KY', 'Kerry', 'IE-M'],
['IE-LK', 'Limerick', 'IE-M'],
['IE-TA', 'Tipperary', 'IE-M'],
['IE-WD', 'Waterford', 'IE-M'],
['IE-G', 'Galway', 'IE-C'],
['IE-LM', 'Leitrim', 'IE-C'],
['IE-MO', 'Mayo', 'IE-C'],
['IE-RN', 'Roscommon', 'IE-C'],
['IE-SO', 'Sligo', 'IE-C'],
['IE-CN', 'Cavan', 'IE-U'],
['IE-DL', 'Donegal', 'IE-U'],
['IE-MN', 'Monaghan', 'IE-U'],
];
/** "County Dublin", "Co. Dublin" and "dublin" all name Dublin. */
const countyKey = (name: string): string =>
name
.trim()
.toLowerCase()
.replace(/^(county|co\.?)\s+/, '');
/**
* Ireland gained its counties (#2922).
*
* geoBoundaries has no county layer for Ireland, so the Atlas showed the four
* provinces (IE-C, IE-L, IE-M, IE-U) next to a UK of counties. The bundle now ships
* Tailte Éireann's 26 counties under their ISO 3166-2:IE codes. What exists already:
*
* `place_regions` is a re-derivable cache full of province rows that nothing would
* ever re-derive, so Ireland's rows are cleared and come back as counties on the next
* Atlas load, as Great Britain's did when it gained its counties.
*
* `visited_regions` holds what a user marked by hand. A marked province stays as it
* is: which of its counties the user saw is not ours to guess. A row whose name is a
* county under another code (a Natural Earth or geocoder code from older versions)
* takes the county's code, and is dropped where the user already holds that county.
*
* `hidden_regions` remembers a region the user switched off. A hidden province hid
* every place in it, so each of its counties is hidden in its place; the province row
* stays and keeps hiding a marked province.
*
* `bucket_list` is a wish, not a polygon, and is left alone.
*/
export class Migration20200101046380_ireland_gained_its_counties extends Migration {
override name = 'Migration20200101046380_ireland_gained_its_counties';
override async up(): Promise<void> {
await this.execute(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'IE'`);
for (const [code, , province] of COUNTIES) {
await this.execute(
`INSERT INTO hidden_regions (user_id, region_code, country_code)
SELECT h.user_id, ?, h.country_code FROM hidden_regions h
WHERE h.region_code = ? AND UPPER(h.country_code) = 'IE'
AND NOT EXISTS (SELECT 1 FROM hidden_regions x WHERE x.user_id = h.user_id AND x.region_code = ?)`,
[code, province, code],
);
}
const rows = (await this.execute(
`SELECT id, user_id, region_code, region_name FROM visited_regions
WHERE UPPER(COALESCE(country_code, '')) = 'IE' ORDER BY id`,
)) as RegionRow[];
const counties = new Map(COUNTIES.map(([code, name]) => [countyKey(name), { code, name }]));
const countyCodes = new Set(COUNTIES.map(([code]) => code));
const held = new Set(rows.map((r) => `${r.user_id}|${r.region_code}`));
for (const row of rows) {
if (countyCodes.has(row.region_code)) continue;
const county = counties.get(countyKey(row.region_name || ''));
if (!county) continue;
const target = `${row.user_id}|${county.code}`;
if (held.has(target)) {
await this.execute(`DELETE FROM visited_regions WHERE id = ?`, [row.id]);
} else {
await this.execute(`UPDATE visited_regions SET region_code = ?, region_name = ? WHERE id = ?`, [
county.code,
county.name,
row.id,
]);
held.add(target);
}
}
}
}
@@ -0,0 +1,147 @@
/**
* `Migration20200101046380_ireland_gained_its_counties` (#2922): the Atlas ships
* Ireland's 26 counties instead of its four provinces. Cached place regions are
* cleared, hand-marked provinces stay, county names under stale codes take the county
* code, and a hidden province hides its counties.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { afterEach, describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101046380_ireland_gained_its_counties';
const LEINSTER = [
'IE-CW',
'IE-D',
'IE-KE',
'IE-KK',
'IE-LD',
'IE-LH',
'IE-LS',
'IE-MH',
'IE-OY',
'IE-WH',
'IE-WW',
'IE-WX',
];
let orm: MikroORM;
async function ormBeforeTarget(): Promise<MikroORM> {
const o = await createMigrationOrm();
const names = await pendingNames(o);
const index = names.indexOf(TARGET);
expect(index).toBeGreaterThan(0);
await migrateTo(o, names[index - 1]);
return o;
}
async function seedUsers(): Promise<void> {
await rawExec(
orm,
`INSERT INTO users (id, username, email, password_hash) VALUES (1, 'one', 'one@test', 'x'), (2, 'two', 'two@test', 'x')`,
);
}
const visited = (userId: number) =>
rawQuery<{ region_code: string; region_name: string; country_code: string }>(
orm,
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY region_code',
[userId],
);
const hidden = async (userId: number) =>
(
await rawQuery<{ region_code: string }>(
orm,
'SELECT region_code FROM hidden_regions WHERE user_id = ? ORDER BY region_code',
[userId],
)
).map((r) => r.region_code);
afterEach(async () => {
await orm?.close(true);
});
describe('Ireland gained its counties', () => {
it('IE-COUNTIES-001: clears the cached Irish place regions and leaves other countries cached', async () => {
orm = await ormBeforeTarget();
await seedUsers();
await rawExec(orm, `INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'Isles')`);
await rawExec(
orm,
`INSERT INTO places (id, trip_id, name, lat, lng) VALUES (1, 1, 'Dublin', 53.34, -6.25), (2, 1, 'Belfast', 54.6, -5.93)`,
);
await rawExec(
orm,
`INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES
(1, 'IE', 'IE-L', 'Leinster'), (2, 'GB', 'GB-BELFAST', 'Belfast')`,
);
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, 'SELECT place_id, region_code FROM place_regions ORDER BY place_id')).toEqual([
{ place_id: 2, region_code: 'GB-BELFAST' },
]);
});
it('IE-COUNTIES-002: keeps a marked province and a marked county, and moves a county name onto its code', async () => {
orm = await ormBeforeTarget();
await seedUsers();
await rawExec(
orm,
`INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES
(1, 'IE-L', 'Leinster', 'IE'),
(1, 'IE-KY', 'Kerry', 'IE'),
(1, 'IE-COR', 'County Cork', 'IE'),
(1, 'IE-GALWAY', 'Co. Galway', 'ie'),
(1, 'FR-IDF', 'Ile-de-France', 'FR')`,
);
await migrateTo(orm, TARGET);
expect(await visited(1)).toEqual([
{ region_code: 'FR-IDF', region_name: 'Ile-de-France', country_code: 'FR' },
{ region_code: 'IE-CO', region_name: 'Cork', country_code: 'IE' },
{ region_code: 'IE-G', region_name: 'Galway', country_code: 'ie' },
{ region_code: 'IE-KY', region_name: 'Kerry', country_code: 'IE' },
{ region_code: 'IE-L', region_name: 'Leinster', country_code: 'IE' },
]);
});
it('IE-COUNTIES-003: drops a stale row whose county the user already holds, never touching another user', async () => {
orm = await ormBeforeTarget();
await seedUsers();
await rawExec(
orm,
`INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES
(1, 'IE-D', 'Dublin', 'IE'),
(1, 'IE-DUB', 'County Dublin', 'IE'),
(1, 'IE-DN', 'dublin', 'IE'),
(2, 'IE-DUB', 'County Dublin', 'IE')`,
);
await migrateTo(orm, TARGET);
expect(await visited(1)).toEqual([{ region_code: 'IE-D', region_name: 'Dublin', country_code: 'IE' }]);
expect(await visited(2)).toEqual([{ region_code: 'IE-D', region_name: 'Dublin', country_code: 'IE' }]);
});
it('IE-COUNTIES-004: a hidden province hides each of its counties, once, and stays hidden itself', async () => {
orm = await ormBeforeTarget();
await seedUsers();
await rawExec(
orm,
`INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES
(1, 'IE-L', 'IE'),
(1, 'IE-D', 'IE'),
(2, 'IE-U', 'IE'),
(2, 'FR-IDF', 'FR')`,
);
await migrateTo(orm, TARGET);
expect(await hidden(1)).toEqual([...LEINSTER, 'IE-L'].sort());
expect(await hidden(2)).toEqual(['FR-IDF', 'IE-CN', 'IE-DL', 'IE-MN', 'IE-U']);
});
});
@@ -105,6 +105,7 @@ const UNNUMBERED = [
'Migration20200101046350_a_place_lies_on_the_real_world',
'Migration20200101046360_a_new_install_starts_with_more_addons_on',
'Migration20200101046370_a_deleted_chat_message_keeps_no_text',
'Migration20200101046380_ireland_gained_its_counties',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -1,4 +1,4 @@
import { getRegionGeoGz, getRegionGeoJson } from '../../../src/nest/atlas/atlas-geo';
import { getRegionFromCoords, getRegionGeoGz, getRegionGeoJson } from '../../../src/nest/atlas/atlas-geo';
import { describe, it, expect } from 'vitest';
import zlib from 'zlib';
@@ -57,3 +57,33 @@ describe('getRegionGeoGz', () => {
expect(await Promise.all(codes.map((code) => getRegionGeoGz(code)))).toEqual(codes.map(() => null));
});
});
describe('Ireland resolves to its counties (#2922)', () => {
// Inland, coastal and near-border places, one county each. The counties come from
// Tailte Éireann and are simplified by the builder itself, so a point that lands in no
// county or in the wrong one would mean the simplification opened a gap or moved a border.
const places: [string, number, number, string][] = [
['Dublin, Trinity College', 53.3438, -6.2546, 'IE-D'],
['Howth village', 53.3866, -6.07, 'IE-D'],
['Cork, St. Patrick Street', 51.8985, -8.4731, 'IE-CO'],
['Galway, Eyre Square', 53.2743, -9.0491, 'IE-G'],
['Killarney', 52.0599, -9.5044, 'IE-KY'],
['Cliffs of Moher visitor centre', 52.9719, -9.4265, 'IE-CE'],
['Kilkenny Castle', 52.6503, -7.2492, 'IE-KK'],
['Letterkenny', 54.9503, -7.7334, 'IE-DL'],
['Westport', 53.8008, -9.5223, 'IE-MO'],
['Cashel, Rock of Cashel', 52.5201, -7.8906, 'IE-TA'],
];
it.each(places)('ATLAS-SVC-051: %s lies in its county', async (_name, lat, lng, code) => {
expect((await getRegionFromCoords('IE', lat, lng))?.region_code).toBe(code);
});
it('ATLAS-SVC-052: a county comes back under its English name', async () => {
expect(await getRegionFromCoords('ie', 53.3438, -6.2546)).toEqual({
country_code: 'IE',
region_code: 'IE-D',
region_name: 'Dublin',
});
});
});
@@ -152,6 +152,26 @@ describe('Atlas admin1 region bundle (#1217)', () => {
}
});
/*
* geoBoundaries has no county layer for Ireland: its ADM1 is the four provinces and
* its ADM2 the 166 local electoral areas, so Ireland showed four provinces next to
* a UK of counties (#2922). Ireland's regions come from Tailte Éireann's 26 counties
* instead, under their ISO 3166-2:IE codes.
*/
it('ATLAS-BUNDLE-011 — Ireland ships its 26 counties, not the four provinces (#2922)', () => {
const ie = regions('IE') as unknown as { properties: { iso_3166_2: string; name: string; admin: string } }[];
const codes = ie.map((f) => f.properties.iso_3166_2).sort();
// ISO 3166-2:IE, Dublin, Cork, Galway and Limerick each one county (city and county together).
const iso =
'IE-CE IE-CN IE-CO IE-CW IE-D IE-DL IE-G IE-KE IE-KK IE-KY IE-LD IE-LH IE-LK ' +
'IE-LM IE-LS IE-MH IE-MN IE-MO IE-OY IE-RN IE-SO IE-TA IE-WD IE-WH IE-WW IE-WX';
expect(codes).toEqual(iso.split(' '));
const byCode = new Map(ie.map((f) => [f.properties.iso_3166_2, f.properties]));
expect(byCode.get('IE-D')?.name).toBe('Dublin');
expect(byCode.get('IE-TA')?.name).toBe('Tipperary');
expect(new Set(ie.map((f) => f.properties.admin))).toEqual(new Set(['Ireland']));
});
it('ATLAS-BUNDLE-006 — China ships Guangdong Province, not Guangzhou Province', () => {
const cn = (
features as unknown as { properties: { iso_a2: string | null; name?: string; iso_3166_2: string } }[]
@@ -0,0 +1,161 @@
/**
* scripts/lib/shared-border-simplify.mjs simplifies a country's region outlines for the
* Atlas builder when the source has no simplified version of its own (Tailte Éireann's
* counties, #2922). What matters is that two regions sharing a border keep the same
* points on it, so no point between them falls into a sliver that belongs to neither.
*/
import { douglasPeucker, simplifySharedBorders } from '../../../scripts/lib/shared-border-simplify.mjs';
import { describe, expect, it } from 'vitest';
type Ring = number[][];
type Geometry = { type: 'Polygon'; coordinates: Ring[] } | { type: 'MultiPolygon'; coordinates: Ring[][] };
type Feature = { type: 'Feature'; properties: Record<string, unknown>; geometry: Geometry | null };
const polygon = (name: string, ...rings: Ring[]): Feature => ({
type: 'Feature',
properties: { name },
geometry: { type: 'Polygon', coordinates: rings },
});
// A border from about (1,0) up to about (1,1) that swings out and back, quantized like the builder's input.
const border: Ring = Array.from({ length: 41 }, (_, i) => [
Math.round((1 + 0.05 * Math.sin(i * 0.7)) * 1000) / 1000,
i / 40,
]);
/**
* Two neighbours: west runs up the shared border, east runs down it from a different
* start. Simplified each on its own, these two keep different points on the border
* (SIMPLIFY-003 checks that the fixture really does that).
*/
function neighbours(): Feature[] {
const west = [[0, 0], ...border, [0, 1], [0, 0]];
const east = [[2, 0], [2, 1], ...border.slice().reverse(), [2, 0]];
return [polygon('west', west), polygon('east', east)];
}
const outer = (f: Feature) => (f.geometry as { coordinates: Ring[] }).coordinates[0];
// The points of a ring on the shared border, its repeated closing point left out.
const onBorder = (ring: Ring) =>
ring
.slice(0, -1)
.filter(([x]) => x > 0.5 && x < 1.5)
.map((p) => p.join(','))
.sort();
describe('douglasPeucker', () => {
it('SIMPLIFY-001: keeps both ends and drops points within the tolerance', () => {
expect(
douglasPeucker(
[
[0, 0],
[1, 0.001],
[2, 0],
[3, 1],
],
0.01,
),
).toEqual([
[0, 0],
[2, 0],
[3, 1],
]);
});
it('SIMPLIFY-002: leaves a line of two points or fewer as it is', () => {
expect(
douglasPeucker(
[
[0, 0],
[1, 1],
],
5,
),
).toEqual([
[0, 0],
[1, 1],
]);
});
});
describe('simplifySharedBorders', () => {
it('SIMPLIFY-003: two neighbours keep exactly the same points on the border they share', () => {
const [west, east] = neighbours();
// Each polygon simplified on its own drifts apart on the border: the bug this avoids.
expect(onBorder(douglasPeucker(outer(west), 0.01))).not.toEqual(onBorder(douglasPeucker(outer(east), 0.01)));
const [westOut, eastOut] = simplifySharedBorders([west, east], 0.01) as Feature[];
expect(onBorder(outer(westOut))).toEqual(onBorder(outer(eastOut)));
expect(onBorder(outer(westOut)).length).toBeLessThan(border.length);
// Its two ends are where three outlines meet, and stay put.
expect(onBorder(outer(westOut))).toEqual(expect.arrayContaining([border[0].join(','), border[40].join(',')]));
});
it('SIMPLIFY-004: the result does not depend on which neighbour comes first', () => {
const [east, west] = simplifySharedBorders(neighbours().reverse(), 0.01) as Feature[];
const [west2, east2] = simplifySharedBorders(neighbours(), 0.01) as Feature[];
expect(outer(west)).toEqual(outer(west2));
expect(outer(east)).toEqual(outer(east2));
});
it('SIMPLIFY-005: an enclave shared whole with the hole around it simplifies the same on both sides', () => {
const circle = (reverse: boolean): Ring => {
const pts = Array.from({ length: 40 }, (_, i) => {
const a = (i / 40) * 2 * Math.PI;
return [Math.round((5 + Math.cos(a)) * 1000) / 1000, Math.round((5 + Math.sin(a)) * 1000) / 1000];
});
const ring = reverse ? pts.reverse() : pts;
return [...ring, ring[0]];
};
const square: Ring = [
[0, 0],
[10, 0],
[10, 10],
[0, 10],
[0, 0],
];
const [around, enclave] = simplifySharedBorders(
[polygon('around', square, circle(true)), polygon('enclave', circle(false))],
0.05,
) as Feature[];
const hole = (around.geometry as { coordinates: Ring[] }).coordinates[1];
const key = (ring: Ring) => ring.map((p) => p.join(',')).sort();
expect(key(hole)).toEqual(key(outer(enclave)));
expect(outer(enclave).length).toBeLessThan(41);
});
it('SIMPLIFY-006: a polygon too small to keep is dropped, and a feature with nothing left gets no geometry', () => {
const speck: Ring = [
[0, 0],
[0.001, 0],
[0.001, 0.001],
[0, 0],
];
const island = polygon('island', speck);
const [out] = simplifySharedBorders([island], 0.01) as Feature[];
expect(out.geometry).toBeNull();
expect(out.properties).toEqual({ name: 'island' });
});
it('SIMPLIFY-007: a MultiPolygon keeps the parts that survive and becomes a Polygon when one is left', () => {
const big: Ring = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
[0, 0],
];
const speck: Ring = [
[9, 9],
[9.001, 9],
[9.001, 9.001],
[9, 9],
];
const [out] = simplifySharedBorders(
[{ type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: [[big], [speck]] } }],
0.01,
) as Feature[];
expect(out.geometry).toEqual({ type: 'Polygon', coordinates: [big] });
});
});