Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6949739956 | ||
|
|
cf7d3cb122 | ||
|
|
99ee93f6da | ||
|
|
b9ac79c57f | ||
|
|
1c64aa1da4 | ||
|
|
29927bae34 | ||
|
|
325bdc5255 | ||
|
|
2cfa2f78f9 | ||
|
|
2b84c34f41 | ||
|
|
9c29779572 | ||
|
|
3cd8dcecfa | ||
|
|
acb4f0800f | ||
|
|
b1081eefde | ||
|
|
e92bf47d7d | ||
|
|
f1cc4fbc10 | ||
|
|
97702940cd | ||
|
|
8eedc37e05 |
@@ -2,6 +2,47 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.20.0 — 2026-10-14 09:30
|
||||||
|
- **Fixed: v0.19.0's region-level ownership did not activate in the production image.** The server-side region loader (`server/regions.ts`) only looked for the manifest + geo in `public/`, but in the Docker image Vite copies `public/*` → `dist/` and no `public/` directory is kept — so `loadRegions` found nothing, `expandCountriesToKeys` degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (`DEU`, `GBR,IRL,IND`, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own `/region-keys.json` fetch still worked, because the client is served from `dist/`). `loadRegions` now probes `public/` (dev) then `dist/` (prod, == `clientDir`) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (`dist/`), dev (`public/`), and empty-root (no data) layouts.
|
||||||
|
- **Deploy:** `docker compose down -v` (drops the `history-data` volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then `up -d --build`.
|
||||||
|
|
||||||
|
## v0.19.0 — 2026-10-14 09:14
|
||||||
|
- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `<name-slug>-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`).
|
||||||
|
- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → "<name> (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
|
||||||
|
- **The dossier shows region names.** NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes.
|
||||||
|
- **⚠ Breaking — pre-v0.19 saved games are dropped.** The `territory` field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs `docker compose down -v` (dropping the `history-data` volume) then `up -d --build`; there is no migration — start fresh after deploying.
|
||||||
|
- **Supersedes the abandoned `feat/sub-region-territory` branch**, which prototyped raw feature-id keys (`DEU-03`); this version uses the slugged, human-readable key scheme instead.
|
||||||
|
- **Verification.** Typecheck, lint, and the unit suite (now **71 tests across 7 files**: +`server/regions.test.ts`, a rewritten `server/map.test.ts`, +2 in `engine.test.ts`) all pass; the production build is clean. The deterministic `LLM_STUB` and the format-agnostic e2e suite (legend/stats/dates) are unchanged. **#16 stays open** — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow.
|
||||||
|
|
||||||
|
## v0.18.0 — 2026-10-13 18:54
|
||||||
|
- **Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA").** The GeoJSON `properties.parent` is a *static geographic-origin* field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's `Selected:` label (as a `(FRA)` suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on `properties.parent` (unchanged) — only the display of it was removed.
|
||||||
|
|
||||||
|
## v0.17.0 — 2026-10-13 15:13
|
||||||
|
- **Filled the no-one's-land left between merged sub-regions (Part of #16).** The v0.14 coarsening's boolean dissolves left slivers of each country's landmass inside no region — measured at **≈ 3.90M km² globally** (the union of a country's regions covered only ~98% of its landmass). The new `scripts/fill-gaps.mjs` (`npm run fill:regions`) closes them: per country it computes the gap (`landmass − union(its regions)`, `@turf/difference`+`union`), drops slivers < **25 km²** (66,637 of them — sub-pixel on the map), and assigns each remaining piece to the **nearest region** (centroid distance) as an appended MultiPolygon part. **2,696,811 km²** of no-one's-land was filled into **1,575 regions** (+6,768 parts: 5,026 → 11,794), leaving **63,889 km²** residual (**98.4% reduction** — the rest is coastline digitization fuzz and 14 tiny border-band overlap artifacts, see below). The named "messy" regions absorb their surroundings: **SI-048 Kočevje (SVN) 16,692 → 19,816 km² (57 → 90 parts)**, **MK-36 Kavadarci (MKD) 21,297 → 25,190 km² (8 → 17)**, **AZ-AGC Ağcabədi (AZE) 18,657 → 32,065 km² (7 → 14)**; the biggest single fills are **AQ-X01~ (Antarctica) +195,351 km²**, **CA-NU (Nunavut) +143,447**, **AQ-X02~ +142,615**, **CL-MA (Magallanes) +81,806**, **US-AK (Alaska) +63,830**, **CA-NT (NWT) +46,333**, **GL-UO (Kommuneqarfiup) +41,971**, **RU-KAM (Kamchatka) +41,911**.
|
||||||
|
- **Correct, not just greedy: clipping, hole policy, dedup.** Each gap piece is clipped sequentially against (a) all *other countries'* regions, (b) its own country's existing regions, and (c) pieces already appended (bbox-touching) — so a piece straddling a border is split, and no piece can double-cover another region's area (**1,149,841 km² of foreign overhang removed** this way; 0 clip failures). **FR-57 Moselle (FRA) gains nothing on purpose**: its apparent gap was a cross-border phantom consumed by the neighbor clip — correct behavior. Holes follow a keep-or-drop policy: a gap hole whose centroid matches a **pre-existing hole** (within 10 km) or lies inside another country's landmass is kept, otherwise it's a phantom and dropped — so the **KZ-KZY Aral hole survives** (5,858 km² planar, the file's only hole; 5,875 km² of water kept open). A global dedup set guarantees each cleaned piece is appended at most once.
|
||||||
|
- **The dataset contract is untouched.** Still **1,796 regions** with identical `id` / `properties.name` / `properties.parent` and order — the fill only *appends* parts; all **177 countries** covered; the single KZ-KZY hole intact. **14 genuinely new overlap pairs** remain (13 same-country border bands + 1 cross CN-FJ×TW-KIN) — a known polychip artifact of filling right up to a shared boundary (the pre-fill file already carried 3,690 pre-existing pairs), and they are sub-pixel at map scale, so they're accepted. The file grows **2,289,528 → 3,399,806 bytes (2.29 → 3.40 MB)** — under the 5 MB guard, so no Douglas–Peucker pass is needed.
|
||||||
|
- **Deterministic + idempotent.** Fixed 25 km² threshold, deterministic ordering (centroid → area → id), 4-dp rounding (same convention as the other scripts). A fresh run on the pristine v0.16 file reproduces the shipped byte-for-byte (sha256 `87f44198…`), and re-running on the filled file is a no-op (byte-identical output).
|
||||||
|
- **Tests** — `scripts/regions.test.ts` (now 9 tests): the v0.16 fixed-point pin is updated to the v0.17 topology (1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole); a new test asserts the **only** hole in the file is KZ-KZY and sits in a realistic 5,000–7,000 km² band; a new **grid-sampled per-country coverage** test (25×25 grid per country, inline even-odd ray cast) asserts no significant no-one's-land remains — median 1.8% / p90 6.1% / max 26.4% (BRN, a coastline-denseness artifact, not a real gap), with thresholds at 5% / 15% / 40% and ≤3 countries above 20%. The planar sum band's upper bound widens 175% → **190%**: the fill only adds area *inside* each country, so it can't shrink the pre-existing admin-1 over-extension tail (Vanuatu 178.9%, Solomon Is. 133% — tiny island nations whose admin-1 polygons extend beyond their own country polygon), while the old under-fill tail is gone (minimum is now Brunei at 78.8%, was ~52%).
|
||||||
|
- **Type-check gotcha (why the test stays turf-free):** the project keeps `@types/geojson` **out of the tsc program** (tsconfig `types: ["vite/client", "node"]`). maplibre-gl's d.ts references the `GeoJSON` UMD global *without importing it*, so under `skipLibCheck` the reference degrades to `any` and `WorldMap.tsx`'s deliberately-minimal `GeoFeature` (with `geometry: unknown`) passes. The moment anything (e.g. a test importing `@turf/turf`) pulls `@types/geojson` into the program, those references typecheck for real and `WorldMap.tsx` fails with 7 errors. The v0.14–0.16 scripts are `.mjs` (never typechecked) for exactly this reason; the new test uses an inline ray cast instead of a geometry library, and `@turf/*` (a new devDependency, 137 packages including the meta `@turf/turf`) is only ever *run* by the `.mjs` scripts.
|
||||||
|
- Part of #16
|
||||||
|
|
||||||
|
## v0.16.0 — 2026-10-13 09:37
|
||||||
|
- **Smoothed the jagged sub-national region boundaries with Douglas–Peucker vertex-thinning (Part of #16).** The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization left some boundaries overly jagged — the worst being regions the dissolve split into many small zig-zag fragments, e.g. **SI-048 Kocevje (Slovenia, 57 parts)** and **MK-36 Kavadartsi (N. Macedonia, 8 parts)**. The new `scripts/smooth-regions.mjs` (`npm run smooth:regions`) runs `@turf/simplify` (Ramer–Douglas–Peucker, tolerance ~**50 m**) over **every ring of every polygon**, dropping the redundant "in-between" vertices of a shallow zig-zag while keeping the overall shape.
|
||||||
|
- **Vertex-thinning only — no topology change.** DP only removes points: it never moves a border, never merges/dissolves a part, and never creates or removes a ring. So the dataset contract is fully preserved — still **1,796 regions** with identical `id` / `properties.name` / `properties.parent`, all **177 countries** covered, the same **5,026 parts / 5,027 rings**, the single **KZ-KZY** hole, and all **83 islands**. The 18 degenerate 3-point "micro-dot" rings (Ragged Island, the Paracels, …) are kept verbatim (`@turf/simplify` requires ≥4 pts and would otherwise throw).
|
||||||
|
- **A gentle pass, reported honestly.** At 50 m the dataset is already fairly smooth (the v0.14 dissolve produced low-vertex, widely-spaced boundaries — median consecutive-vertex spacing ~18 km), so this is a light cleanup: **111,891 → 111,175 vertices (716 removed, 0.64%)** across **542 of 1,796** regions. **Kocelje 612 → 599** and **Kavadartsi 169 → 163** verts; both keep their exact part count (the "mess" there is fragmentation, which thinning — under the no-topology-change constraint — does not fix). The file shrinks **2,302,473 → 2,289,528 bytes (2.29 MB)**. Bump `TOLERANCE_M` to 100 / 200 m and re-run for a stronger pass (100 m → 974 removed; 200 m → 1,312 removed) — the pass stays idempotent either way.
|
||||||
|
- **The "messy" regions are really fragmentation artifacts, not fine jaggedness.** Ranking by **part density** (parts packed per unit area) rather than raw part count — which is dominated by legitimate *dispersed* archipelagos (Nunavut 213, Alaska 159, Antarctica 135, British Columbia 84) — surfaces the genuinely messy **coherent regions the dissolve left fragmented**: **SI-048 Kocevje (SVN, 57 parts in ~16,600 km²)**, **GR-H Stereá Elláda / Central Greece (GRC, 53 parts)**, **KR-46 South Jeolla (KOR, 28 parts)**, **SE-C Uppsala (SWE, 19 parts)**, **HR-13 Zadarska (HRV, 17 parts)**, plus the milder Balkan **MK-36 Kavadartsi (MKD, 8 parts)**. These real land regions (the Balkan ones in particular) ended up as many scattered fragments — the "similar shape problems".
|
||||||
|
- **Deterministic + idempotent.** One `@turf/simplify` pass at a fixed tolerance, then 4-dp rounding (the same convention as the other scripts). The output already sits at the pass's fixed point, so re-running on the smoothed file is byte-identical (sha256 `70a43541…`, verified across repeated runs); `public/regions.geo.json` and the built `dist/regions.geo.json` are sha256-identical.
|
||||||
|
- **Tests.** `scripts/regions.test.ts` gains an eighth test pinning the v0.16 fixed point: 1,796 regions, 5,026 parts, 5,027 rings, the single KZ-KZY hole, and exactly **111,175** vertices (8 tests total). All prior assertions (well-formed, unique ids, 177-country coverage, area floor, holes, coverage band) pass unchanged on the smoothed dataset.
|
||||||
|
- Part of #16
|
||||||
|
|
||||||
|
## v0.15.0 — 2026-10-12 13:49
|
||||||
|
- **Filled the small holes left by the v0.14 coarsening (Part of #16).** The coarsened `public/regions.geo.json` still carried **2,245 tiny enclosed holes** (all < 500 km² in practice) — unfilled patches left by the boolean dissolves and Natural Earth's own digitization, which showed up as "holes in regions". The new `scripts/cleanup-regions.mjs` (`npm run cleanup:regions`) drops every inner ring smaller than the 1,000 km² threshold so each region becomes solid: across **498 features, 56,484 km²** of artifact area was filled, removing the visible unfilled dots.
|
||||||
|
- **Real water is kept, not erased.** Any enclosed hole ≥ 1,000 km² survives as a hole. In this dataset the genuinely large lakes (the Great Lakes, the Caspian, the Aral, Lake Superior) had already been dissolved by the v0.14 coarsening, so none remain to preserve; the only surviving hole is a single medium one — **KZ-KZY (Kyzylorda, Kazakhstan) at 5,858 km²**, a real water body in the Aral basin — which is kept. The 1,000 km² threshold sits comfortably between the tiny artifacts (all < 500) and any genuine lake, so it fills exactly the artifacts and nothing else.
|
||||||
|
- **The dataset contract is untouched.** Still **1,796 regions** with identical `id`s / `properties.name` / `properties.parent` (0 mismatches), all **177 countries** still covered, the ~100 km floor and the island exemption intact — only inner rings were dropped, never a feature or its identity. The file shrinks 2,431 → **2,249 KB (2.20 MB)** (fewer inner rings).
|
||||||
|
- **Deterministic + idempotent.** Fixed threshold, deterministic planar geometry, 4-dp rounding; re-running on the cleaned file rewrites a byte-identical file (sha256-verified). Bridging very-close MultiPolygon parts is available behind an opt-in `--bridge` flag but is **off by default**, because the union it performs can itself mint new medium-sized holes — counter to the fill-holes goal — so the shipped run is the clean hole-fill only.
|
||||||
|
- **Tests.** `scripts/regions.test.ts` gains a seventh test asserting that no enclosed hole below the cleanup threshold remains and that only a handful of real water bodies survive (57 tests total). `public/regions.geo.json` and the built `dist/regions.geo.json` are sha256-identical.
|
||||||
|
- Part of #16.
|
||||||
|
|
||||||
## v0.14.0 — 2026-10-12 09:51
|
## v0.14.0 — 2026-10-12 09:51
|
||||||
- **Coarsened the sub-national choropleth dataset to a ~100 km floor (Part of #16).** `public/regions.geo.json` shrinks from 4,138 real administrative regions to **1,796** (2,342 merges): every region smaller than ~100×100 km (10,000 km²) that has a same-country neighbor within 0.2° (≈22 km) of its bounding box is **dissolved into its nearest such neighbor** (nearest by centroid distance; ties → larger area, then id). Standalone cities and micro-districts collapse into the region around them: `US-DC` (191 km²) into Maryland, `FR-75` Paris (59 km²) into Hauts-de-Seine, the UK's 232 admin-1 districts down to **16** regions, and all of North Macedonia's 85 municipalities into one whole-country region.
|
- **Coarsened the sub-national choropleth dataset to a ~100 km floor (Part of #16).** `public/regions.geo.json` shrinks from 4,138 real administrative regions to **1,796** (2,342 merges): every region smaller than ~100×100 km (10,000 km²) that has a same-country neighbor within 0.2° (≈22 km) of its bounding box is **dissolved into its nearest such neighbor** (nearest by centroid distance; ties → larger area, then id). Standalone cities and micro-districts collapse into the region around them: `US-DC` (191 km²) into Maryland, `FR-75` Paris (59 km²) into Hauts-de-Seine, the UK's 232 admin-1 districts down to **16** regions, and all of North Macedonia's 85 municipalities into one whole-country region.
|
||||||
- **Islands are exempt.** A region with no same-country neighbor within 0.2° keeps its shape whatever its size — **83** such small islands survive in the final set (Corsica, the Kerguelen/Crozet archipelagos, the Bahamas districts, Lord Howe, …) and the coarsening's fixed point leaves **0** non-island regions below the floor.
|
- **Islands are exempt.** A region with no same-country neighbor within 0.2° keeps its shape whatever its size — **83** such small islands survive in the final set (Corsica, the Kerguelen/Crozet archipelagos, the Bahamas districts, Lord Howe, …) and the coarsening's fixed point leaves **0** non-island regions below the floor.
|
||||||
|
|||||||
Generated
+2387
-2
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.14.0",
|
"version": "0.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"generate:regions": "node scripts/generate-regions.mjs",
|
"generate:regions": "node scripts/generate-regions.mjs",
|
||||||
"coarsen:regions": "node scripts/coarsen-regions.mjs",
|
"coarsen:regions": "node scripts/coarsen-regions.mjs",
|
||||||
|
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||||
|
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||||
|
"fill:regions": "node scripts/fill-gaps.mjs",
|
||||||
|
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
@@ -40,6 +44,14 @@
|
|||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"@turf/area": "^7.4.0",
|
||||||
|
"@turf/bbox": "^7.4.0",
|
||||||
|
"@turf/boolean-point-in-polygon": "^7.4.0",
|
||||||
|
"@turf/centroid": "^7.4.0",
|
||||||
|
"@turf/difference": "^7.4.0",
|
||||||
|
"@turf/distance": "^7.4.0",
|
||||||
|
"@turf/simplify": "^7.4.0",
|
||||||
|
"@turf/turf": "^7.4.0",
|
||||||
"@turf/union": "^7.4.0",
|
"@turf/union": "^7.4.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-keys.mjs — build the deterministic region-key manifest.
|
||||||
|
*
|
||||||
|
* Region ownership flips from whole-country (ISO_A3) to individual sub-region
|
||||||
|
* granularity, but the LLM and the persisted `Nation.territory` list need a
|
||||||
|
* *stable, human-readable, globally unique* identifier per sub-region. Raw
|
||||||
|
* feature ids (`FR-57`) are opaque to the model, so we mint a readable key of
|
||||||
|
* the form:
|
||||||
|
*
|
||||||
|
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
|
||||||
|
*
|
||||||
|
* where:
|
||||||
|
* - <name-slug> = properties.name → NFKD → strip combining marks → lowercase
|
||||||
|
* → a tiny fold table for the handful of letters NFKD does NOT decompose
|
||||||
|
* (ə ø ð đ œ ł, the only non-folding letters present in this dataset)
|
||||||
|
* → every non-alphanumeric run collapsed to a single '-' → leading/trailing
|
||||||
|
* '-' trimmed.
|
||||||
|
* - <4char> = a deterministic 4-letter lowercase code derived from the
|
||||||
|
* feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable
|
||||||
|
* (same id → same code on every run) because the key is persisted in
|
||||||
|
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
||||||
|
* regions all named "Western") the 4-char codes are made distinct by
|
||||||
|
* re-deriving with a deterministic salt on collision, which guarantees the
|
||||||
|
* full key is unique across all 1,796 regions.
|
||||||
|
*
|
||||||
|
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||||
|
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
||||||
|
* is looked up by the client (`WorldMap`) and the server (`map`/`presets`/
|
||||||
|
* `engine`) to join a sub-region's stable id to its readable key.
|
||||||
|
*
|
||||||
|
* Builtins only — deliberately NO `@turf`/geojson import (importing one pulls
|
||||||
|
* `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the
|
||||||
|
* v0.17 changelog note). Reads the geo file with plain `JSON.parse`.
|
||||||
|
*
|
||||||
|
* Idempotent: a re-run rewrites a byte-identical file (no timestamps, no
|
||||||
|
* randomness, stable feature order, deterministic hash).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letters present in the dataset that NFKD does NOT decompose, and their
|
||||||
|
* single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup.
|
||||||
|
* Kept deliberately tiny + deterministic (only the six that actually occur).
|
||||||
|
*/
|
||||||
|
const FOLD = {
|
||||||
|
'ə': 'a', // schwa
|
||||||
|
'ø': 'o', // o-stroke
|
||||||
|
'ð': 'd', // eth
|
||||||
|
'đ': 'dj', // d-stroke
|
||||||
|
'œ': 'oe', // oe ligature
|
||||||
|
'ł': 'l', // l-stroke
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readable slug from a region name. NFKD decomposes + we strip the combining
|
||||||
|
* marks, lowercase, fold the non-decomposing letters, collapse every other
|
||||||
|
* non-alphanumeric run to one hyphen, and trim leading/trailing hyphens.
|
||||||
|
*/
|
||||||
|
function slug(name) {
|
||||||
|
let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase();
|
||||||
|
s = [...s].map((ch) => FOLD[ch] ?? ch).join('');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit — a tiny, stable, pure string hash. */
|
||||||
|
function fnv1a32(str) {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = (h * 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
|
||||||
|
function code4(h) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 4-char code for an id, deterministically salted on within-group collision. */
|
||||||
|
function codeFor(id, used) {
|
||||||
|
let code = code4(fnv1a32(id));
|
||||||
|
let attempt = 0;
|
||||||
|
while (used.has(code)) {
|
||||||
|
attempt += 1;
|
||||||
|
code = code4(fnv1a32(`${id}#${attempt}`));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load the region geo (plain parse; no geojson types) ---
|
||||||
|
const geoFile = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||||
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||||
|
|
||||||
|
if (features.length !== 1796) {
|
||||||
|
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group features by their (computed) name-slug, preserving file order, so the
|
||||||
|
// within-slug 4-char uniqueness resolution below is deterministic.
|
||||||
|
const groups = new Map(); // slug -> array of { id }
|
||||||
|
for (const f of features) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (!id || typeof name !== 'string') {
|
||||||
|
throw new Error(`region with id ${JSON.stringify(id)} has no usable name`);
|
||||||
|
}
|
||||||
|
const s = slug(name);
|
||||||
|
if (!groups.has(s)) groups.set(s, []);
|
||||||
|
groups.get(s).push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign keys, resolving 4-char collisions within each slug group.
|
||||||
|
const manifest = {};
|
||||||
|
for (const [s, ids] of groups) {
|
||||||
|
const used = new Set();
|
||||||
|
for (const { id } of ids) {
|
||||||
|
manifest[id] = `${s}-${codeFor(id, used)}`;
|
||||||
|
used.add(manifest[id].slice(-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
if (new Set(keys).size !== keys.length) {
|
||||||
|
throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`);
|
||||||
|
}
|
||||||
|
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (!KEY_RE.test(k) || !k.includes('-')) {
|
||||||
|
throw new Error(`malformed region key: ${JSON.stringify(k)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (stable order = insertion order = file order) ---
|
||||||
|
const out = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
|
||||||
|
|
||||||
|
const western = Object.entries(manifest)
|
||||||
|
.filter(([, k]) => k.startsWith('western-'))
|
||||||
|
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||||
|
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
|
||||||
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/cleanup-regions.mjs — removes the small holes left by coarsening.
|
||||||
|
*
|
||||||
|
* Run: npm run cleanup:regions (in place on public/regions.geo.json)
|
||||||
|
* node scripts/cleanup-regions.mjs [out] [src] [--bridge]
|
||||||
|
*
|
||||||
|
* The v0.14 coarsening produced a clean 1,796-region dataset, but small
|
||||||
|
* unfilled patches ("holes") remain inside regions — artifacts of the boolean
|
||||||
|
* dissolves and Natural Earth's own digitization. This fixes that in place,
|
||||||
|
* WITHOUT touching the dataset's contract (the region count, the `id`s, the
|
||||||
|
* `properties = { name, parent }`, the ~100 km floor and the island exemption
|
||||||
|
* are all preserved — only inner rings are dropped).
|
||||||
|
*
|
||||||
|
* ENCLOSED HOLES — every hole (inner ring) whose area is < HOLE_AREA_MAX_KM2
|
||||||
|
* is dropped, so the region fills in to be solid. Any larger hole (a genuine
|
||||||
|
* water body) is kept. NOTE: the big real lakes (Great Lakes, Caspian, Aral,
|
||||||
|
* Lake Superior, …) were already dissolved by the v0.14 coarsening, so they
|
||||||
|
* are not present as holes here; the only hole above the threshold in this
|
||||||
|
* dataset is a single medium one (~5,858 km2, kept). A 1,000 km2 threshold
|
||||||
|
* is comfortably above the 2,252 tiny artifact holes (all < 500 km2) and
|
||||||
|
* comfortably below any large real lake, so it fills exactly the artifacts.
|
||||||
|
*
|
||||||
|
* BRIDGING (opt-in, `--bridge`) — a MultiPolygon whose separate parts sit
|
||||||
|
* only a short distance apart (a thin gap across a strait, a nearby island)
|
||||||
|
* leaves a visible crack of background. This joins each such close pair with
|
||||||
|
* a small connector polygon and a boolean union, closing the crack while
|
||||||
|
* genuinely far parts (islands, separated territories) stay separate. It is
|
||||||
|
* OFF by default: the union itself can mint new (medium) holes, which is
|
||||||
|
* counter to the primary "fill holes" goal, so the default run is the
|
||||||
|
* clean, deterministic hole-fill only.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: fixed threshold, deterministic planar geometry,
|
||||||
|
* 4-dp rounding. Re-running on an already-clean file rewrites the same file
|
||||||
|
* (no small holes remain to fill; the kept holes are above the threshold).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { union } from '@turf/union';
|
||||||
|
import { featureCollection } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const DEFAULT_FILE = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
// positional args (out, src) plus named flags; keep the two orthogonal
|
||||||
|
const args = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
||||||
|
const flags = process.argv.slice(2).filter((a) => a.startsWith('-'));
|
||||||
|
const BRIDGE = flags.includes('--bridge');
|
||||||
|
const OUT = args[0] ? path.resolve(args[0]) : DEFAULT_FILE;
|
||||||
|
const SRC = args[1] ? path.resolve(args[1]) : DEFAULT_FILE;
|
||||||
|
|
||||||
|
// --- parameters ---------------------------------------------------------------
|
||||||
|
const HOLE_AREA_MAX_KM2 = 1000; // fill enclosed holes smaller than this (keep real lakes)
|
||||||
|
const BRIDGE_MAX_KM = 30; // bridge part-gaps smaller than this …
|
||||||
|
const BRIDGE_MIN_KM = 0.3; // … but only real gaps (a pair in (BRIDGE_MIN, BRIDGE_MAX))
|
||||||
|
const BRIDGE_EXT_KM = 2; // extend each bridge end into its part (ensures union overlap)
|
||||||
|
const BRIDGE_HW_MIN_KM = 2; // minimum half-width of a connector
|
||||||
|
const BRIDGE_HW_MAX_KM = 20; // cap so a connector never becomes a big blob
|
||||||
|
const R4 = 4; // coordinate precision (decimal places)
|
||||||
|
const SLIVER_EPS_KM2 = 1e-6; // drop only truly-degenerate (≈zero-area) parts
|
||||||
|
|
||||||
|
// --- area model (identical to coarsen-regions.mjs / regions.test.ts) -----------
|
||||||
|
const KM_LON = 111.32;
|
||||||
|
const KM_LAT = 110.574;
|
||||||
|
const rad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
function ringAreaKm2(ring) {
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0));
|
||||||
|
let a = 0;
|
||||||
|
const n = ring.length;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [lon1, lat1] = ring[i];
|
||||||
|
const [lon2, lat2] = ring[(i + 1) % n];
|
||||||
|
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||||
|
}
|
||||||
|
return Math.abs(a / 2);
|
||||||
|
}
|
||||||
|
function geomAreaKm2(geom) {
|
||||||
|
if (!geom) return 0;
|
||||||
|
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||||
|
let total = 0;
|
||||||
|
for (const poly of polys) total += ringAreaKm2(poly[0]);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rounding / normalization (same conventions as coarsen-regions.mjs) --------
|
||||||
|
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
|
||||||
|
function roundRing(ring, N = R4) {
|
||||||
|
const out = [];
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
const p = [rN(x, N), rN(y, N)];
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
|
||||||
|
}
|
||||||
|
if (out.length > 1 && out[0][0] === out[out.length - 1][0] && out[0][1] === out[out.length - 1][1]) out.pop();
|
||||||
|
if (out.length < 3) return out;
|
||||||
|
return [...out, [...out[0]]];
|
||||||
|
}
|
||||||
|
function roundAll(geom) {
|
||||||
|
if (!geom || (geom.type !== 'Polygon' && geom.type !== 'MultiPolygon')) return null;
|
||||||
|
const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates)
|
||||||
|
.map((poly) => poly.map((ring) => roundRing(ring)))
|
||||||
|
.filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3));
|
||||||
|
if (!polys.length) return null;
|
||||||
|
if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
|
||||||
|
return { type: 'MultiPolygon', coordinates: polys };
|
||||||
|
}
|
||||||
|
function normalizeGeom(geom) {
|
||||||
|
const rounded = roundAll(geom);
|
||||||
|
if (!rounded) return null;
|
||||||
|
const all = rounded.type === 'Polygon' ? [rounded.coordinates] : rounded.coordinates;
|
||||||
|
const kept = all.filter((poly) => ringAreaKm2(poly[0]) >= SLIVER_EPS_KM2);
|
||||||
|
const polys = kept.length ? kept : all;
|
||||||
|
if (polys.length === 1 && rounded.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
|
||||||
|
return { type: 'MultiPolygon', coordinates: polys };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- closest-point measurement (decimated planar point↔segment, ~km) ----------
|
||||||
|
function toXY(ring) {
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0));
|
||||||
|
return ring.map(([lon, lat]) => [lon * kx, lat * KM_LAT]);
|
||||||
|
}
|
||||||
|
function dec(pts, maxN = 200) {
|
||||||
|
if (pts.length <= maxN) return pts;
|
||||||
|
const step = Math.ceil(pts.length / maxN);
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < pts.length; i += step) out.push(pts[i]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Closest point-pair between two planar rings: returns { d, p, q } with p on A,
|
||||||
|
* q on B and d = |p-q| in km (both on the decimated rings — a good-enough
|
||||||
|
* approximation for bridging; a tiny offset is corrected by the extension).
|
||||||
|
*/
|
||||||
|
function ringClosest(AX, BX) {
|
||||||
|
const A = dec(AX), B = dec(BX);
|
||||||
|
let best = Infinity, bestP = null, bestQ = null;
|
||||||
|
for (const p of A) {
|
||||||
|
for (let i = 0; i < B.length - 1; i++) {
|
||||||
|
const a = B[i], b = B[i + 1];
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
const l2 = dx * dx + dy * dy;
|
||||||
|
let t = l2 === 0 ? 0 : ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const c = [a[0] + t * dx, a[1] + t * dy];
|
||||||
|
const dd = Math.hypot(p[0] - c[0], p[1] - c[1]);
|
||||||
|
if (dd < best) { best = dd; bestP = p; bestQ = c; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of B) {
|
||||||
|
for (let i = 0; i < A.length - 1; i++) {
|
||||||
|
const a = A[i], b = A[i + 1];
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
const l2 = dx * dx + dy * dy;
|
||||||
|
let t = l2 === 0 ? 0 : ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const c = [a[0] + t * dx, a[1] + t * dy];
|
||||||
|
const dd = Math.hypot(p[0] - c[0], p[1] - c[1]);
|
||||||
|
if (dd < best) { best = dd; bestP = c; bestQ = p; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { d: best, p: bestP, q: bestQ };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a thin connector (a quad in lon/lat) between two closest points p, q
|
||||||
|
* (both in planar km). The connector is extended a little into each part so the
|
||||||
|
* boolean union joins them, and its half-width is wide enough to span the local
|
||||||
|
* gap (capped so it never becomes a big blob).
|
||||||
|
*/
|
||||||
|
function makeBridgeRing(p, q) {
|
||||||
|
const gap = Math.hypot(q[0] - p[0], q[1] - p[1]);
|
||||||
|
if (gap < 1e-3) return null; // coincident → nothing to bridge
|
||||||
|
const midLat = (p[1] / KM_LAT + q[1] / KM_LAT) / 2;
|
||||||
|
const kx = KM_LON * Math.cos(rad(midLat));
|
||||||
|
const ux = (q[0] - p[0]) / gap, uy = (q[1] - p[1]) / gap; // unit p→q
|
||||||
|
const nx = -uy, ny = ux; // perpendicular
|
||||||
|
const hw = Math.min(BRIDGE_HW_MAX_KM, Math.max(BRIDGE_HW_MIN_KM, gap / 2));
|
||||||
|
const ext = BRIDGE_EXT_KM;
|
||||||
|
const pExt = [p[0] - ux * ext, p[1] - uy * ext];
|
||||||
|
const qExt = [q[0] + ux * ext, q[1] + uy * ext];
|
||||||
|
const toLL = ([x, y]) => [rN(x / kx, R4), rN(y / KM_LAT, R4)];
|
||||||
|
const c1 = toLL([pExt[0] + nx * hw, pExt[1] + ny * hw]);
|
||||||
|
const c2 = toLL([qExt[0] + nx * hw, qExt[1] + ny * hw]);
|
||||||
|
const c3 = toLL([qExt[0] - nx * hw, qExt[1] - ny * hw]);
|
||||||
|
const c4 = toLL([pExt[0] - nx * hw, pExt[1] - ny * hw]);
|
||||||
|
return [c1, c2, c3, c4, c1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bridge all close pairs of a MultiPolygon and union everything into one shape. */
|
||||||
|
function bridgeParts(geom) {
|
||||||
|
const parts = geom.coordinates;
|
||||||
|
const partsXY = parts.map((p) => toXY(p[0]));
|
||||||
|
const bridges = [];
|
||||||
|
let bridgedPairs = 0;
|
||||||
|
for (let i = 0; i < partsXY.length; i++) {
|
||||||
|
for (let j = i + 1; j < partsXY.length; j++) {
|
||||||
|
const { d, p, q } = ringClosest(partsXY[i], partsXY[j]);
|
||||||
|
if (d > BRIDGE_MIN_KM && d < BRIDGE_MAX_KM) {
|
||||||
|
const ring = makeBridgeRing(p, q);
|
||||||
|
if (ring) { bridges.push(ring); bridgedPairs++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bridges.length) return { geom, bridgedPairs: 0, changed: false };
|
||||||
|
// union: original parts (as Polygons) + connector rings (as Polygons)
|
||||||
|
const feats = parts.map((poly) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: poly } }));
|
||||||
|
for (const ring of bridges) feats.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } });
|
||||||
|
let u;
|
||||||
|
try {
|
||||||
|
u = union(featureCollection(feats));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` [warn] bridge union failed: ${e.message}`);
|
||||||
|
return { geom, bridgedPairs: 0, changed: false };
|
||||||
|
}
|
||||||
|
const norm = u ? normalizeGeom(u.geometry) : null;
|
||||||
|
// Guard: a good bridge only adds a little area. Reject the union if it lost
|
||||||
|
// area (< 0.8×, a bad union that ate parts) or over-filled (> 1.3×, e.g. many
|
||||||
|
// overlapping bridges in a dense archipelago) — keep the original instead.
|
||||||
|
if (norm) {
|
||||||
|
const na = geomAreaKm2(norm);
|
||||||
|
const oa = geomAreaKm2(geom);
|
||||||
|
if (oa > 0 && (na < 0.8 * oa || na > 1.3 * oa)) {
|
||||||
|
return { geom, bridgedPairs: 0, changed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { geom: norm ?? geom, bridgedPairs, changed: true, bridgedParts: u ? (u.geometry.type === 'Polygon' ? 1 : u.geometry.coordinates.length) : parts.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main ----------------------------------------------------------------------
|
||||||
|
function main() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const fc = JSON.parse(readFileSync(SRC, 'utf8'));
|
||||||
|
const inKB = (Buffer.byteLength(readFileSync(SRC)) / 1024).toFixed(0);
|
||||||
|
console.log(`input: ${fc.features.length} regions (${inKB} KB) from ${SRC}`);
|
||||||
|
console.log(`mode: holes <${HOLE_AREA_MAX_KM2} km2 filled, larger kept${BRIDGE ? `; + bridging part-gaps in (${BRIDGE_MIN_KM}, ${BRIDGE_MAX_KM}) km` : ''}`);
|
||||||
|
|
||||||
|
// diagnostics
|
||||||
|
let holesFilled = 0, holesFilledArea = 0, holesKept = 0, holesKeptArea = 0, featuresTouchedHole = 0;
|
||||||
|
let bridgedFeatures = 0, bridgedPairs = 0, partsBefore = 0, partsAfter = 0, areaBefore = 0, areaAfter = 0;
|
||||||
|
const bridgeSamples = [];
|
||||||
|
const keptHoleSamples = [];
|
||||||
|
|
||||||
|
const outFeatures = [];
|
||||||
|
for (const f of fc.features) {
|
||||||
|
let geom = f.geometry;
|
||||||
|
|
||||||
|
// ---- Opt-in: bridge close parts (additive connector + union) ----
|
||||||
|
if (BRIDGE && geom.type === 'MultiPolygon' && geom.coordinates.length >= 2) {
|
||||||
|
const before = { parts: geom.coordinates.length, area: geomAreaKm2(geom) };
|
||||||
|
const res = bridgeParts(geom);
|
||||||
|
if (res.changed) {
|
||||||
|
geom = res.geom;
|
||||||
|
bridgedFeatures++;
|
||||||
|
bridgedPairs += res.bridgedPairs;
|
||||||
|
partsBefore += before.parts;
|
||||||
|
partsAfter += geom.type === 'Polygon' ? 1 : geom.coordinates.length;
|
||||||
|
areaBefore += before.area;
|
||||||
|
areaAfter += geomAreaKm2(geom);
|
||||||
|
if (bridgeSamples.length < 20)
|
||||||
|
bridgeSamples.push(`${f.id} ${f.properties.name} (${f.properties.parent}): ${before.parts}p→${geom.type === 'Polygon' ? 1 : geom.coordinates.length}p, ${res.bridgedPairs} bridge(s), ${Math.round(before.area)}→${Math.round(geomAreaKm2(geom))} km2`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fill small enclosed holes: drop inner rings < threshold, keep larger ----
|
||||||
|
const isMulti = geom.type === 'MultiPolygon';
|
||||||
|
const polys = isMulti ? geom.coordinates : [geom.coordinates];
|
||||||
|
let touchedHole = false;
|
||||||
|
const newPolys = polys.map((poly) => {
|
||||||
|
const kept = [poly[0]]; // outer ring always kept
|
||||||
|
for (let i = 1; i < poly.length; i++) {
|
||||||
|
const area = ringAreaKm2(poly[i]);
|
||||||
|
if (area < 1e-6) continue; // degenerate — drop
|
||||||
|
if (area < HOLE_AREA_MAX_KM2) {
|
||||||
|
holesFilled++; holesFilledArea += area; touchedHole = true; // fill (drop)
|
||||||
|
} else {
|
||||||
|
holesKept++; holesKeptArea += area;
|
||||||
|
kept.push(poly[i]); // a genuine water body — keep the hole
|
||||||
|
if (keptHoleSamples.length < 20) keptHoleSamples.push(`${f.id}(${f.properties.parent}) ${Math.round(area)} km2`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
});
|
||||||
|
if (touchedHole) featuresTouchedHole++;
|
||||||
|
|
||||||
|
const rebuilt = isMulti
|
||||||
|
? { type: 'MultiPolygon', coordinates: newPolys }
|
||||||
|
: { type: 'Polygon', coordinates: newPolys[0] };
|
||||||
|
// normalize (round 4dp, drop degenerate). NEVER drop a feature: if nothing
|
||||||
|
// usable survives (a degenerate 3-pt 0-area micro-dot), keep the
|
||||||
|
// hole-filled (rebuilt) geometry, else the untouched original.
|
||||||
|
const norm = normalizeGeom(rebuilt);
|
||||||
|
outFeatures.push({ type: 'Feature', id: f.id, properties: { name: f.properties.name, parent: f.properties.parent }, geometry: norm ?? rebuilt ?? geom });
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = { type: 'FeatureCollection', features: outFeatures };
|
||||||
|
const json = JSON.stringify(out);
|
||||||
|
const outKB = (Buffer.byteLength(json) / 1024).toFixed(0);
|
||||||
|
writeFileSync(OUT, json + '\n');
|
||||||
|
|
||||||
|
console.log(`\n--- RESULTS ---`);
|
||||||
|
console.log(`regions: ${fc.features.length} → ${out.features.length} (should be unchanged)`);
|
||||||
|
console.log(`file: ${inKB} KB → ${outKB} KB (${(outKB / 1024).toFixed(2)} MB)`);
|
||||||
|
console.log(`\nENCLOSED HOLES:`);
|
||||||
|
console.log(` filled ${holesFilled} holes (${Math.round(holesFilledArea).toLocaleString('en-US')} km2) in ${featuresTouchedHole} features`);
|
||||||
|
console.log(` kept ${holesKept} hole(s) (${Math.round(holesKeptArea).toLocaleString('en-US')} km2) as real water`);
|
||||||
|
if (keptHoleSamples.length) console.log(` kept-hole list: ${keptHoleSamples.join(', ')}`);
|
||||||
|
if (BRIDGE) {
|
||||||
|
console.log(`\nBRIDGING (part-gaps):`);
|
||||||
|
console.log(` bridged ${bridgedFeatures} features (${bridgedPairs} close pairs): parts ${partsBefore} → ${partsAfter}, area ${Math.round(areaBefore).toLocaleString('en-US')} → ${Math.round(areaAfter).toLocaleString('en-US')} km2`);
|
||||||
|
for (const s of bridgeSamples) console.log(` ${s}`);
|
||||||
|
} else {
|
||||||
|
console.log(`\nbridging: skipped (pass --bridge to enable)`);
|
||||||
|
}
|
||||||
|
console.log(`\nwrote ${OUT}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fill "no-one's-land" gaps left in `public/regions.geo.json` by the v0.14 coarsening merge.
|
||||||
|
*
|
||||||
|
* For each country, the gap = (country landmass) − (union of the country's sub-regions):
|
||||||
|
* area inside the country's authoritative landmass that no sub-region covers. Each gap
|
||||||
|
* fragment is assigned to the **nearest** sub-region (centroid haversine distance) and
|
||||||
|
* appended to it as extra polygon part(s). A fragment is only added if:
|
||||||
|
*
|
||||||
|
* • it is not a sub-tiny sliver (≥ GAP_MIN_KM2),
|
||||||
|
* • it does not fall inside an existing region's *hole* (water) — this keeps the
|
||||||
|
* Aral-basin hole in KZ-KZY open,
|
||||||
|
* • it is not byte-identical to a part the file already contains (polyclip sometimes
|
||||||
|
* re-emits a region's own outer ring with a phantom sliver hole; once the hole is
|
||||||
|
* cleaned the piece equals the existing part, and appending it would only grow the
|
||||||
|
* file on every re-run),
|
||||||
|
* • it has been clipped against every region of *other* countries (and against pieces
|
||||||
|
* already appended this run) that touch its bbox, so it never overlaps an existing
|
||||||
|
* region — no new cross-border overlaps are introduced.
|
||||||
|
*
|
||||||
|
* Holes found in gap fragments are kept only when they are real: pre-existing region
|
||||||
|
* holes (matched by centroid) or enclaves of another country (e.g. Lesotho inside ZAF,
|
||||||
|
* so the ZAF fragment stays a ring and never covers the LSO region); every other hole
|
||||||
|
* is a polyclip phantom and is dropped.
|
||||||
|
*
|
||||||
|
* Standard GeoJSON (`{type, coordinates}`) throughout — the same shape the file and
|
||||||
|
* MapLibre already use, and what @turf v7 expects.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/fill-gaps.mjs [inFile [outFile]]
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
* Env: GAP_MIN_KM2 (default 25) — gap fragments below this area are ignored slivers.
|
||||||
|
*
|
||||||
|
* Deterministic: same input → same output. Re-running on an already-filled file is a
|
||||||
|
* no-op (every re-discovered fragment is either a sub-threshold sliver, a phantom that
|
||||||
|
* byte-matches an existing part, or already covered by the enlarged union), so the
|
||||||
|
* result is idempotent.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import difference from "@turf/difference";
|
||||||
|
import union from "@turf/union";
|
||||||
|
import centroid from "@turf/centroid";
|
||||||
|
import bbox from "@turf/bbox";
|
||||||
|
import area from "@turf/area";
|
||||||
|
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
||||||
|
import distance from "@turf/distance";
|
||||||
|
import { feature, featureCollection, multiPolygon, polygon, point } from "@turf/helpers";
|
||||||
|
|
||||||
|
const GAP_MIN_KM2 = Number(process.env.GAP_MIN_KM2 ?? 25);
|
||||||
|
const RING_MIN_KM2 = 1e-4; // drop degenerate (numerical-noise) rings
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round coordinates in place to 6 decimals (file convention). Accepts either a single
|
||||||
|
* ring (array of positions) or a polygon part (array of rings) and detects the depth.
|
||||||
|
*/
|
||||||
|
function roundCoords(coords) {
|
||||||
|
const isRing = Array.isArray(coords[0]) && Array.isArray(coords[0][0]) ? false : true;
|
||||||
|
const rings = isRing ? [coords] : coords;
|
||||||
|
for (const ring of rings) for (const c of ring) {
|
||||||
|
c[0] = Number(c[0].toFixed(6));
|
||||||
|
c[1] = Number(c[1].toFixed(6));
|
||||||
|
}
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area (km²) of a closed ring, 0 when degenerate/NaN. */
|
||||||
|
function ringKm2(ring) {
|
||||||
|
if (!isValidRing(ring)) return 0;
|
||||||
|
try { return area(polygon([ring])) * 1e-6; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A valid GeoJSON ring: ≥4 closed positions, each [finiteNumber, finiteNumber]. */
|
||||||
|
function isValidRing(ring) {
|
||||||
|
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||||
|
for (const c of ring) {
|
||||||
|
if (!Array.isArray(c) || c.length < 2) return false;
|
||||||
|
if (typeof c[0] !== "number" || !Number.isFinite(c[0])) return false;
|
||||||
|
if (typeof c[1] !== "number" || !Number.isFinite(c[1])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level refs populated in main before use (see keepHole).
|
||||||
|
let preHoleCentroids = []; // centroids of holes present in the input file (kept verbatim)
|
||||||
|
let otherLandmasses = []; // { iso, feature } for every country landmass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this hole a "real" one that must be preserved? Kept when it is a pre-existing
|
||||||
|
* region hole (centroid match, e.g. the Aral) or lies inside another country's
|
||||||
|
* landmass (enclave, e.g. Lesotho). Otherwise it is a polyclip phantom → dropped.
|
||||||
|
*/
|
||||||
|
function keepHole(hole, currentIso) {
|
||||||
|
const hKm2 = ringKm2(hole);
|
||||||
|
if (!(hKm2 >= RING_MIN_KM2)) return false; // degenerate / NaN
|
||||||
|
const cm = centroid(polygon([hole])).geometry.coordinates;
|
||||||
|
if (preHoleCentroids.some((p) => distance(cm, p) < 10)) return true;
|
||||||
|
if (otherLandmasses.some((l) => l.iso !== currentIso && booleanPointInPolygon(point(cm), l.feature))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a GeoJSON polygon part (`[outerRing, ...holeRings]`): round coordinates, keep
|
||||||
|
* only real holes (see keepHole). Returns the cleaned part, or null if the outer ring
|
||||||
|
* is degenerate.
|
||||||
|
*/
|
||||||
|
function cleanPolygonPart(part, currentIso) {
|
||||||
|
if (!Array.isArray(part) || part.length === 0) return null;
|
||||||
|
const outer = part[0];
|
||||||
|
if (!isValidRing(outer)) return null;
|
||||||
|
const outerKm2 = ringKm2(outer);
|
||||||
|
if (!(outerKm2 >= RING_MIN_KM2)) return null; // covers NaN too
|
||||||
|
const holes = part.slice(1).filter((h) => isValidRing(h) && keepHole(h, currentIso));
|
||||||
|
roundCoords([outer, ...holes]);
|
||||||
|
return [outer, ...holes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a geometry (Polygon|MultiPolygon) into cleaned polygon parts. */
|
||||||
|
function partsOf(geom, iso) {
|
||||||
|
if (!geom) return [];
|
||||||
|
const raw = geom.type === "MultiPolygon" ? geom.coordinates : [geom.coordinates];
|
||||||
|
const out = [];
|
||||||
|
for (const p of raw) { const c = cleanPolygonPart(p, iso); if (c) out.push(c); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild a geometry (NOT a feature) from cleaned parts. */
|
||||||
|
function toGeometry(parts) {
|
||||||
|
// turf helpers return Features; take their .geometry ({type, coordinates}).
|
||||||
|
return parts.length > 1 ? multiPolygon(parts).geometry : polygon(parts[0]).geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bbox of a polygon part, as [west, south, east, north]. */
|
||||||
|
function partBBox(part) { return bbox(polygon(part)); }
|
||||||
|
|
||||||
|
function bBoxesOverlap(a, b) {
|
||||||
|
return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample points along a part's outer ring (start + every ~max(1, n/16)th vertex). */
|
||||||
|
function samplePoints(part, maxN = 16) {
|
||||||
|
const ring = part[0];
|
||||||
|
const n = ring.length;
|
||||||
|
if (n <= maxN) return ring;
|
||||||
|
const step = Math.ceil(n / maxN);
|
||||||
|
const out = [ring[0]];
|
||||||
|
for (let i = step; i < n; i += step) out.push(ring[i]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level list of pre-existing region holes (water guard), built in main.
|
||||||
|
let holeRings = []; // { bbox, poly }
|
||||||
|
|
||||||
|
/** Does a gap piece overlap any pre-existing region hole (water)? */
|
||||||
|
function overlapsAnyHole(piece, pieceCm) {
|
||||||
|
const pBBox = partBBox(piece);
|
||||||
|
const candidates = holeRings.filter((h) => bBoxesOverlap(pBBox, h.bbox));
|
||||||
|
if (candidates.length === 0) return false;
|
||||||
|
const pts = [pieceCm, ...samplePoints(piece)];
|
||||||
|
return candidates.some((h) => pts.some((p) => booleanPointInPolygon(point(p), h.poly)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- main
|
||||||
|
|
||||||
|
const inFile = process.argv[2] ?? "public/regions.geo.json";
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
const geo = JSON.parse(fs.readFileSync(inFile, "utf8"));
|
||||||
|
const countries = JSON.parse(fs.readFileSync(new URL("../public/countries.geo.json", import.meta.url), "utf8"));
|
||||||
|
|
||||||
|
otherLandmasses = countries.features.map((f) => ({ iso: f.id, feature: f }));
|
||||||
|
|
||||||
|
const features = geo.features;
|
||||||
|
const regionsByParent = new Map();
|
||||||
|
for (const f of features) {
|
||||||
|
const p = f.properties.parent;
|
||||||
|
if (!regionsByParent.has(p)) regionsByParent.set(p, []);
|
||||||
|
regionsByParent.get(p).push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region inner-ring (hole) list for the water-hole guard; also the centroids of every
|
||||||
|
// pre-existing hole (so the hole policy never destroys one, e.g. KZ-KZY's Aral hole).
|
||||||
|
for (const f of features) {
|
||||||
|
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
|
||||||
|
for (const p of parts) for (const h of p.slice(1)) {
|
||||||
|
if (Array.isArray(h) && h.length >= 4 && ringKm2(h) >= RING_MIN_KM2) {
|
||||||
|
const poly = polygon([h]);
|
||||||
|
holeRings.push({ bbox: bbox(poly), poly });
|
||||||
|
preHoleCentroids.push(centroid(poly).geometry.coordinates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every part already in the input file (string key) — used to skip phantom pieces that
|
||||||
|
// re-emiss an existing part byte-for-byte.
|
||||||
|
const existingPartSet = new Set();
|
||||||
|
for (const f of features) {
|
||||||
|
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
|
||||||
|
for (const p of parts) existingPartSet.add(JSON.stringify(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All regions with bboxes, for cross-border clipping.
|
||||||
|
const allRegions = features.map((f) => ({
|
||||||
|
feature: f,
|
||||||
|
parent: f.properties.parent,
|
||||||
|
bbox: bbox(feature(f.geometry)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`input: ${inFile}`);
|
||||||
|
console.log(`regions: ${features.length} countries(with regions): ${regionsByParent.size} region holes: ${holeRings.length}`);
|
||||||
|
|
||||||
|
// Per-region running totals for the report.
|
||||||
|
const added = new Map(); // feature -> { km2, parts }
|
||||||
|
let totalSliverKm2 = 0, totalWaterKm2 = 0, totalFilledKm2 = 0, totalDupN = 0, totalClippedKm2 = 0, totalClipFailN = 0;
|
||||||
|
|
||||||
|
// Pieces already appended this run (any country) — later pieces clip against these so
|
||||||
|
// appended pieces never overlap each other either.
|
||||||
|
const appendedSoFar = []; // { parent, bbox, part }
|
||||||
|
|
||||||
|
for (const [iso, countryRegions] of regionsByParent) {
|
||||||
|
const landmass = countries.features.find((c) => c.id === iso);
|
||||||
|
if (!landmass) continue; // no authoritative landmass → nothing to fill against
|
||||||
|
const feats = countryRegions.map((r) => feature(r.geometry));
|
||||||
|
|
||||||
|
let unionGeom, diffGeom;
|
||||||
|
try {
|
||||||
|
// A country with a single sub-region has no union to compute: it IS the union.
|
||||||
|
unionGeom = countryRegions.length === 1
|
||||||
|
? countryRegions[0].geometry
|
||||||
|
: union(featureCollection(feats)).geometry;
|
||||||
|
diffGeom = difference(featureCollection([landmass, { ...landmass, geometry: unionGeom }])).geometry;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${iso}\tSKIPPED (${unionGeom ? "difference" : "union"} failed: ${e.message})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!diffGeom) continue;
|
||||||
|
|
||||||
|
const rawPieces = partsOf(diffGeom, iso); // cleaned gap fragments inside the landmass
|
||||||
|
|
||||||
|
// Pass 1: filter (sliver / water / duplicate) and clip against foreign territory.
|
||||||
|
const fillable = [];
|
||||||
|
let sliverKm2 = 0, waterKm2 = 0, dupN = 0, clippedKm2 = 0, sliverN = 0, waterN = 0, clipFailN = 0;
|
||||||
|
for (const piece of rawPieces) {
|
||||||
|
const piecePoly = polygon(piece);
|
||||||
|
const aKm2 = area(piecePoly) * 1e-6;
|
||||||
|
if (aKm2 < GAP_MIN_KM2) { sliverKm2 += aKm2; sliverN++; continue; }
|
||||||
|
|
||||||
|
const pieceCm = centroid(piecePoly).geometry.coordinates;
|
||||||
|
if (overlapsAnyHole(piece, pieceCm)) { waterKm2 += aKm2; waterN++; continue; }
|
||||||
|
if (existingPartSet.has(JSON.stringify(piece))) { dupN++; continue; }
|
||||||
|
|
||||||
|
// Clip: remove any part of this piece that ANY existing region (own or foreign
|
||||||
|
// country) or an already-appended piece claims. Clipped *sequentially* against
|
||||||
|
// each touching target (more robust than one big union-difference): a target that
|
||||||
|
// fails to clip is skipped and counted, not allowed to abort the whole piece.
|
||||||
|
// Clipping against the country's OWN regions as well as foreign ones is what kills
|
||||||
|
// the same-country overlaps: the gap is `landmass − union(own regions)`, and
|
||||||
|
// polychip's difference can leave thin slivers of an own region inside that "gap";
|
||||||
|
// re-differencing each fragment against the individual regions (simpler operands
|
||||||
|
// than the big union) strips them out. Both operands are built as proper Features
|
||||||
|
// carrying a real geometry — never `feature()` on an already-Feature object (that
|
||||||
|
// double-wraps the geometry into the Feature itself and breaks polychip).
|
||||||
|
const pBBox = partBBox(piece);
|
||||||
|
const targets = [
|
||||||
|
...allRegions.filter((r) => bBoxesOverlap(r.bbox, pBBox)).map((r) => ({ feature: r.feature, bbox: r.bbox })),
|
||||||
|
...appendedSoFar.filter((a) => bBoxesOverlap(a.bbox, pBBox)).map((a) => ({ feature: polygon(a.part), bbox: a.bbox })),
|
||||||
|
];
|
||||||
|
if (targets.length === 0) {
|
||||||
|
fillable.push({ piece, aKm2, cm: pieceCm });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cur = { type: "Feature", properties: {}, geometry: piecePoly.geometry };
|
||||||
|
let curBBox = pBBox;
|
||||||
|
let covered = false;
|
||||||
|
for (const t of targets) {
|
||||||
|
if (!bBoxesOverlap(curBBox, t.bbox)) continue;
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = difference(featureCollection([cur, t.feature]));
|
||||||
|
} catch {
|
||||||
|
clipFailN++; // clip against this target failed: keep current (safe fallback)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!d || !d.geometry) { covered = true; break; } // piece fully covered by this target
|
||||||
|
cur = { type: "Feature", properties: {}, geometry: d.geometry };
|
||||||
|
curBBox = bbox(cur);
|
||||||
|
}
|
||||||
|
if (covered || !cur.geometry) { clippedKm2 += aKm2; continue; } // fully covered by foreign territory
|
||||||
|
const kept = [];
|
||||||
|
for (const cp of partsOf(cur.geometry, iso)) {
|
||||||
|
const cpArea = area(polygon(cp)) * 1e-6;
|
||||||
|
if (cpArea < GAP_MIN_KM2) { sliverKm2 += cpArea; sliverN++; continue; }
|
||||||
|
if (existingPartSet.has(JSON.stringify(cp))) { dupN++; continue; }
|
||||||
|
kept.push({ piece: cp, aKm2: cpArea, cm: centroid(polygon(cp)).geometry.coordinates });
|
||||||
|
}
|
||||||
|
clippedKm2 += Math.max(0, aKm2 - kept.reduce((s, k) => s + k.aKm2, 0));
|
||||||
|
fillable.push(...kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: nearest-region assignment and append.
|
||||||
|
const regionInfo = countryRegions.map((r) => {
|
||||||
|
const f = feature(r.geometry);
|
||||||
|
return { r, cm: centroid(f).geometry.coordinates, areaKm2: area(f) * 1e-6 };
|
||||||
|
});
|
||||||
|
let realKm2 = 0;
|
||||||
|
for (const { piece, aKm2, cm } of fillable) {
|
||||||
|
// Nearest region by centroid haversine distance; tie → larger area, then lexicographic id.
|
||||||
|
let best = null;
|
||||||
|
for (const info of regionInfo) {
|
||||||
|
const d = distance(cm, info.cm);
|
||||||
|
if (
|
||||||
|
best === null ||
|
||||||
|
d < best.d ||
|
||||||
|
(d === best.d && (info.areaKm2 > best.areaKm2 || (info.areaKm2 === best.areaKm2 && info.r.id < best.id)))
|
||||||
|
) best = { info, d };
|
||||||
|
}
|
||||||
|
if (!best) continue; // no regions at all (should not happen)
|
||||||
|
realKm2 += aKm2;
|
||||||
|
const target = best.info.r;
|
||||||
|
const parts = (target.geometry.type === "MultiPolygon" ? target.geometry.coordinates : [target.geometry.coordinates]).concat([piece]);
|
||||||
|
target.geometry = toGeometry(parts);
|
||||||
|
const a = added.get(target) ?? { km2: 0, parts: 0 };
|
||||||
|
a.km2 += aKm2; a.parts += 1;
|
||||||
|
added.set(target, a);
|
||||||
|
appendedSoFar.push({ parent: iso, bbox: partBBox(piece), part: piece });
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSliverKm2 += sliverKm2;
|
||||||
|
totalWaterKm2 += waterKm2;
|
||||||
|
totalFilledKm2 += realKm2;
|
||||||
|
totalDupN += dupN;
|
||||||
|
totalClippedKm2 += clippedKm2;
|
||||||
|
totalClipFailN += clipFailN;
|
||||||
|
|
||||||
|
// Verify the fill for countries we actually changed: recompute the residual gap.
|
||||||
|
let afterKm2 = null;
|
||||||
|
if (realKm2 > 0) {
|
||||||
|
try {
|
||||||
|
const u2 = countryRegions.length === 1
|
||||||
|
? countryRegions[0].geometry
|
||||||
|
: union(featureCollection(countryRegions.map((r) => feature(r.geometry)))).geometry;
|
||||||
|
afterKm2 = area(difference(featureCollection([landmass, { ...landmass, geometry: u2 }]))) * 1e-6;
|
||||||
|
} catch { afterKm2 = null; }
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`${iso}\tregions=${countryRegions.length}\tgap-before=${(area(diffGeom) * 1e-6).toFixed(0)}` +
|
||||||
|
(afterKm2 === null ? "" : `\tgap-after=${afterKm2.toFixed(0)}`) +
|
||||||
|
`\tfilled=${realKm2.toFixed(0)}\tslivers=${sliverN}(${sliverKm2.toFixed(0)})\twater-skipped=${waterN}(${waterKm2.toFixed(0)})` +
|
||||||
|
`\tdup-skipped=${dupN}${clippedKm2 > 0 ? `\tforeign-overlap-removed=${clippedKm2.toFixed(0)}` : ""}${clipFailN > 0 ? `\tclip-failed=${clipFailN}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAddedKm2 = [...added.values()].reduce((s, a) => s + a.km2, 0);
|
||||||
|
const totalAddedParts = [...added.values()].reduce((s, a) => s + a.parts, 0);
|
||||||
|
console.log(`\nfilled ${totalAddedKm2.toFixed(0)} km² into ${added.size} regions (+${totalAddedParts} parts)`);
|
||||||
|
console.log(
|
||||||
|
`totals: filled=${totalFilledKm2.toFixed(0)} slivers-ignored=${totalSliverKm2.toFixed(0)} water-kept-open=${totalWaterKm2.toFixed(0)}` +
|
||||||
|
` duplicate-phantoms-skipped=${totalDupN} foreign-overlap-removed=${totalClippedKm2.toFixed(0)} clip-failed=${totalClipFailN}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(`wrote ${outFile}`);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`.
|
||||||
|
*
|
||||||
|
* The manifest maps each sub-region's **stable feature id** (from
|
||||||
|
* `public/regions.geo.json`) to a deterministic, human-readable, globally
|
||||||
|
* unique region key of the form `<name-slug>-<4char>` (built by
|
||||||
|
* `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both
|
||||||
|
* `Nation.territory` and the `RegionMap` on these keys, so the manifest is a
|
||||||
|
* load-bearing data artifact — this test pins its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads both JSON files with plain
|
||||||
|
* `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one
|
||||||
|
* pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see
|
||||||
|
* the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-keys.json', () => {
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const geo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number }>;
|
||||||
|
};
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||||
|
|
||||||
|
it('has exactly 1,796 entries (one per region)', () => {
|
||||||
|
expect(Object.keys(manifest)).toHaveLength(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 1,796 unique keys (no collisions)', () => {
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
expect(new Set(keys).size).toBe(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
|
||||||
|
const ending = /-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending);
|
||||||
|
expect(k, `key has no hyphen: ${k}`).toContain('-');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
|
||||||
|
// The manifest is looked up by feature id, so its key set must line up
|
||||||
|
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||||
|
// rebuilding the keys).
|
||||||
|
expect(featureIds).toHaveLength(1796);
|
||||||
|
const manifestIds = Object.keys(manifest);
|
||||||
|
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
||||||
|
});
|
||||||
|
});
|
||||||
+216
-11
@@ -12,9 +12,16 @@
|
|||||||
* Tiny or uncovered countries collapse to a single whole-country region
|
* Tiny or uncovered countries collapse to a single whole-country region
|
||||||
* (`<ISO_A3>-01`).
|
* (`<ISO_A3>-01`).
|
||||||
*
|
*
|
||||||
* This test is self-contained (no import of the generator): it re-implements
|
* v0.17 (`scripts/fill-gaps.mjs`) additionally fills the no-one's-land left
|
||||||
* the same planar area model the generator/analysis use, so the area-based
|
* between merged sub-regions: per country, the uncovered slivers of the
|
||||||
* assertions measure the same quantity:
|
* country's landmass (≥ 25 km²) were added as extra parts of the nearest
|
||||||
|
* region, so the regions now cover the landmass almost completely (the
|
||||||
|
* sampling test below measures that per country).
|
||||||
|
*
|
||||||
|
* This test is self-contained (no import of the generator) and deliberately
|
||||||
|
* dependency-free — it re-implements the same planar area model the
|
||||||
|
* generator/analysis use, so the area-based assertions measure the same
|
||||||
|
* quantity:
|
||||||
*
|
*
|
||||||
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
|
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
|
||||||
*
|
*
|
||||||
@@ -22,6 +29,11 @@
|
|||||||
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
|
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
|
||||||
* by design, the same way the coverage band already absorbed them.
|
* by design, the same way the coverage band already absorbed them.
|
||||||
*
|
*
|
||||||
|
* No geojson/@turf types are imported: the project keeps `@types/geojson`
|
||||||
|
* OUT of the tsc program (see tsconfig `types`) so maplibre-gl's d.ts stays
|
||||||
|
* in its loose (skipLibCheck) mode; the one coverage test below uses an
|
||||||
|
* inline even-odd ray cast instead of a geometry library.
|
||||||
|
*
|
||||||
* It reads each file exactly once and runs in O(total vertices).
|
* It reads each file exactly once and runs in O(total vertices).
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -77,6 +89,50 @@ function median(nums: number[]): number {
|
|||||||
const m = Math.floor(s.length / 2);
|
const m = Math.floor(s.length / 2);
|
||||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw min/max bbox [w, s, e, n] of a geometry (all coordinates). */
|
||||||
|
function rawBBox(geom: Geometry): [number, number, number, number] {
|
||||||
|
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||||
|
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
|
||||||
|
for (const poly of polys)
|
||||||
|
for (const ring of poly)
|
||||||
|
for (const [lo, la] of ring) {
|
||||||
|
if (lo < w) w = lo;
|
||||||
|
if (lo > e) e = lo;
|
||||||
|
if (la < s) s = la;
|
||||||
|
if (la > n) n = la;
|
||||||
|
}
|
||||||
|
return [w, s, e, n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Even-odd ray cast: is `[lon, lat]` inside `ring`? Planar, in raw lon/lat
|
||||||
|
* space (no antimeridian handling — the polygons and the sample points live
|
||||||
|
* in the same unwrapped space, so containment is mathematically consistent
|
||||||
|
* even for countries that cross the date line). Used only as a fast,
|
||||||
|
* deterministic coverage proxy (see the sampling test), never for areas.
|
||||||
|
*/
|
||||||
|
function pointInRing(pt: [number, number], ring: Ring): boolean {
|
||||||
|
const [x, y] = pt;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
const [xi, yi] = ring[i];
|
||||||
|
const [xj, yj] = ring[j];
|
||||||
|
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is a point inside a (Multi)Polygon: inside the outer ring, outside all holes. */
|
||||||
|
function pointInGeom(pt: [number, number], geom: Geometry): boolean {
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
if (!pointInRing(pt, geom.coordinates[0])) return false;
|
||||||
|
return geom.coordinates.slice(1).every((h) => !pointInRing(pt, h));
|
||||||
|
}
|
||||||
|
if (geom.type === 'MultiPolygon')
|
||||||
|
return geom.coordinates.some((p) => pointInGeom(pt, { type: 'Polygon', coordinates: p }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// ------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
interface RegionFeature {
|
interface RegionFeature {
|
||||||
@@ -112,6 +168,9 @@ for (const f of regions.features)
|
|||||||
const countByParent = new Map<string, number>();
|
const countByParent = new Map<string, number>();
|
||||||
for (const f of regions.features)
|
for (const f of regions.features)
|
||||||
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
||||||
|
// Raw bboxes for the sampling test's cheap reject (O(total vertices)).
|
||||||
|
const regionBB = new Map<string, [number, number, number, number]>();
|
||||||
|
for (const f of regions.features) regionBB.set(f.id, rawBBox(f.geometry));
|
||||||
|
|
||||||
// --- island/close-neighbor helpers (mirror scripts/coarsen-regions.mjs) ------
|
// --- island/close-neighbor helpers (mirror scripts/coarsen-regions.mjs) ------
|
||||||
|
|
||||||
@@ -326,14 +385,54 @@ describe('regions.geo.json', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sums to within [40%, 175%] of each country area (median in [70%, 130%])', () => {
|
it('leaves exactly one enclosed hole: KZ-KZY (the real Aral Sea), still large', () => {
|
||||||
|
// v0.15 filled every small hole; v0.16 smoothing never touches holes; and
|
||||||
|
// v0.17's gap-filler explicitly keeps the Aral hole (its centroid matches a
|
||||||
|
// pre-existing hole and it lies inside another country's landmass) while
|
||||||
|
// dropping phantom holes. So the whole file must contain exactly one hole,
|
||||||
|
// on KZ-KZY, at a realistic size (~5,860 km2 planar; the real Aral has
|
||||||
|
// shrunk to roughly this by the 2020s) — no new holes of any size.
|
||||||
|
let holes = 0;
|
||||||
|
let maxHole = 0;
|
||||||
|
const holeIds: string[] = [];
|
||||||
|
let kzkzyHoleArea = 0;
|
||||||
|
for (const f of regions.features) {
|
||||||
|
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||||
|
for (const poly of polys) {
|
||||||
|
for (let i = 1; i < poly.length; i++) {
|
||||||
|
holes++;
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of poly[i]) lat += la;
|
||||||
|
const area = ringAreaKm2(poly[i], poly[i].length ? lat / poly[i].length : 0);
|
||||||
|
if (area > maxHole) maxHole = area;
|
||||||
|
holeIds.push(f.id);
|
||||||
|
if (f.id === 'KZ-KZY') kzkzyHoleArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(holes, `expected exactly 1 hole (KZ-KZY), found ${holes}: ${[...new Set(holeIds)].join(', ')}`).toBe(1);
|
||||||
|
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||||
|
expect(
|
||||||
|
kzkzyHoleArea >= 5_000 && kzkzyHoleArea <= 7_000,
|
||||||
|
`KZ-KZY hole is ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2 — outside the 5,000–7,000 km2 Aral band`,
|
||||||
|
).toBe(true);
|
||||||
|
console.log(
|
||||||
|
`\n holes: exactly 1 (KZ-KZY ≈ ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2) — the Aral hole survived v0.17's fill`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums to within [40%, 190%] of each country area (median in [70%, 130%])', () => {
|
||||||
// The band is a planar-approximation sanity check. It is wider than a naive
|
// The band is a planar-approximation sanity check. It is wider than a naive
|
||||||
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
|
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
|
||||||
// independently of the admin-0 country polygons (they are NOT clipped to
|
// independently of the admin-0 country polygons (they are NOT clipped to
|
||||||
// them), so for a handful of small/island nations the region union
|
// them). v0.17's gap fill removed the large UNDER-fills (the old minimum
|
||||||
// over/under-fills the country: measured range is ~[52%, 151%]
|
// was Brunei at ~52% — its regions now cover the landmass, ≈ 79%), but
|
||||||
// (Brunei under-fills, Vanuatu over-fills). The band brackets that with
|
// the OVER-fill tail is a pre-existing admin-1 artifact: a few tiny island
|
||||||
// margin while still catching a genuinely missing or doubled-up region.
|
// nations (Vanuatu 178.9%, Solomon Is. 133%, Bahamas 129%) have admin-1
|
||||||
|
// polygons that extend beyond their own country polygon, and the fill
|
||||||
|
// only added area *inside* the country, so it can't shrink that tail.
|
||||||
|
// The band brackets the measured [78.8%, 178.9%] with margin while still
|
||||||
|
// catching a genuinely missing or doubled-up region.
|
||||||
const ratios: number[] = [];
|
const ratios: number[] = [];
|
||||||
let minRatio = Infinity;
|
let minRatio = Infinity;
|
||||||
let maxRatio = -Infinity;
|
let maxRatio = -Infinity;
|
||||||
@@ -347,8 +446,8 @@ describe('regions.geo.json', () => {
|
|||||||
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
|
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
|
||||||
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
|
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
|
||||||
expect(
|
expect(
|
||||||
ratio >= 0.4 && ratio <= 1.75,
|
ratio >= 0.4 && ratio <= 1.9,
|
||||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`,
|
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
const medRatio = median(ratios);
|
const medRatio = median(ratios);
|
||||||
@@ -359,7 +458,113 @@ describe('regions.geo.json', () => {
|
|||||||
console.log(
|
console.log(
|
||||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
|
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
|
||||||
` / median ${(100 * medRatio).toFixed(1)}%` +
|
` / median ${(100 * medRatio).toFixed(1)}%` +
|
||||||
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–175%)`,
|
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–190%)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers each country\'s landmass with no significant no-one\'s-land (v0.17 gap fill, grid-sampled)', () => {
|
||||||
|
// The v0.17 fill (scripts/fill-gaps.mjs) assigned every uncovered sliver of
|
||||||
|
// each country's landmass (≥ 25 km²) to its nearest region, so the residual
|
||||||
|
// no-one's-land per country should be small everywhere. Measuring the
|
||||||
|
// exact union is too slow, so this samples a 25×25 grid over each
|
||||||
|
// country's raw bbox (skipping points outside the country polygon) and
|
||||||
|
// counts the fraction of in-land sample points that fall in NONE of the
|
||||||
|
// country's regions — a fast, deterministic proxy (same planar space,
|
||||||
|
// inline even-odd ray cast; antimeridian-safe because points and polygons
|
||||||
|
// share the raw coordinate space). Sampling over-counts coastline
|
||||||
|
// mismatch (admin-0 vs admin-1 edges), so the thresholds are generous:
|
||||||
|
// they catch a real regression (a whole sliver left unfilled) without
|
||||||
|
// being brittle to digitization fuzz.
|
||||||
|
const N = 25;
|
||||||
|
const results: { id: string; name: string; frac: number; land: number }[] = [];
|
||||||
|
for (const c of countries.features) {
|
||||||
|
const [w, s, e, n] = rawBBox(c.geometry);
|
||||||
|
if (e - w < 0.01 || n - s < 0.01) continue; // degenerate (point-like) country
|
||||||
|
const regs = regions.features.filter((f) => f.properties.parent === c.id);
|
||||||
|
let land = 0;
|
||||||
|
let uncov = 0;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const la = s + ((i + 0.5) / N) * (n - s);
|
||||||
|
for (let j = 0; j < N; j++) {
|
||||||
|
const lo = w + ((j + 0.5) / N) * (e - w);
|
||||||
|
const pt: [number, number] = [lo, la];
|
||||||
|
if (!pointInGeom(pt, c.geometry)) continue;
|
||||||
|
land++;
|
||||||
|
// cheap bbox reject first, then the full containment test
|
||||||
|
if (!regs.some((f) => {
|
||||||
|
const [rw, rs, re, rn] = regionBB.get(f.id)!;
|
||||||
|
if (lo < rw || lo > re || la < rs || la > rn) return false;
|
||||||
|
return pointInGeom(pt, f.geometry);
|
||||||
|
}))
|
||||||
|
uncov++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (land >= 4) results.push({ id: c.id, name: c.properties.name, frac: uncov / land, land });
|
||||||
|
}
|
||||||
|
expect(results.length, `only ${results.length} countries sampled (expected ~176)`).toBeGreaterThanOrEqual(150);
|
||||||
|
const fracs = results.map((r) => r.frac).sort((a, b) => a - b);
|
||||||
|
const med = median(fracs);
|
||||||
|
const p90 = fracs[Math.floor(fracs.length * 0.9)];
|
||||||
|
const worst = results.reduce((m, r) => (r.frac > m.frac ? r : m));
|
||||||
|
const over20 = results.filter((r) => r.frac > 0.2).sort((a, b) => b.frac - a.frac);
|
||||||
|
expect(
|
||||||
|
med <= 0.05,
|
||||||
|
`median country gap ${(100 * med).toFixed(1)}% > 5% — significant no-one's-land remains`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
p90 <= 0.15,
|
||||||
|
`p90 country gap ${(100 * p90).toFixed(1)}% > 15% — many countries have real gaps`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
worst.frac <= 0.4,
|
||||||
|
`worst country ${worst.id} (${worst.name}) gap ${(100 * worst.frac).toFixed(1)}% > 40%`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
over20.length <= 3,
|
||||||
|
`${over20.length} countries with > 20% gap: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}`,
|
||||||
|
).toBe(true);
|
||||||
|
console.log(
|
||||||
|
`\n no-one's-land (grid-sampled, N=${N}): median ${(100 * med).toFixed(1)}% / p90 ${(100 * p90).toFixed(1)}%` +
|
||||||
|
` / max ${worst.id} (${worst.name}) ${(100 * worst.frac).toFixed(1)}%` +
|
||||||
|
(over20.length ? ` — >20%: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}` : ' — no country > 20%'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
|
||||||
|
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
|
||||||
|
// fixed point: it never moves an existing border, merges a part, or
|
||||||
|
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
|
||||||
|
// keeps the single KZ-KZY hole. The region count (1,796), the
|
||||||
|
// per-feature ids/names/parents and their order are unchanged; only the
|
||||||
|
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
||||||
|
// the signature guards against a non-idempotent or destructive re-run.
|
||||||
|
// The fill is byte-deterministic per input and idempotent (a second run
|
||||||
|
// on the filled file changes nothing).
|
||||||
|
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||||
|
let verts = 0,
|
||||||
|
rings = 0,
|
||||||
|
parts = 0,
|
||||||
|
holes = 0;
|
||||||
|
const holeIds: string[] = [];
|
||||||
|
for (const f of regions.features) {
|
||||||
|
const polys = polysOf(f.geometry);
|
||||||
|
parts += polys.length;
|
||||||
|
for (const poly of polys) {
|
||||||
|
rings += poly.length;
|
||||||
|
holes += Math.max(0, poly.length - 1);
|
||||||
|
if (poly.length > 1) holeIds.push(f.id);
|
||||||
|
for (const ring of poly) verts += ring.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
||||||
|
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
||||||
|
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
|
||||||
|
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||||
|
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||||
|
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
|
||||||
|
console.log(
|
||||||
|
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||||||
|
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/smooth-regions.mjs — smooths jagged sub-national region boundaries.
|
||||||
|
*
|
||||||
|
* Run: npm run smooth:regions (in place on public/regions.geo.json)
|
||||||
|
* node scripts/smooth-regions.mjs [out] [src]
|
||||||
|
*
|
||||||
|
* The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization
|
||||||
|
* left some sub-national boundaries overly jagged — the worst being coastal /
|
||||||
|
* borderland regions that the dissolve split into many small, zig-zag fragments
|
||||||
|
* (e.g. SI-048 Kocevje, Slovenia, 57 parts; MK-36 Kavadartsi, N. Macedonia, 8
|
||||||
|
* parts). This cleans the jaggedness in place with **Douglas–Peucker
|
||||||
|
* vertex-thinning** (`@turf/simplify`), applied to every ring of every polygon.
|
||||||
|
*
|
||||||
|
* DOUGLAS–PEUCKER — `@turf/simplify` keeps a vertex only when it deviates from
|
||||||
|
* the chord joining its kept neighbours by more than the tolerance, removing
|
||||||
|
* the redundant "in-between" points of a shallow zig-zag. The tolerance is
|
||||||
|
* expressed in the GeoJSON degree space (its distance metric treats 1° lon
|
||||||
|
* == 1° lat); TOLERANCE_M (metres) is converted with the same ~111 km/° the
|
||||||
|
* area model uses, so TOLERANCE_M = 50 is a gentle ~50 m pass. Bump it to
|
||||||
|
* 100 / 200 for a stronger pass and re-run — the pass is idempotent either way.
|
||||||
|
*
|
||||||
|
* VERTEX-THINNING ONLY — DP only removes points. It never moves a border,
|
||||||
|
* never merges/dissolves regions or parts, and never creates or removes a
|
||||||
|
* ring, so topology is preserved exactly: the region count, every `id`,
|
||||||
|
* `properties.name`/`properties.parent`, the part count of every feature, the
|
||||||
|
* single real hole (KZ-KZY), and all 83 islands are all unchanged. Only the
|
||||||
|
* redundant vertices along each ring are dropped.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: one @turf/simplify pass at a fixed tolerance, then
|
||||||
|
* 4-dp rounding (the same convention as the other scripts). Because the output
|
||||||
|
* is already at the tolerance's fixed point, re-running on the smoothed file
|
||||||
|
* yields a byte-identical file (sha256-verified). The 18 degenerate 3-point
|
||||||
|
* micro-rings (the ~0-area "dots" like Ragged Island / the Paracels) are kept
|
||||||
|
* verbatim — @turf/simplify requires ≥4 points and would otherwise throw — so
|
||||||
|
* no ring is ever dropped and every feature keeps its exact part/ring count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { simplify } from '@turf/simplify';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const DEFAULT_FILE = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const args = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
||||||
|
const OUT = args[0] ? path.resolve(args[0]) : DEFAULT_FILE;
|
||||||
|
const SRC = args[1] ? path.resolve(args[1]) : DEFAULT_FILE;
|
||||||
|
|
||||||
|
// --- parameters ---------------------------------------------------------------
|
||||||
|
const TOLERANCE_M = 50; // DP tolerance in metres — ~50 m is gentle (bump to 100/200 for more)
|
||||||
|
const M_PER_DEG = 111_000; // ~111 km per degree (same scale as the area model's 111.32/110.574)
|
||||||
|
const TOL_DEG = TOLERANCE_M / M_PER_DEG; // @turf/simplify works in degree space → ≈ 0.00045°
|
||||||
|
const R4 = 4; // coordinate precision (decimal places) — same as the other scripts
|
||||||
|
|
||||||
|
// --- rounding / normalization (same conventions as the other scripts) ----------
|
||||||
|
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
|
||||||
|
function roundRing(ring, N = R4) {
|
||||||
|
const out = [];
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
const p = [rN(x, N), rN(y, N)];
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
|
||||||
|
}
|
||||||
|
if (out.length > 1 && out[0][0] === out[out.length - 1][0] && out[0][1] === out[out.length - 1][1]) out.pop();
|
||||||
|
if (out.length < 3) return ring; // degenerate — keep verbatim, never corrupt a ring
|
||||||
|
return [...out, [...out[0]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tiny geometry helpers -----------------------------------------------------
|
||||||
|
const polysOf = (g) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||||
|
const ringsOf = (g) => polysOf(g).flatMap((p) => p);
|
||||||
|
const countVerts = (g) => ringsOf(g).reduce((s, r) => s + r.length, 0);
|
||||||
|
const countParts = (g) => (g.type === 'Polygon' ? 1 : g.coordinates.length);
|
||||||
|
const countRings = (g) => ringsOf(g).length;
|
||||||
|
const countHoles = (g) => polysOf(g).reduce((s, p) => s + Math.max(0, p.length - 1), 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth one closed ring with a single @turf/simplify (Douglas–Peucker) pass.
|
||||||
|
* Only removes vertices — never adds, never drops the ring. Degenerate rings
|
||||||
|
* (<4 pts) are returned verbatim (simplify would throw); a ring DP would
|
||||||
|
* over-collapse below a valid 4-pt ring is kept as-is; any error keeps the
|
||||||
|
* original. Never throws.
|
||||||
|
*/
|
||||||
|
function smoothRing(ring) {
|
||||||
|
if (!Array.isArray(ring) || ring.length < 4) return ring; // keep degenerate verbatim
|
||||||
|
try {
|
||||||
|
const feat = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
|
||||||
|
const next = simplify(feat, { tolerance: TOL_DEG }).geometry.coordinates[0];
|
||||||
|
if (!Array.isArray(next) || next.length < 4) return ring; // over-collapsed — keep original
|
||||||
|
const closed =
|
||||||
|
next[next.length - 1][0] === next[0][0] && next[next.length - 1][1] === next[0][1]
|
||||||
|
? next
|
||||||
|
: [...next, [next[0][0], next[0][1]]];
|
||||||
|
const rounded = roundRing(closed);
|
||||||
|
return rounded.length >= 4 ? rounded : ring;
|
||||||
|
} catch {
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smooth every ring of a geometry. Never drops a ring or a part. */
|
||||||
|
function smoothGeom(geom) {
|
||||||
|
const newPolys = polysOf(geom).map((poly) => poly.map(smoothRing));
|
||||||
|
if (geom.type === 'Polygon' && newPolys.length === 1) return { type: 'Polygon', coordinates: newPolys[0] };
|
||||||
|
return { type: 'MultiPolygon', coordinates: newPolys };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main ----------------------------------------------------------------------
|
||||||
|
function main() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const rawIn = readFileSync(SRC);
|
||||||
|
const fc = JSON.parse(rawIn);
|
||||||
|
const inBytes = Buffer.byteLength(rawIn);
|
||||||
|
console.log(`input: ${fc.features.length} regions (${(inBytes / 1024).toFixed(0)} KB) from ${SRC}`);
|
||||||
|
console.log(`mode: Douglas–Peucker via @turf/simplify, tolerance ${TOLERANCE_M} m (≈ ${TOL_DEG}°)`);
|
||||||
|
|
||||||
|
// before stats
|
||||||
|
let vb = 0, hb = 0, pb = 0, rb = 0, degenerate = 0;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
vb += countVerts(f.geometry);
|
||||||
|
hb += countHoles(f.geometry);
|
||||||
|
pb += countParts(f.geometry);
|
||||||
|
rb += countRings(f.geometry);
|
||||||
|
for (const r of ringsOf(f.geometry)) if (r.length < 4) degenerate++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFeatures = fc.features.map((f) => {
|
||||||
|
const g = smoothGeom(f.geometry);
|
||||||
|
return { type: 'Feature', id: f.id, properties: { name: f.properties.name, parent: f.properties.parent }, geometry: g };
|
||||||
|
});
|
||||||
|
const out = { type: 'FeatureCollection', features: outFeatures };
|
||||||
|
|
||||||
|
// after stats + change count (compare against the original, in order)
|
||||||
|
let va = 0, ha = 0, pa = 0, ra = 0, changed = 0;
|
||||||
|
for (let i = 0; i < out.features.length; i++) {
|
||||||
|
const f = out.features[i];
|
||||||
|
va += countVerts(f.geometry);
|
||||||
|
ha += countHoles(f.geometry);
|
||||||
|
pa += countParts(f.geometry);
|
||||||
|
ra += countRings(f.geometry);
|
||||||
|
if (JSON.stringify(f.geometry) !== JSON.stringify(fc.features[i].geometry)) changed++;
|
||||||
|
}
|
||||||
|
const afterVerts = new Map(out.features.map((f) => [f.id, countVerts(f.geometry)]));
|
||||||
|
|
||||||
|
const json = JSON.stringify(out);
|
||||||
|
writeFileSync(OUT, json + '\n');
|
||||||
|
const outBytes = Buffer.byteLength(json) + 1;
|
||||||
|
|
||||||
|
console.log(`\n--- RESULTS ---`);
|
||||||
|
console.log(`regions: ${fc.features.length} → ${out.features.length} (unchanged)`);
|
||||||
|
console.log(`vertices (incl. ring closure): ${vb} → ${va} removed ${vb - va} (${(100 * (vb - va) / vb).toFixed(2)}%)`);
|
||||||
|
console.log(`rings: ${rb} → ${ra} parts: ${pb} → ${pa} holes: ${hb} → ${ha} (all unchanged)`);
|
||||||
|
console.log(`regions whose geometry changed: ${changed} / ${fc.features.length}`);
|
||||||
|
console.log(`degenerate rings (<4 pts, kept verbatim): ${degenerate}`);
|
||||||
|
console.log(`file: ${(inBytes / 1024).toFixed(0)} KB → ${(outBytes / 1024).toFixed(0)} KB (${(outBytes / 1024 / 1024).toFixed(2)} MB)`);
|
||||||
|
|
||||||
|
// Diagnose the "messy" regions: the real problem is fragmentation — the
|
||||||
|
// dissolve left coherent regions as many disconnected parts. Rank by part
|
||||||
|
// count (then total vertices) so these surface; Kocelje/Kavadartsi are here.
|
||||||
|
const messy = fc.features
|
||||||
|
.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.properties.name,
|
||||||
|
parent: f.properties.parent,
|
||||||
|
parts: countParts(f.geometry),
|
||||||
|
vb: countVerts(f.geometry),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.parts - a.parts || b.vb - a.vb)
|
||||||
|
.slice(0, 15);
|
||||||
|
console.log(`\nMOST-MESSY (top ${messy.length} by fragmentation = parts, then vertices):`);
|
||||||
|
console.log(` id name country parts verts verts(after)`);
|
||||||
|
for (const m of messy)
|
||||||
|
console.log(` ${m.id.padEnd(10)} ${m.name.slice(0, 37).padEnd(38)} ${m.parent.padEnd(7)} ${String(m.parts).padStart(4)} ${String(m.vb).padStart(5)} ${String(afterVerts.get(m.id)).padStart(5)}`);
|
||||||
|
|
||||||
|
console.log(`\nwrote ${OUT}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -318,6 +318,17 @@ describe('buildUserPrompt (batched round)', () => {
|
|||||||
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
||||||
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes a regions reference table only when one is provided', () => {
|
||||||
|
// with a reference table: the payload carries it (region key -> "name (parent)")
|
||||||
|
const withRef = JSON.parse(
|
||||||
|
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
|
||||||
|
) as { regions?: Record<string, string> };
|
||||||
|
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
|
||||||
|
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
|
||||||
|
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
|
||||||
|
expect(withoutRef.regions).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queueRoundItem', () => {
|
describe('queueRoundItem', () => {
|
||||||
@@ -461,6 +472,41 @@ describe('applyTurnResult', () => {
|
|||||||
expect(state.pending).toHaveLength(1); // original untouched
|
expect(state.pending).toHaveLength(1); // original untouched
|
||||||
expect(state.version).toBe(1);
|
expect(state.version).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
|
||||||
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
|
// the valid key was gained (stolen from its owner); the bogus one was ignored
|
||||||
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
|
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||||
|
// the bogus key never entered any nation's territory
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
expect(n.territory).not.toContain('not-a-region-xyz');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
|
expect(next.nations.de.territory).toContain('POL');
|
||||||
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
|
|||||||
+26
-13
@@ -53,11 +53,12 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
' ],',
|
' ],',
|
||||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
'- Use only nation ids and region keys that exist in the provided state.',
|
||||||
|
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -75,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
* model to simulate the combined consequences of the whole batch (or to let the
|
* model to simulate the combined consequences of the whole batch (or to let the
|
||||||
* world advance on its own when the batch is empty) and return one TurnResult.
|
* world advance on its own when the batch is empty) and return one TurnResult.
|
||||||
*/
|
*/
|
||||||
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
export function buildUserPrompt(
|
||||||
|
state: GameState,
|
||||||
|
items: RoundItem[],
|
||||||
|
regionReference?: Record<string, string>,
|
||||||
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
const playerActions = items.map((i) =>
|
const playerActions = items.map((i) =>
|
||||||
@@ -88,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|||||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||||
|
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
state: {
|
state: {
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
date: state.date,
|
date: state.date,
|
||||||
@@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|||||||
pending: items,
|
pending: items,
|
||||||
roundInstruction,
|
roundInstruction,
|
||||||
};
|
};
|
||||||
|
if (regionReference) payload.regions = regionReference;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +289,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||||
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
||||||
*/
|
*/
|
||||||
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
|
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
@@ -309,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
|
|||||||
for (const t of result.territoryChanges) {
|
for (const t of result.territoryChanges) {
|
||||||
const n = next.nations[t.nation];
|
const n = next.nations[t.nation];
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
for (const code of t.gain) {
|
const valid = (k: string) => !validKeys || validKeys.has(k);
|
||||||
|
for (const key of t.gain) {
|
||||||
|
if (!valid(key)) continue; // ignore a key that is not a real region
|
||||||
for (const other of Object.values(next.nations)) {
|
for (const other of Object.values(next.nations)) {
|
||||||
const i = other.territory.indexOf(code);
|
const i = other.territory.indexOf(key);
|
||||||
if (i !== -1) other.territory.splice(i, 1);
|
if (i !== -1) other.territory.splice(i, 1);
|
||||||
}
|
}
|
||||||
if (!n.territory.includes(code)) n.territory.push(code);
|
if (!n.territory.includes(key)) n.territory.push(key);
|
||||||
}
|
}
|
||||||
for (const code of t.lose) {
|
for (const key of t.lose) {
|
||||||
const i = n.territory.indexOf(code);
|
if (!valid(key)) continue;
|
||||||
|
const i = n.territory.indexOf(key);
|
||||||
if (i !== -1) n.territory.splice(i, 1);
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,10 +376,14 @@ export async function runTurn(opts: {
|
|||||||
preset: Preset;
|
preset: Preset;
|
||||||
state: GameState;
|
state: GameState;
|
||||||
items: RoundItem[];
|
items: RoundItem[];
|
||||||
|
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
|
||||||
|
regionReference?: Record<string, string>;
|
||||||
|
/** Set of valid region keys; territory changes with other keys are ignored. */
|
||||||
|
validRegionKeys?: ReadonlySet<string>;
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys } = opts;
|
||||||
const system = buildSystemPrompt(preset);
|
const system = buildSystemPrompt(preset);
|
||||||
const user = buildUserPrompt(state, items);
|
const user = buildUserPrompt(state, items, regionReference);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|
||||||
@@ -398,7 +411,7 @@ export async function runTurn(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: applyTurnResult(state, result), result };
|
return { state: applyTurnResult(state, result, validRegionKeys), result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { createLlmClient } from './llm';
|
|||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
|
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
@@ -49,6 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
|||||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
|
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||||
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
|
loadRegions(root);
|
||||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
@@ -121,6 +125,10 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1, // the first saved revision; every later mutation bumps it
|
version: 1, // the first saved revision; every later mutation bumps it
|
||||||
};
|
};
|
||||||
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
|
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||||
db.save(state);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -227,6 +235,8 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
preset,
|
preset,
|
||||||
state: game,
|
state: game,
|
||||||
items: game.pending,
|
items: game.pending,
|
||||||
|
regionReference: referenceForKeys(allTerritoryKeys(game)),
|
||||||
|
validRegionKeys: validKeys(),
|
||||||
});
|
});
|
||||||
db.save(state);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
+40
-35
@@ -3,6 +3,10 @@ import { buildRegionMap } from './map';
|
|||||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
|
||||||
|
// by region key (one per owned sub-region), not by a parent-country ISO_A3. The
|
||||||
|
// keys below are deliberately fake (obviously not real manifest entries) so this
|
||||||
|
// test stays self-contained and format-agnostic.
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
id: 'g',
|
id: 'g',
|
||||||
presetId: 'p',
|
presetId: 'p',
|
||||||
@@ -16,7 +20,7 @@ const state: GameState = {
|
|||||||
name: 'UK',
|
name: 'UK',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
territory: ['GBR', 'IRL'],
|
territory: ['albania-aaaa', 'wales-bbbb'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -24,7 +28,7 @@ const state: GameState = {
|
|||||||
name: 'DE',
|
name: 'DE',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||||
territory: ['DEU'],
|
territory: ['brandenburg-cccc'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -33,28 +37,28 @@ const state: GameState = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
const regions: RegionMap = buildRegionMap(state);
|
const regions: RegionMap = buildRegionMap(state);
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
it('maps each owned region key to its owner + that nation\'s stable color', () => {
|
||||||
const m = buildRegionMap(state);
|
const m = buildRegionMap(state);
|
||||||
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||||
// a region nobody owns is simply absent
|
// a region nobody owns is simply absent
|
||||||
expect(m.FRA).toBeUndefined();
|
expect(m['normandie-dddd']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('regionOwner', () => {
|
describe('regionOwner', () => {
|
||||||
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
it('resolves an owned region key to its owner record', () => {
|
||||||
expect(regionOwner(regions, 'GBR')).toEqual({
|
expect(regionOwner(regions, 'albania-aaaa')).toEqual({
|
||||||
color: nationColor('uk'),
|
color: nationColor('uk'),
|
||||||
owner: 'UK',
|
owner: 'UK',
|
||||||
ownerId: 'uk',
|
ownerId: 'uk',
|
||||||
});
|
});
|
||||||
expect(regionOwner(regions, 'DEU')).toEqual({
|
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
ownerId: 'de',
|
ownerId: 'de',
|
||||||
@@ -62,11 +66,11 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||||
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
|
||||||
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an empty or missing code', () => {
|
it('returns undefined for an empty or missing key', () => {
|
||||||
expect(regionOwner(regions, '')).toBeUndefined();
|
expect(regionOwner(regions, '')).toBeUndefined();
|
||||||
expect(regionOwner(regions, null)).toBeUndefined();
|
expect(regionOwner(regions, null)).toBeUndefined();
|
||||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||||
@@ -74,28 +78,29 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #6 regression guard (sub-national map). The click/hover handlers resolve the
|
* #6 regression guard (sub-national map, region-keyed). The click/hover handlers
|
||||||
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
|
* resolve the owning nation from a rendered map feature. MapLibre repacks a
|
||||||
* source's top-level `id` into a *numeric index* when it stores the source, so a
|
* GeoJSON source's top-level `id` into a *numeric index* when it stores the
|
||||||
* feature the map hands back at interaction time has a number for `id`. The
|
* source, so a feature the map hands back at interaction time has a number for
|
||||||
* choropleth is now sub-national: a feature's id is a sub-region code
|
* `id`. The choropleth is sub-national: a feature's own sub-region id is a code
|
||||||
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
|
* (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`),
|
||||||
* client stamps into `properties.parent` (the RegionMap is keyed by that
|
* which the client stamps into `properties.regionKey` from `region-keys.json`
|
||||||
* country code, never the sub-region id). This pins the exact data shape and
|
* (the RegionMap is keyed by that key, never the sub-region id nor the repacked
|
||||||
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
|
* id). This pins the exact data shape and proves the resolution:
|
||||||
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
|
* `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths
|
||||||
* `regionId`, which is not a RegionMap key) do not.
|
* (the repacked numeric `feature.id`, and the sub-region `regionId`, which is
|
||||||
|
* not a RegionMap key) do not.
|
||||||
*/
|
*/
|
||||||
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
|
describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => {
|
||||||
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
||||||
// is a number (repacked), and the stable identifiers live only in properties —
|
// is a number (repacked), and the stable identifiers live only in properties —
|
||||||
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
|
// `regionId` (the sub-region code) and `regionKey` (the region key).
|
||||||
const repackedFeature = {
|
const repackedFeature = {
|
||||||
id: 0,
|
id: 0,
|
||||||
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
|
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('resolves the owner from properties.parent (the fix)', () => {
|
it('resolves the owner from properties.regionKey (the fix)', () => {
|
||||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
@@ -104,7 +109,7 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
||||||
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
|
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
|
||||||
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,17 +121,17 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mapFeatureOwner', () => {
|
describe('mapFeatureOwner', () => {
|
||||||
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
|
it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => {
|
||||||
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a parent country nobody owns', () => {
|
it('returns undefined for a region key nobody owns', () => {
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -1,6 +1,8 @@
|
|||||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
// Build the choropleth payload from a GameState: every region key (see
|
||||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
// public/region-keys.json) maps to its owning nation plus that nation's stable
|
||||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
// color (see shared.nationColor). The client merges this onto the sub-national
|
||||||
|
// regions.geo.json (resolving each feature by its own region key) and calls
|
||||||
|
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
|
||||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
@@ -8,8 +10,8 @@ export function buildRegionMap(state: GameState): RegionMap {
|
|||||||
const map: RegionMap = {};
|
const map: RegionMap = {};
|
||||||
for (const [id, nation] of Object.entries(state.nations)) {
|
for (const [id, nation] of Object.entries(state.nations)) {
|
||||||
const color = nationColor(id);
|
const color = nationColor(id);
|
||||||
for (const code of nation.territory) {
|
for (const key of nation.territory) {
|
||||||
map[code] = { color, owner: nation.name, ownerId: id };
|
map[key] = { color, owner: nation.name, ownerId: id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
allTerritoryKeys,
|
||||||
|
expandCountriesToKeys,
|
||||||
|
hasRegionData,
|
||||||
|
loadRegions,
|
||||||
|
referenceForKeys,
|
||||||
|
regionName,
|
||||||
|
resetRegions,
|
||||||
|
validKeys,
|
||||||
|
} from './regions';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
// The repo root: this file lives in server/, so '..' is the directory holding
|
||||||
|
// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors
|
||||||
|
// how server/index.ts locates its root via import.meta.url).
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(loadRegions(root)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the full manifest: 1,796 unique valid region keys', () => {
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
||||||
|
const fra = expandCountriesToKeys(['FRA']);
|
||||||
|
// FRA has 42 sub-regions in the committed dataset.
|
||||||
|
expect(fra).toHaveLength(42);
|
||||||
|
// every expanded key is a real region key
|
||||||
|
for (const k of fra) expect(validKeys().has(k)).toBe(true);
|
||||||
|
// and the reference table labels each one with its parent country
|
||||||
|
const ref = referenceForKeys(fra);
|
||||||
|
expect(Object.keys(ref)).toHaveLength(42);
|
||||||
|
for (const [k, label] of Object.entries(ref)) {
|
||||||
|
expect(label.endsWith('(FRA)')).toBe(true);
|
||||||
|
expect(regionName(k)).toBeTruthy();
|
||||||
|
}
|
||||||
|
// sample known FRA keys, and their exact reference strings
|
||||||
|
expect(fra).toContain('ain-lqtt');
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
||||||
|
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['not-a-real-key-xyz']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => {
|
||||||
|
const state: GameState = {
|
||||||
|
id: 'g',
|
||||||
|
presetId: 'p',
|
||||||
|
presetTitle: 'P',
|
||||||
|
playerNationId: 'uk',
|
||||||
|
turn: 1,
|
||||||
|
date: '1939-09-01',
|
||||||
|
nations: {
|
||||||
|
uk: {
|
||||||
|
id: 'uk',
|
||||||
|
name: 'UK',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
territory: expandCountriesToKeys(['GBR']),
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
id: 'fr',
|
||||||
|
name: 'FR',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
territory: expandCountriesToKeys(['FRA']),
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('graceful degradation with no region data', () => {
|
||||||
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
|
it('expands to the raw input, omits references, and reports no data', () => {
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
// no manifest -> the country list passes through unchanged (old model)
|
||||||
|
expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']);
|
||||||
|
expect(referenceForKeys(['FRA'])).toEqual({});
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
expect(regionName('FRA')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => resetRegions());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
|
||||||
|
const realRoot = root;
|
||||||
|
// A root that mimics the prod Docker image: no public/, the region files live
|
||||||
|
// in dist/ (where Vite copies public/* at build). This is the exact layout that
|
||||||
|
// broke v0.19.0 in prod (the loader only looked in public/).
|
||||||
|
const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
|
||||||
|
const prodRoot = path.join(base, 'prod');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-keys.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-keys.json'),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'regions.geo.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'regions.geo.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => resetRegions());
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
|
expect(loadRegions(realRoot)).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
|
const emptyRoot = path.join(base, 'empty');
|
||||||
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
expect(loadRegions(emptyRoot)).toBe(false);
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||||
|
// (region-keys.json: stable feature id -> region key) and the sub-national geo
|
||||||
|
// (regions.geo.json: feature id -> { name, parent }) — from public/ in dev (Vite
|
||||||
|
// serves it) or dist/ in prod (Vite copies public/* -> dist/ at build, and the
|
||||||
|
// Docker image keeps only dist/) — and joins them into the lookups the
|
||||||
|
// region-level ownership model (v0.19, #16 P1) needs:
|
||||||
|
//
|
||||||
|
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions
|
||||||
|
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||||
|
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||||||
|
//
|
||||||
|
// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and
|
||||||
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into the
|
||||||
|
// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once
|
||||||
|
// at boot; the accessors below are pure data reads thereafter.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||||
|
interface RegionInfo {
|
||||||
|
name: string;
|
||||||
|
parent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionData {
|
||||||
|
/** stable feature id -> region key (from public/region-keys.json). */
|
||||||
|
idToKey: Record<string, string>;
|
||||||
|
/** region key -> { name, parent }. */
|
||||||
|
keyToInfo: Record<string, RegionInfo>;
|
||||||
|
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
|
||||||
|
keysByCountry: Record<string, string[]>;
|
||||||
|
/** the set of all region keys. */
|
||||||
|
validKeys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: RegionData | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load (or reload) the region-key manifest + geo. Idempotent. The two files are
|
||||||
|
* served to the client from `public/` in dev (Vite serves it directly) and from
|
||||||
|
* `dist/` in prod (Vite copies `public/*` → `dist/` at build and the Docker image
|
||||||
|
* keeps only `dist/`), so probe `public/` then `dist/` (plus the root as a last
|
||||||
|
* resort) and use the first directory that holds both files. Returns true when
|
||||||
|
* both loaded cleanly; a missing/broken pair clears the data and returns false
|
||||||
|
* so the server still boots and degrades gracefully (expansion becomes a no-op,
|
||||||
|
* ownership falls back to the raw input keys).
|
||||||
|
*/
|
||||||
|
export function loadRegions(root: string): boolean {
|
||||||
|
const keyFile = 'region-keys.json';
|
||||||
|
const geoFile = 'regions.geo.json';
|
||||||
|
const base = [
|
||||||
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
|
root, // last resort
|
||||||
|
].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
|
||||||
|
if (!base) {
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keyPath = path.join(base, keyFile);
|
||||||
|
const geoPath = path.join(base, geoFile);
|
||||||
|
try {
|
||||||
|
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||||
|
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
|
};
|
||||||
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
|
const validKeys = new Set<string>();
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||||
|
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||||
|
keyToInfo[key] = { name, parent };
|
||||||
|
validKeys.add(key);
|
||||||
|
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||||
|
}
|
||||||
|
data = { idToKey, keyToInfo, keysByCountry, validKeys };
|
||||||
|
return validKeys.size > 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region data', err);
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
|
||||||
|
export function resetRegions(): void {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the manifest + geo loaded cleanly. */
|
||||||
|
export function hasRegionData(): boolean {
|
||||||
|
return data !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
|
||||||
|
* (file order, deduped). This is what turns a preset's country-level territory
|
||||||
|
* into region-level ownership at game creation. Returns the input unchanged when
|
||||||
|
* no region data is loaded (graceful degradation — behaves like the old model).
|
||||||
|
*/
|
||||||
|
export function expandCountriesToKeys(countries: string[]): string[] {
|
||||||
|
if (!data) return countries;
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of countries) {
|
||||||
|
for (const key of data.keysByCountry[c] ?? []) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
|
||||||
|
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
|
||||||
|
* passes the game's owned-region universe) so the prompt stays bounded.
|
||||||
|
*/
|
||||||
|
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!data) return out;
|
||||||
|
for (const key of keys) {
|
||||||
|
const info = data.keyToInfo[key];
|
||||||
|
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The region key's human name (for the dossier), or undefined if unknown. */
|
||||||
|
export function regionName(key: string): string | undefined {
|
||||||
|
return data?.keyToInfo[key]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The union of every nation's territory region keys (the game's region universe). */
|
||||||
|
export function allTerritoryKeys(state: GameState): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const key of n.territory) out.add(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
|
||||||
|
export function validKeys(): ReadonlySet<string> {
|
||||||
|
return data ? data.validKeys : new Set<string>();
|
||||||
|
}
|
||||||
+27
-20
@@ -23,7 +23,7 @@ export interface RelationChange {
|
|||||||
set: RelationStatus;
|
set: RelationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */
|
||||||
export interface TerritoryChange {
|
export interface TerritoryChange {
|
||||||
nation: string;
|
nation: string;
|
||||||
gain: string[];
|
gain: string[];
|
||||||
@@ -48,26 +48,32 @@ export interface TurnResult {
|
|||||||
territoryChanges: TerritoryChange[];
|
territoryChanges: TerritoryChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
|
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||||
export interface RegionOwner {
|
export interface RegionOwner {
|
||||||
color: string;
|
color: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
|
||||||
|
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
|
||||||
|
* #16 P1); a key absent from this map is unowned (rendered grey).
|
||||||
|
*/
|
||||||
export type RegionMap = Record<string, RegionOwner>;
|
export type RegionMap = Record<string, RegionOwner>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
||||||
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
|
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
|
||||||
* the LLM stay at country granularity — see #16), so `key` is a country code such
|
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
|
||||||
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
|
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
|
||||||
* empty/missing key or an unowned country.
|
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
|
||||||
|
* unowned region.
|
||||||
*
|
*
|
||||||
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||||||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
||||||
* which reads the feature's `properties.parent` — never the repacked top-level
|
* which reads the feature's stamped `properties.regionKey` — never the repacked
|
||||||
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
|
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
|
||||||
* the sub-region id.
|
* and never the sub-region id.
|
||||||
*/
|
*/
|
||||||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||||
if (!key) return undefined;
|
if (!key) return undefined;
|
||||||
@@ -76,22 +82,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the owning-nation record for a **rendered map feature** from the
|
* Resolve the owning-nation record for a **rendered map feature** from the
|
||||||
* authoritative RegionMap. The choropleth renders sub-national regions, but the
|
* authoritative RegionMap. The choropleth renders sub-national regions, and the
|
||||||
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
|
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
|
||||||
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
|
* so a feature's owner is found via its `properties.regionKey` — the region key
|
||||||
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
|
* the client stamps from `public/region-keys.json` by the feature's stable id
|
||||||
* `regionOwner`. This is the single shared source of truth for that resolution,
|
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
|
||||||
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule
|
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
|
||||||
* lives in one tested place rather than in the React component.
|
* / repacked `feature.id`" rule lives in one tested place rather than in the
|
||||||
|
* React component.
|
||||||
*
|
*
|
||||||
* Returns `undefined` for a missing feature/parent or an unowned country.
|
* Returns `undefined` for a missing feature/regionKey or an unowned region.
|
||||||
*/
|
*/
|
||||||
export function mapFeatureOwner(
|
export function mapFeatureOwner(
|
||||||
regions: RegionMap,
|
regions: RegionMap,
|
||||||
feature: { properties?: { parent?: unknown } } | null | undefined,
|
feature: { properties?: { regionKey?: unknown } } | null | undefined,
|
||||||
): RegionOwner | undefined {
|
): RegionOwner | undefined {
|
||||||
const parent = feature?.properties?.parent;
|
const regionKey = feature?.properties?.regionKey;
|
||||||
return regionOwner(regions, typeof parent === 'string' ? parent : '');
|
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||||
|
|||||||
+4
-2
@@ -26,8 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
* A single nation in the world. `territory` holds **region keys** (see
|
||||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
|
||||||
|
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
|
||||||
|
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
|
||||||
*/
|
*/
|
||||||
export interface Nation {
|
export interface Nation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+6
-6
@@ -590,11 +590,12 @@ function GameScreen({
|
|||||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||||
// When the dossier target was set by a map click, remember which sub-region
|
// When the dossier target was set by a map click, remember which sub-region
|
||||||
// (its own name + parent country) was clicked, for a small label in the
|
// was clicked, for a small label in the dossier. The label shows only the
|
||||||
// dossier. Cleared when the user picks a nation via the manual Inspect select.
|
// region's name. Cleared when the user picks a nation via the manual Inspect
|
||||||
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
|
// select.
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null);
|
||||||
const focusNation = useCallback(
|
const focusNation = useCallback(
|
||||||
(id: string, info?: { parent?: string; name?: string }) => {
|
(id: string, info?: { name?: string }) => {
|
||||||
setViewingId(id);
|
setViewingId(id);
|
||||||
setSelectedRegion(info ?? null);
|
setSelectedRegion(info ?? null);
|
||||||
},
|
},
|
||||||
@@ -779,8 +780,7 @@ function GameScreen({
|
|||||||
</Select>
|
</Select>
|
||||||
{selectedRegion && (
|
{selectedRegion && (
|
||||||
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
|
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
|
||||||
Selected: {selectedRegion.name ?? selectedRegion.parent ?? 'region'}
|
Selected: {selectedRegion.name ?? 'region'}
|
||||||
{selectedRegion.parent ? ` (${selectedRegion.parent})` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
import StatBar from './StatBar';
|
import StatBar from './StatBar';
|
||||||
import { nationColor } from '@shared/protocol';
|
import { nationColor } from '@shared/protocol';
|
||||||
import { RELATION_COLORS } from '@/lib/colors';
|
import { RELATION_COLORS } from '@/lib/colors';
|
||||||
|
import { keyToName } from '@/lib/regions';
|
||||||
import type { Nation, RelationStatus } from '@shared/types';
|
import type { Nation, RelationStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
||||||
@@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||||
const regions = nation.territory;
|
const regions = nation.territory;
|
||||||
|
|
||||||
|
// Human region names for the territory chips (region key -> name), loaded once
|
||||||
|
// (cached in @/lib/regions). A key with no known name falls back to the raw key.
|
||||||
|
const [keyNames, setKeyNames] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
keyToName()
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setKeyNames(m);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* names are cosmetic — a failed fetch just leaves the raw keys */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const identity = (
|
const identity = (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -95,15 +114,20 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
</h3>
|
</h3>
|
||||||
{regions.length > 0 ? (
|
{regions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{regions.map((code) => (
|
{regions.map((key) => {
|
||||||
<span
|
const label = keyNames[key] ?? key;
|
||||||
key={code}
|
return (
|
||||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
<span
|
||||||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
key={key}
|
||||||
>
|
className="rounded border px-1.5 py-0.5 text-xs"
|
||||||
{code}
|
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||||||
</span>
|
title={key}
|
||||||
))}
|
data-region-key={key}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No territory.</p>
|
<p className="text-sm text-muted-foreground">No territory.</p>
|
||||||
|
|||||||
+121
-92
@@ -11,6 +11,7 @@ maplibregl.setWorkerUrl(workerUrl);
|
|||||||
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||||
import type { Nation } from '@shared/types';
|
import type { Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { idToKey } from '@/lib/regions';
|
||||||
|
|
||||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
|
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
|
||||||
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
|
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
|
||||||
@@ -101,26 +102,32 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||||||
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
|
* Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has
|
||||||
* owns whole countries, so every sub-region of a country shares that country's
|
* its own region key (from `public/region-keys.json`, looked up by the feature's
|
||||||
* owning nation's color — the map looks like the old country choropleth, but
|
* stable id) and the RegionMap is keyed by that region key, so a single
|
||||||
* subdivided into finer cells. Each sub-region therefore carries:
|
* sub-region can change owner while its neighbours keep theirs. Each feature
|
||||||
|
* therefore carries:
|
||||||
*
|
*
|
||||||
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
|
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
|
||||||
* survives into the features MapLibre hands back on click/hover
|
* survives into the features MapLibre hands back on click/hover
|
||||||
* (the top-level `id` is repacked into a numeric index, #6);
|
* (the top-level `id` is repacked into a numeric index, #6);
|
||||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
|
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
|
||||||
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
|
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
|
||||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
|
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
|
||||||
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
||||||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||||||
* player-highlight border).
|
* player-highlight border).
|
||||||
*
|
*
|
||||||
* Crucially, the owner lookup keys the RegionMap by `properties.parent` (the
|
* Crucially, the owner lookup keys the RegionMap by the stamped
|
||||||
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
|
* `properties.regionKey`, **never** by the sub-region id or the repacked
|
||||||
* `FRA-03` entries, only `FRA`.
|
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
|
||||||
*/
|
*/
|
||||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
function applyRegions(
|
||||||
|
geo: FeatureCollection,
|
||||||
|
regions: RegionMap,
|
||||||
|
playerNationId: string,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
f.properties = f.properties ?? {};
|
f.properties = f.properties ?? {};
|
||||||
@@ -128,11 +135,11 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
// survives into the rendered features (the top-level `id` gets repacked).
|
// survives into the rendered features (the top-level `id` gets repacked).
|
||||||
const subId = f.id == null ? '' : String(f.id);
|
const subId = f.id == null ? '' : String(f.id);
|
||||||
f.properties.regionId = subId;
|
f.properties.regionId = subId;
|
||||||
// Ownership lives at the parent country: resolve `properties.parent`
|
// Resolve this feature's **region key** from the manifest (id -> key) and
|
||||||
// (the ISO_A3) against the RegionMap. Never the sub-region id.
|
// key the RegionMap by it — never the sub-region id or the repacked id.
|
||||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
const key = idKey[subId] ?? '';
|
||||||
f.properties.parent = parent;
|
f.properties.regionKey = key;
|
||||||
const r = parent ? regions[parent] : undefined;
|
const r = key ? regions[key] : undefined;
|
||||||
if (r) {
|
if (r) {
|
||||||
f.properties.fill = r.color;
|
f.properties.fill = r.color;
|
||||||
f.properties.owner = r.owner;
|
f.properties.owner = r.owner;
|
||||||
@@ -151,16 +158,21 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a FeatureCollection of just the sub-regions whose **parent** country is
|
* Build a FeatureCollection of just the sub-regions whose **region key** is in
|
||||||
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
|
* `keys` (cloned). Used by the territory-change flash: a turn can change
|
||||||
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
|
* individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each
|
||||||
* must light up **all** of its sub-regions, not a single feature.
|
* changed region key lights up exactly the sub-region that owns it.
|
||||||
*/
|
*/
|
||||||
function featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
|
function featuresByRegionKeys(
|
||||||
const set = new Set(parents);
|
geo: FeatureCollection,
|
||||||
|
keys: Iterable<string>,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
): FeatureCollection {
|
||||||
|
const set = new Set(keys);
|
||||||
const features = geo.features.filter((f) => {
|
const features = geo.features.filter((f) => {
|
||||||
const parent = f.properties?.parent;
|
const id = f.id == null ? '' : String(f.id);
|
||||||
return typeof parent === 'string' && set.has(parent);
|
const key = idKey[id] ?? '';
|
||||||
|
return key !== '' && set.has(key);
|
||||||
});
|
});
|
||||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||||
}
|
}
|
||||||
@@ -174,19 +186,20 @@ function statRow(label: string, value?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact hover tooltip: the sub-region's name, its parent country (ISO_A3), its
|
* Compact hover tooltip: the sub-region's name, its **current owner**
|
||||||
* owner (color-chipped), and 1–2 key stats. When the parent country has no
|
* (color-chipped), and 1–2 key stats. When the region's country has no owning
|
||||||
* owning nation, a minimal "No owner" card is shown instead.
|
* nation, a minimal "No owner" card is shown instead. The static
|
||||||
|
* parent-country code (`properties.parent`) is deliberately NOT printed here —
|
||||||
|
* it never updates when ownership is transferred, so only the live owner chip
|
||||||
|
* (resolved from the RegionMap) is shown.
|
||||||
*/
|
*/
|
||||||
function hoverHtml(subRegionName: string, parentCode: string, region?: RegionOwner, nation?: Nation): string {
|
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
|
||||||
if (!region || !nation) {
|
if (!region || !nation) {
|
||||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || parentCode || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
||||||
}
|
}
|
||||||
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||||
const sub = parentCode ? `<div class="hm-popup-sub">${esc(parentCode)}</div>` : '';
|
|
||||||
return `<div class="hm-popup">
|
return `<div class="hm-popup">
|
||||||
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
|
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
|
||||||
${sub}
|
|
||||||
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||||
<div class="hm-stats">${stats}</div>
|
<div class="hm-stats">${stats}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -202,37 +215,36 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||||||
* dossier switches to that nation. For a sub-region, the owner is the parent
|
* dossier switches to that nation. The optional `info` carries the clicked
|
||||||
* country's owning nation. The optional `info` carries the clicked sub-region's
|
* sub-region's name, for a small "which region" label in the dossier. Not
|
||||||
* own id/name and its parent country's ISO_A3, for a small "which region" label
|
* called for unowned regions (the dossier keeps its current target). See the
|
||||||
* in the dossier. Not called for unowned regions (the dossier keeps its
|
* component doc for the full behavior.
|
||||||
* current target). See the component doc for the full behavior.
|
|
||||||
*/
|
*/
|
||||||
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
|
onFocusNation?: (id: string, info?: { name?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
||||||
* the finer `regions.geo.json` dataset: each country is subdivided into
|
* the finer `regions.geo.json` dataset: each country is subdivided into
|
||||||
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
|
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
|
||||||
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
|
* lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each
|
||||||
* nation owns whole countries, so all of a country's sub-regions share that
|
* sub-region has its own region key (from `public/region-keys.json`, looked up
|
||||||
* country's owning-nation color. Rendered as a fixed, full-viewport background
|
* by the feature's stable id) and the RegionMap is keyed by that key, so a
|
||||||
* layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle
|
* single sub-region can change owner independently of its neighbours. Rendered
|
||||||
* terrain, per-nation stable fills (uniform per country, the choropleth is the
|
* as a fixed, full-viewport background layer (z-0) behind the floating HUD
|
||||||
* star), thin sub-region borders, a brighter/thicker amber border for the
|
* windows: a dark-navy basemap with subtle terrain, per-nation stable fills,
|
||||||
* player's countries, a hover tooltip (sub-region name + parent country + owner
|
* thin sub-region borders, a brighter/thicker amber border for the player's
|
||||||
* + key stats), a **click that switches the dossier to the sub-region's parent
|
* regions, a hover tooltip (sub-region name + current owner + key stats), a
|
||||||
* country's owning nation** (no popup — see #7), a transient flash when a
|
* **click that switches the dossier to the sub-region's owning nation** (no
|
||||||
* country's owner changes (lighting up all its sub-regions), and a per-scenario
|
* popup — see #7), a transient flash when a sub-region's owner changes, and a
|
||||||
* initial camera.
|
* per-scenario initial camera.
|
||||||
*
|
*
|
||||||
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||||
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
|
* RegionMap by its stamped **region key** (`properties.regionKey`, via
|
||||||
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
||||||
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
||||||
* never from the sub-region id (not a RegionMap key). Unowned countries (no
|
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
|
||||||
* RegionMap entry for the parent) simply do not change the dossier.
|
* (no RegionMap entry for their key) simply do not change the dossier.
|
||||||
*
|
*
|
||||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
* — no floating control and no external API.
|
* — no floating control and no external API.
|
||||||
@@ -248,6 +260,9 @@ export default function WorldMap({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||||||
|
// The region-key manifest (feature id -> region key), loaded once; used to
|
||||||
|
// stamp each feature's region key and to expand the flash to its sub-region.
|
||||||
|
const idKeyRef = useRef<Record<string, string> | null>(null);
|
||||||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||||||
const regionsRef = useRef(regions);
|
const regionsRef = useRef(regions);
|
||||||
const nationsRef = useRef(nations);
|
const nationsRef = useRef(nations);
|
||||||
@@ -303,17 +318,28 @@ export default function WorldMap({
|
|||||||
return g;
|
return g;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The region-key manifest (feature id -> region key), fetched once and
|
||||||
|
// cached in a ref so both the paint path and the flash can resolve keys.
|
||||||
|
const loadIdKey = (): Promise<Record<string, string>> =>
|
||||||
|
idKeyRef.current
|
||||||
|
? Promise.resolve(idKeyRef.current)
|
||||||
|
: idToKey().then((k) => {
|
||||||
|
idKeyRef.current = k;
|
||||||
|
return k;
|
||||||
|
});
|
||||||
|
|
||||||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
// Transient flash: pulse a bright outline over the changed regions, then fade
|
||||||
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
|
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
|
||||||
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
|
// region keys (a turn can change individual sub-regions, v0.19 #16 P1); each
|
||||||
// up all of that country's sub-regions.
|
// lights up exactly the sub-region that owns that key.
|
||||||
function animateFlash(codes: string[]) {
|
function animateFlash(codes: string[]) {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src || cancelled) return;
|
if (!geo || !idKey || !src || cancelled) return;
|
||||||
src.setData(featuresByParents(geo, codes));
|
src.setData(featuresByRegionKeys(geo, codes, idKey));
|
||||||
if (!map.getLayer(FLASH_LINE)) {
|
if (!map.getLayer(FLASH_LINE)) {
|
||||||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||||||
// hover highlight when that layer exists; otherwise just append.
|
// hover highlight when that layer exists; otherwise just append.
|
||||||
@@ -349,15 +375,14 @@ export default function WorldMap({
|
|||||||
// The sub-region a hovered/clicked feature belongs to is identified by its
|
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||||||
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||||||
// and its **owner** is resolved from the authoritative RegionMap via the
|
// and its **owner** is resolved from the authoritative RegionMap via the
|
||||||
// feature's `properties.parent` (the parent country's ISO_A3) through
|
// feature's stamped `properties.regionKey` through
|
||||||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||||
// index, #6) and never from the sub-region id (not a RegionMap key).
|
// index, #6) and never from the raw sub-region id (not a RegionMap key).
|
||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const p = f.properties as { name?: string; regionId?: string; parent?: string };
|
const p = f.properties as { name?: string; regionId?: string };
|
||||||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
const parent = typeof p?.parent === 'string' ? p.parent : '';
|
|
||||||
if (subId && subId !== lastHoverId) {
|
if (subId && subId !== lastHoverId) {
|
||||||
lastHoverId = subId;
|
lastHoverId = subId;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
@@ -373,7 +398,7 @@ export default function WorldMap({
|
|||||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
|
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
};
|
};
|
||||||
@@ -386,23 +411,19 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||||
// owning nation. For a sub-region, the owner is the **parent country's**
|
// owning nation, resolved from the feature's stamped `properties.regionKey`
|
||||||
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
|
// via the RegionMap (never the repacked top-level `f.id`, #6, and never the
|
||||||
// RegionMap (never the repacked top-level `f.id`, #6, and never the
|
// raw sub-region id). A sub-region whose key has no owner leaves the current
|
||||||
// sub-region id). A sub-region whose parent country has no owner leaves the
|
// dossier target unchanged — never a broken dossier.
|
||||||
// current dossier target unchanged — never a broken dossier.
|
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const region = mapFeatureOwner(regionsRef.current, f);
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
if (!region?.ownerId) return;
|
if (!region?.ownerId) return;
|
||||||
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
|
// Carry the clicked sub-region's name so the dossier can show which region
|
||||||
// the dossier can show which region was selected.
|
// was selected.
|
||||||
const p = f.properties as { regionId?: string; parent?: string; name?: string };
|
const p = f.properties as { name?: string };
|
||||||
const info = {
|
const info = { name: typeof p?.name === 'string' ? p.name : undefined };
|
||||||
parent: typeof p?.parent === 'string' ? p.parent : undefined,
|
|
||||||
name: typeof p?.name === 'string' ? p.name : undefined,
|
|
||||||
};
|
|
||||||
onFocusNationRef.current?.(region.ownerId, info);
|
onFocusNationRef.current?.(region.ownerId, info);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,34 +451,40 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stamp ownership + colors onto the regions source (no-op until geo loads).
|
// Stamp ownership + colors onto the regions source (no-op until both the
|
||||||
|
// base geo and the region-key manifest are loaded).
|
||||||
const paintRegions = () => {
|
const paintRegions = () => {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src) return;
|
if (!geo || !idKey || !src) return;
|
||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureLayers = async () => {
|
const ensureLayers = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
ensureSources();
|
ensureSources();
|
||||||
if (baseGeoRef.current) {
|
if (!baseGeoRef.current) {
|
||||||
paintRegions();
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
await loadGeo();
|
await loadGeo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load regions geojson', err);
|
console.error('failed to load regions geojson', err);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
|
||||||
paintRegions();
|
|
||||||
}
|
}
|
||||||
|
if (!idKeyRef.current) {
|
||||||
|
try {
|
||||||
|
await loadIdKey();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load region keys', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
paintRegions();
|
||||||
if (!map.getLayer(FILL_LAYER)) {
|
if (!map.getLayer(FILL_LAYER)) {
|
||||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||||
// colors read clearly even on the dark background / over the terrain.
|
// colors read clearly even on the dark background / over the terrain.
|
||||||
// Every sub-region carries its parent country's owner color, so a whole
|
// Each sub-region carries its own owning nation's color (v0.19 #16 P1),
|
||||||
// country reads as one uniform fill — same as the old country map, but
|
// so the fill traces region-key ownership, not country borders.
|
||||||
// subdivided.
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
@@ -537,28 +564,30 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-color the choropleth whenever ownership changes, and flash any country
|
// Re-color the choropleth whenever ownership changes, and flash each
|
||||||
// (all of its sub-regions) whose owner changed since the last update (a
|
// sub-region whose owner changed since the last update (a transient,
|
||||||
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
|
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
|
||||||
// `animateFlash` expands each changed country to every one of its sub-regions.
|
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
|
||||||
|
// key.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const cur = regions;
|
const cur = regions;
|
||||||
const prev = prevRegionsRef.current;
|
const prev = prevRegionsRef.current;
|
||||||
prevRegionsRef.current = cur;
|
prevRegionsRef.current = cur;
|
||||||
const wasPrimed = primedRef.current;
|
const wasPrimed = primedRef.current;
|
||||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||||
|
|
||||||
if (map && geo) {
|
if (map && geo && idKey) {
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
|
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPrimed && prev) {
|
if (wasPrimed && prev && idKey) {
|
||||||
const changed = new Set<string>();
|
const changed = new Set<string>();
|
||||||
for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||||
if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code);
|
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
|
||||||
}
|
}
|
||||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Client-side region-key data access. Fetches the two static JSON files served
|
||||||
|
// from public/ and builds the lookups the region-level UI needs:
|
||||||
|
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
|
||||||
|
// - keyToName(): region key -> human name (to show region names in the dossier)
|
||||||
|
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
|
||||||
|
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
|
||||||
|
// hardcoded origin.
|
||||||
|
type GeoLike = {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let idKeyPromise: Promise<Record<string, string>> | null = null;
|
||||||
|
let keyNamePromise: Promise<Record<string, string>> | null = null;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
|
||||||
|
export function idToKey(): Promise<Record<string, string>> {
|
||||||
|
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
|
||||||
|
return idKeyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* region key -> human region name. Built by joining region-keys.json (id -> key)
|
||||||
|
* with regions.geo.json (id -> properties.name). A key with no known name is
|
||||||
|
* simply absent (callers fall back to the raw key).
|
||||||
|
*/
|
||||||
|
export function keyToName(): Promise<Record<string, string>> {
|
||||||
|
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
|
||||||
|
([keys, geo]) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = keys[id];
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (key && typeof name === 'string') out[key] = name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return keyNamePromise;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user