Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb4f0800f | ||
|
|
b1081eefde | ||
|
|
e92bf47d7d | ||
|
|
f1cc4fbc10 | ||
|
|
97702940cd | ||
|
|
8eedc37e05 | ||
|
|
69782aad69 | ||
|
|
7bf11fa5b9 | ||
|
|
db46e1a5ec | ||
|
|
99188b5b15 | ||
|
|
0ebdb36daa | ||
|
|
06cdbf3d81 |
@@ -27,8 +27,10 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
|
||||
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui
|
||||
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
|
||||
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth
|
||||
from `public/countries.geo.json` (`id` = ISO_A3)
|
||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
||||
choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||
`properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country**
|
||||
granularity, so a sub-region inherits its parent country's owner color (see #16).
|
||||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
||||
**injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
|
||||
(`LLM_STUB=1`) is used by all tests/e2e.
|
||||
@@ -48,7 +50,8 @@ history/
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
├── public/
|
||||
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
|
||||
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
||||
│ └── regions.geo.json # 2,597 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
|
||||
@@ -140,17 +143,22 @@ history/
|
||||
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
||||
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
|
||||
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
||||
- **Map click → dossier target (owner resolved from the RegionMap).** Clicking a map region does
|
||||
NOT open a popup — it switches the dossier to that region's owning nation
|
||||
(`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set
|
||||
the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap`
|
||||
by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId`
|
||||
(properties survive into the rendered features) and the shared `regionOwner()` helper looks up.
|
||||
It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's
|
||||
`id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a
|
||||
number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no
|
||||
`RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The
|
||||
hover path (a non-closeable tooltip with owner + key stats) uses the same resolution.
|
||||
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
|
||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||
**parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records
|
||||
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
|
||||
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved
|
||||
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's
|
||||
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the
|
||||
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level
|
||||
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region
|
||||
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave
|
||||
the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable
|
||||
tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution.
|
||||
**PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the
|
||||
LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's
|
||||
color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions
|
||||
individually ownable).
|
||||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
||||
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
||||
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||
@@ -239,5 +247,6 @@ history/
|
||||
caught.
|
||||
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
|
||||
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
|
||||
- The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied
|
||||
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
|
||||
- The `dist/` client is committed to the build output only; the `public/` geo files
|
||||
(`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite
|
||||
at build time, so the prod image serves them from `dist/` (no separate copy).
|
||||
|
||||
@@ -2,6 +2,55 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## 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
|
||||
- **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.
|
||||
- **Real dissolve, not overlap.** Each merge is a real boolean union (`@turf/union`, polygon clipping) that removes the shared boundary between the merged region and its host, yielding one clean polygon — the final dataset has **0** fallback (non-dissolved) unions. Passes iterate smallest-area-first to a fixed point (2 passes on the v0.13 data, ~7 s); the output is sorted by (parent, id) at 4-dp precision and is byte-reproducible: re-running on the coarsened file reaches the fixed point immediately and rewrites an identical file (sha256-verified).
|
||||
- **Numbers.** Per-country region count: min 1 / median 7 / max 80 (Russia; was 1 / 15 / 232 — the UK). Region areas: median 23,933 km²; smallest ≈0 (degenerate micro-dots like Ragged Island and the Paracel Islands, kept as islands); largest is Antarctica itself (≈21.8M km² in the planar model, exempt — it wraps a full 360°). 158 of the 177 countries had at least one merge. The file is 2,431 KB (2.37 MB) — under the 5 MB guard, so no Douglas–Peucker pass was needed.
|
||||
- **Tooling + tests.** New `scripts/coarsen-regions.mjs` (`npm run coarsen:regions`) performs the merge in place; `scripts/regions.test.ts` gains a sixth test asserting that below-floor non-island regions stay under 1% of the dataset (currently 0) alongside the existing well-formed / unique-id / 177-country coverage / area / coverage-band checks (56 tests total). The map's data contract is unchanged (`properties.parent` → RegionMap), so `WorldMap.tsx`, `App.tsx`, `shared/protocol.ts`, and `server/map.ts` all keep working as-is.
|
||||
- Part of #16.
|
||||
|
||||
## v0.13.0 — 2026-10-12 08:30
|
||||
- **Replaced the geometric sub-region grid with real named sub-national administrative regions (#16, PR 2).** The choropleth source `public/regions.geo.json` now contains real named administrative regions — states, provinces, governorates, oblasts, … (Natural Earth admin-1, 1:10m) — instead of the abstract ~100–200 km geometric grid. Each region keeps a stable `id` and a `name`. The map's data contract is **unchanged** (`properties.parent` → the parent country's ISO_A3, `properties.name` → the region name), so no change to the map code or the LLM contract: `WorldMap.tsx`, `App.tsx`, `shared/protocol.ts`, and `server/map.ts` all keep working as-is.
|
||||
- **Generator rewritten** — `scripts/generate-regions.mjs` (same path, dependency-free, `npm run generate:regions`) now groups the admin-1 features by their `adm0_a3` (the parent's ISO_A3), keeps only parents in the 177-country set, and id's each region with its ISO 3166-2 code when present (e.g. `US-CA`, `FR-75`, `RU-MOW`) else `<ISO_A3>-<nn>`; within a country, ids are disambiguated with a `-2/-3/…` suffix on collision (ISO 3166-2 codes are not always unique within a country — e.g. all nine of Bosnia's entities report `BA-BIH`). A country with no admin-1 data would collapse to one whole-country region `<ISO_A3>-01` (0 are needed at 1:10m, which covers all 177). Coordinates are rounded to 4 decimal places, and the file is Douglas–Peucker-simplified only if it exceeds the ~8 MB guard. Output is sorted by (parent, id) and fully deterministic.
|
||||
- **The dataset is 4,138 regions covering all 177 countries**, 2,831 KB (2.76 MB) after Douglas–Peucker (the 4-dp base is ~23 MB). Per-country region count: min 1 / median 15 / max 232 (United Kingdom). There are **0 whole-country fallbacks** — 1:10m admin-1 covers every one of the 177 (the five single-region countries are SOL, SAH, CYN, PRI, FLK). Samples: `US-CA` California and `US-NY` New York (US); `FR-75` Paris and `FR-13` Bouches-du-Rhône (France); `RU-MOW` Moskovskaya (Russia).
|
||||
- **Validation test updated** — `scripts/regions.test.ts` was rewritten for the new dataset: well-formed features, globally unique ids, all 177 countries covered (≥1 region each), sane region areas (median ≥ 100 km², no non-Antarctica region ≥ 5,000,000 km² — Antarctica is exempt because its full-360° wrap ring breaks the planar area model), and each country's region area summing to within [40%, 175%] of its own area (median within [70%, 130%] — the band is wider than a naive [40%, 110%] because Natural Earth's admin-1 polygons are digitized independently of the admin-0 country polygons and are not clipped to them). Runs in O(total vertices).
|
||||
- Part of #16.
|
||||
|
||||
## v0.12.0 — 2026-10-12 01:03
|
||||
- **Map now renders the sub-national regional dataset (#16, PR 2).** The choropleth source switches from `public/countries.geo.json` (177 countries) to `public/regions.geo.json` (**2,597 sub-regions**): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed `countries`/`countries-fill`/`countries-line` → `regions`/`regions-fill`/`regions-line`, and the border line is thinned to `0.75` (from `1.25`) so the subdivision reads within each country.
|
||||
- **Ownership stays at country granularity.** A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's `properties.parent` (the parent country's ISO_A3) — never the sub-region id (`FRA-03`) nor the repacked top-level `feature.id`. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click.
|
||||
- **New shared helper `mapFeatureOwner(regions, feature)`** in `@shared/protocol` — the single source of truth for resolving a rendered map feature's owner via `properties.parent` → `RegionMap[parent]` (built on the existing `regionOwner` key lookup). The territory-change **flash** now expands a changed country to **all** of its sub-regions (via the new `featuresByParents`), so a whole-country transfer still pulses correctly at the finer resolution.
|
||||
- **No ownership-model or LLM change** — `buildRegionMap`/`RegionMap` remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3.
|
||||
- **Tests** — new `mapFeatureOwner` unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric `id`, `regionId` = sub-region code, `parent` = ISO_A3) and proving resolution via `parent` while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry.
|
||||
- Part of #16.
|
||||
|
||||
## v0.11.0 — 2026-10-12 00:31
|
||||
- **Sub-national region dataset + generator (#16, foundation only — no app behavior change).** A new dependency-free `scripts/generate-regions.mjs` (run via `npm run generate:regions`) splits each of the 177 countries in `public/countries.geo.json` into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed `public/regions.geo.json` (**2,597 regions**, ~755 KB). Each region is id'd `<ISO_A3>-<nn>` (nn = 1..k in row-major order, capped at 36 per country) with `properties = { name, parent }`, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
|
||||
- **Validation test** — new self-contained `scripts/regions.test.ts` (picked up by `npm test`) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches `^[A-Z]{3}-\d+$` and whose `parent` is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
|
||||
|
||||
Generated
+2462
-2
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.11.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -10,6 +10,10 @@
|
||||
"start": "node server/dist/index.js",
|
||||
"test": "vitest run",
|
||||
"generate:regions": "node scripts/generate-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",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
@@ -39,6 +43,15 @@
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@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",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/node": "^22.13.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,612 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/coarsen-regions.mjs — coarsens the committed `public/regions.geo.json`
|
||||
* in place, so the map is genuinely coarser, not just finer-grained.
|
||||
*
|
||||
* Rule (per user request: "focus on joining cities to the region around it.
|
||||
* Regions shouldn't be smaller than around 100x100km, excluding islands"):
|
||||
*
|
||||
* FLOOR — no region should be smaller than ~100×100 km = 10,000 km²,
|
||||
* EXCEPT islands.
|
||||
* ISLAND — a region is an island if it has NO same-country neighbor: no
|
||||
* other region with the same `properties.parent` whose bounding box
|
||||
* is within CLOSE_DEG (≈≤22 km) of its own. Small islands stay
|
||||
* as-is regardless of size (Corsica, the Hawaiian counties,
|
||||
* Okinawa, the Faroes, island micro-states, …).
|
||||
* MERGE — a region that is small (area < FLOOR) AND not an island is
|
||||
* merged into its NEAREST same-country neighbor (nearest by
|
||||
* centroid distance; tie-break → larger area, then lexicographic
|
||||
* id). Standalone cities (FR-75 Paris, the Latvian/Slovenian
|
||||
* municipalities, the UK's 231 micro-districts) collapse into the
|
||||
* region around them.
|
||||
* DISSOLVE— the merge is a real boolean union (`@turf/union`, polygon-
|
||||
* clipping): the shared boundary between the merged region and its
|
||||
* host is removed, producing a single clean polygon — not two
|
||||
* overlapping shapes. (Fallback if the union fails on a pair:
|
||||
* combine the rings into a MultiPolygon without dissolving; the
|
||||
* count of such fallbacks is reported.)
|
||||
* ITERATE — passes run smallest-area-first (small regions merge into their
|
||||
* neighbors, which grow and then absorb more). After each pass the
|
||||
* area/neighbor/centroid metrics are recomputed; it stops at the
|
||||
* fixed point (no small non-island region left) or at MAX_ITER.
|
||||
* IDENTITY— the host (the region merged INTO) keeps its own `id` and
|
||||
* `name`; the absorbed region's id is dropped. `properties.parent`
|
||||
* is unchanged, so the map code (WorldMap/App/protocol/map.ts)
|
||||
* keeps working untouched.
|
||||
*
|
||||
* Deterministic: fixed processing order (area, then id), deterministic
|
||||
* neighbor choice (distance, then -area, then id), deterministic boolean
|
||||
* union. Re-running on the coarsened file reaches the fixed point
|
||||
* immediately and rewrites a byte-identical file.
|
||||
*
|
||||
* Area model: the SAME planar approximation the validation test uses
|
||||
* (`scripts/regions.test.ts`) — `x = lon·111.32·cos(midLat)`,
|
||||
* `y = lat·110.574` km, shoelace per OUTER ring (holes ignored), each ring at
|
||||
* its own mean latitude — so the script's merge decisions and the test's
|
||||
* assertions measure the same quantity.
|
||||
*
|
||||
* Longitude handling: every ring is unwrapped to a continuous [0,360)-derived
|
||||
* space before its bbox/centroid is computed (the shortest-difference
|
||||
* "np.unwrap" scheme), which makes the one full-360° antimeridian-wrapping
|
||||
* feature (Antarctica's mainland ring) and any seam-straddling ring behave
|
||||
* correctly; cross-feature longitude gaps use the 360°-cyclic minimum.
|
||||
*
|
||||
* Output: features sorted by (parent, id) (the same comparator as
|
||||
* `scripts/generate-regions.mjs`), coordinates rounded to 4 decimal places,
|
||||
* single line of JSON + trailing newline. If the file would exceed the
|
||||
* ~5 MB guard, a light Douglas–Peucker pass (tolerances escalated through a
|
||||
* small table) brings it under ~4 MB before writing.
|
||||
*
|
||||
* Run: `npm run coarsen:regions`
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { union } from '@turf/union';
|
||||
import { feature, featureCollection } from '@turf/helpers';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
const FILE = path.join(root, 'public', 'regions.geo.json');
|
||||
|
||||
// --- parameters ---------------------------------------------------------------
|
||||
const AREA_FLOOR_KM2 = 10_000; // ~100×100 km floor for non-island regions
|
||||
const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this; ≈≤22 km)
|
||||
const MAX_ITER = 20; // safety cap on fixed-point passes (spec: ~15)
|
||||
const SIZE_GUARD_KB = 5_120; // keep the committed file under ~5 MB
|
||||
const SIZE_TARGET_KB = 4_096; // …and land under ~4 MB once simplified
|
||||
const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees)
|
||||
const R4 = 4; // coordinate precision (decimal places, ≈11 m)
|
||||
const SLIVER_EPS_KM2 = 1e-6; // drop only truly-degenerate (≈zero-area) clipper slivers
|
||||
|
||||
// --- area model (identical to scripts/regions.test.ts) -------------------------
|
||||
const KM_LON = 111.32;
|
||||
const KM_LAT = 110.574;
|
||||
const rad = (deg) => (deg * Math.PI) / 180;
|
||||
|
||||
/** Shoelace area (km²) of one ring, measured at the ring's own mean latitude. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Sum of a geometry's outer-ring areas (holes are lakes — ignored by design). */
|
||||
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;
|
||||
}
|
||||
|
||||
// --- longitude unwrapping (antimeridian-safe) ----------------------------------
|
||||
|
||||
/**
|
||||
* Unwrap a ring's longitudes to a continuous real line (the "np.unwrap"
|
||||
* shortest-difference scheme, anchored at the first vertex's [0,360) value).
|
||||
* Returns { lons, full } — `full` when the unwrapped span is ≥ 300°, i.e. the
|
||||
* ring is a full-360° antimeridian band (Antarctica's mainland ring).
|
||||
*/
|
||||
function unwrapRing(ring) {
|
||||
const n = ring.length;
|
||||
const norm = (x) => ((x % 360) + 360) % 360; // → [0,360)
|
||||
const u = new Array(n);
|
||||
u[0] = norm(ring[0][0]);
|
||||
for (let i = 1; i < n; i++) {
|
||||
// The PREVIOUS unwrapped value may lie outside [0,360) (it accumulates),
|
||||
// so normalize it back before measuring the difference; otherwise a bogus
|
||||
// ~180° jump is inserted and the ring's span balloons to ~180°.
|
||||
const a = norm(u[i - 1]);
|
||||
const b = norm(ring[i][0]);
|
||||
const d = (((b - a) % 360) + 540) % 360 - 180; // shortest signed diff in (-180, 180]
|
||||
u[i] = u[i - 1] + d;
|
||||
}
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const v of u) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { lons: u, full: max - min >= 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cyclic 1-D gap (degrees) between two tight longitude intervals in their own
|
||||
* unwrapped [0,360)-derived spaces. Tries the three 360° shifts of `b` so
|
||||
* different unwrap anchors reconcile; a `full` interval is close to everything.
|
||||
*/
|
||||
function lonGap(intA, intB) {
|
||||
if (intA.full || intB.full) return 0;
|
||||
let best = Infinity;
|
||||
for (const k of [-1, 0, 1]) {
|
||||
const a2 = intB.a + 360 * k;
|
||||
const b2 = intB.b + 360 * k;
|
||||
const gap = intA.b < a2 ? a2 - intA.b : b2 < intA.a ? intA.a - b2 : 0;
|
||||
if (gap < best) best = gap;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- geometry helpers ----------------------------------------------------------
|
||||
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
|
||||
|
||||
/** Round a ring to 4 dp, drop consecutive duplicates, and (re-)close it. */
|
||||
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; // degenerate; caller drops it
|
||||
return [...out, [...out[0]]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round every ring of a geometry to 4 dp, drop degenerate rings (<3 pts) and
|
||||
* empty polygons, keep the Polygon/MultiPolygon type (a single-part
|
||||
* MultiPolygon collapses to Polygon). Returns null if nothing usable survived.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a union output (or fallback MultiPolygon): roundAll, then drop
|
||||
* truly-degenerate (≈zero-area, < SLIVER_EPS_KM2) polygons the clipper can
|
||||
* leave behind at shared boundaries. It never drops *every* part — a merge of
|
||||
* two sub-resolution (zero-area) micro-regions must still collapse into one
|
||||
* (degenerate) region rather than vanish. Returns null only when the input
|
||||
* itself had no usable ring (e.g. `null`).
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Douglas–Peucker of an OPEN point sequence (keeps endpoints). */
|
||||
function dpOpen(pts, tol) {
|
||||
if (pts.length < 3) return pts;
|
||||
let maxD = 0;
|
||||
let idx = 0;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const l2 = dx * dx + dy * dy;
|
||||
let t = l2 === 0 ? 0 : ((pts[i][0] - a[0]) * dx + (pts[i][1] - a[1]) * dy) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const d = Math.hypot(pts[i][0] - (a[0] + t * dx), pts[i][1] - (a[1] + t * dy));
|
||||
if (d > maxD) {
|
||||
maxD = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
if (maxD > tol) {
|
||||
const left = dpOpen(pts.slice(0, idx + 1), tol);
|
||||
const right = dpOpen(pts.slice(idx), tol);
|
||||
return left.concat(right.slice(1));
|
||||
}
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
/** Douglas–Peucker a CLOSED ring at tolerance `tol` (0 = skip). */
|
||||
function dpRing(ring, tol) {
|
||||
if (tol <= 0 || ring.length < 4) return ring;
|
||||
let pts = ring;
|
||||
if (pts[0][0] === pts[pts.length - 1][0] && pts[0][1] === pts[pts.length - 1][1]) pts = pts.slice(0, -1);
|
||||
const s = dpOpen(pts, tol);
|
||||
if (s.length < 3) return ring;
|
||||
return [...s, [...s[0]]];
|
||||
}
|
||||
|
||||
/** Round (and optionally DP-simplify) every ring of a geometry. */
|
||||
function transformGeom(geom, tol) {
|
||||
const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates)
|
||||
.map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : 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 };
|
||||
}
|
||||
|
||||
// --- region record -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A region under management. `lonInt` is the tight longitude interval in
|
||||
* [0,360)-derived unwrapped space (or `full` for a 360° band); `(clat, clon)`
|
||||
* is the area-weighted centroid of the LARGEST outer ring (the region's
|
||||
* "location"), unwrapped for its ring then wrapped to [-180,180) for
|
||||
* cyclic-distance use.
|
||||
*/
|
||||
class Region {
|
||||
constructor(f) {
|
||||
this.id = f.id;
|
||||
this.name = f.properties.name;
|
||||
this.parent = f.properties.parent;
|
||||
this.geom = f.geometry;
|
||||
this.rawGeom = null; // last pre-normalization union output (safety net for build())
|
||||
this.dead = false;
|
||||
this.metrics();
|
||||
}
|
||||
|
||||
metrics() {
|
||||
this.area = geomAreaKm2(this.geom);
|
||||
const polys = this.geom.type === 'Polygon' ? [this.geom.coordinates] : this.geom.coordinates;
|
||||
let latMin = Infinity;
|
||||
let latMax = -Infinity;
|
||||
let lonA = Infinity;
|
||||
let lonB = -Infinity;
|
||||
let lonFull = false;
|
||||
let bestRing = null;
|
||||
let bestArea = -1;
|
||||
for (const poly of polys) {
|
||||
const outer = poly[0];
|
||||
for (const [, la] of outer) {
|
||||
if (la < latMin) latMin = la;
|
||||
if (la > latMax) latMax = la;
|
||||
}
|
||||
const { lons, full } = unwrapRing(outer);
|
||||
if (full) lonFull = true;
|
||||
let a = Infinity;
|
||||
let b = -Infinity;
|
||||
for (const v of lons) {
|
||||
if (v < a) a = v;
|
||||
if (v > b) b = v;
|
||||
}
|
||||
lonA = Math.min(lonA, a);
|
||||
lonB = Math.max(lonB, b);
|
||||
const ar = ringAreaKm2(outer);
|
||||
if (ar > bestArea) {
|
||||
bestArea = ar;
|
||||
bestRing = outer;
|
||||
}
|
||||
}
|
||||
this.lonInt = { a: lonA, b: lonB, full: lonFull };
|
||||
this.latMin = latMin;
|
||||
this.latMax = latMax;
|
||||
// area-weighted centroid of the largest outer ring, in unwrapped space
|
||||
const { lons } = unwrapRing(bestRing);
|
||||
const lats = bestRing.map((p) => p[1]);
|
||||
const n = bestRing.length;
|
||||
let A = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x1 = lons[i];
|
||||
const y1 = lats[i];
|
||||
const x2 = lons[(i + 1) % n];
|
||||
const y2 = lats[(i + 1) % n];
|
||||
const f = x1 * y2 - x2 * y1;
|
||||
A += f;
|
||||
cx += (x1 + x2) * f;
|
||||
cy += (y1 + y2) * f;
|
||||
}
|
||||
A /= 2;
|
||||
if (Math.abs(A) > 1e-9) {
|
||||
cx /= 6 * A;
|
||||
cy /= 6 * A;
|
||||
} else {
|
||||
cx = (lonA + lonB) / 2;
|
||||
cy = (latMin + latMax) / 2;
|
||||
}
|
||||
this.clon = (((cx + 180) % 360) + 360) % 360 - 180;
|
||||
this.clat = cy;
|
||||
}
|
||||
|
||||
/** Cyclic planar distance (km) to another region's centroid. */
|
||||
distKm(o) {
|
||||
let dlon = Math.abs(this.clon - o.clon) % 360;
|
||||
if (dlon > 180) dlon = 360 - dlon;
|
||||
const midLat = rad((this.clat + o.clat) / 2);
|
||||
return Math.hypot(dlon * KM_LON * Math.cos(midLat), (this.clat - o.clat) * KM_LAT);
|
||||
}
|
||||
|
||||
/** Bounding-box closeness to another same-space region (see CLOSE_DEG). */
|
||||
closeTo(o) {
|
||||
const latGap = this.latMax < o.latMin ? o.latMin - this.latMax : o.latMax < this.latMin ? this.latMin - o.latMax : 0;
|
||||
if (latGap > CLOSE_DEG) return false;
|
||||
return lonGap(this.lonInt, o.lonInt) <= CLOSE_DEG;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A "degenerate" region is a zero-area artifact: after 4-dp rounding its only
|
||||
* ring has <3 distinct points (a line/point, e.g. a micro-municipality that
|
||||
* v0.13's Douglas–Peucker collapsed to `[A, B, A]`). It contributes no area,
|
||||
* so it can never be dissolved by the clipper (which returns null) nor kept
|
||||
* through ring-dropping (which loses the 2-point ring). Merging one is purely
|
||||
* an absorption: the host keeps its own geometry.
|
||||
*/
|
||||
const isDegenerate = (r) => r.area < SLIVER_EPS_KM2;
|
||||
|
||||
// --- main ----------------------------------------------------------------------
|
||||
function main() {
|
||||
const fc0 = JSON.parse(readFileSync(FILE, 'utf8'));
|
||||
const regions = fc0.features.map((f) => new Region(f));
|
||||
const byParent = new Map();
|
||||
for (const r of regions) {
|
||||
if (!byParent.has(r.parent)) byParent.set(r.parent, []);
|
||||
byParent.get(r.parent).push(r);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
console.log(`input: ${fc0.features.length} regions across ${byParent.size} countries (${(Buffer.byteLength(readFileSync(FILE)) / 1024).toFixed(0)} KB)`);
|
||||
console.log(`parameters: floor ${AREA_FLOOR_KM2.toLocaleString('en-US')} km2, close ${CLOSE_DEG} deg, max ${MAX_ITER} passes`);
|
||||
|
||||
const merges = []; // { from, fromName, fromArea, to, toName, toAreaAfter }
|
||||
let fallbackUnions = 0;
|
||||
|
||||
// The fixed-point loop. Each pass: every region that is small AND has a
|
||||
// close same-country neighbor merges into the nearest such neighbor
|
||||
// (nearest by centroid distance; tie → larger area → lexicographic id).
|
||||
for (let pass = 0; pass < MAX_ITER; pass++) {
|
||||
let skippedIsland = 0;
|
||||
let skippedGrew = 0;
|
||||
const items = [];
|
||||
for (const r of regions) {
|
||||
if (r.dead || r.area >= AREA_FLOOR_KM2) continue;
|
||||
const siblings = byParent.get(r.parent);
|
||||
for (const s of siblings) {
|
||||
if (s !== r && !s.dead && r.closeTo(s)) {
|
||||
items.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!items.length) {
|
||||
console.log(`after pass ${pass}: fixed point reached (no small non-island regions left)`);
|
||||
break;
|
||||
}
|
||||
items.sort((a, b) => (a.area !== b.area ? a.area - b.area : a.id < b.id ? -1 : 1));
|
||||
let merged = 0;
|
||||
for (const r of items) {
|
||||
if (r.dead) continue;
|
||||
if (r.area >= AREA_FLOOR_KM2) {
|
||||
skippedGrew++;
|
||||
continue; // absorbed a smaller region earlier this pass and crossed the floor
|
||||
}
|
||||
const siblings = byParent.get(r.parent);
|
||||
// Close same-country neighbors as of NOW (mid-pass): some may have died
|
||||
// earlier in this pass.
|
||||
const close = [];
|
||||
for (const s of siblings) if (s !== r && !s.dead && r.closeTo(s)) close.push(s);
|
||||
if (!close.length) {
|
||||
skippedIsland++;
|
||||
continue; // lost its last close neighbor this pass → island now → exempt
|
||||
}
|
||||
// Prefer a REAL (non-degenerate) host so real regions dissolve cleanly;
|
||||
// fall back to any close neighbor only when no real one is nearby.
|
||||
const realCands = close.filter((s) => !isDegenerate(s));
|
||||
const pool = realCands.length ? realCands : close;
|
||||
let host = null;
|
||||
for (const s of pool) {
|
||||
if (
|
||||
!host ||
|
||||
r.distKm(s) < r.distKm(host) ||
|
||||
(r.distKm(s) === r.distKm(host) &&
|
||||
(s.area !== host.area ? s.area > host.area : s.id < host.id))
|
||||
)
|
||||
host = s;
|
||||
}
|
||||
if (isDegenerate(r)) {
|
||||
// r is a zero-area artifact (line/point): it contributes no area, so the
|
||||
// merge is a pure absorption — the host keeps its own geometry (a real
|
||||
// host is untouched; if the host is also degenerate a cluster of dots
|
||||
// collapses to one). Never route degenerate input through the clipper
|
||||
// (returns null) or ring-dropping (would lose the 2-point ring the map
|
||||
// already renders).
|
||||
r.dead = true;
|
||||
merged++;
|
||||
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
|
||||
continue;
|
||||
}
|
||||
if (isDegenerate(host)) {
|
||||
// r is real but the chosen host is a zero-area artifact: the host takes
|
||||
// on r's geometry (keeping its own id/name). No clipper needed.
|
||||
host.geom = r.geom;
|
||||
host.rawGeom = r.geom;
|
||||
host.metrics();
|
||||
r.dead = true;
|
||||
merged++;
|
||||
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
|
||||
continue;
|
||||
}
|
||||
// Both real: dissolve via a boolean union; the host keeps its id/name
|
||||
// and the item vanishes. @turf/union@7 dispatches through geomEach, which
|
||||
// only understands a FeatureCollection / single Feature / bare geometry —
|
||||
// a raw array of features has no `.type` and throws "Unknown Geometry
|
||||
// Type". Wrap it.
|
||||
let unionGeom = null;
|
||||
let rawUnion = null;
|
||||
try {
|
||||
const u = union(featureCollection([feature(r.geom), feature(host.geom)]));
|
||||
rawUnion = u ? u.geometry : null;
|
||||
unionGeom = normalizeGeom(rawUnion);
|
||||
} catch (err) {
|
||||
console.error(` [warn] union failed for ${r.id} into ${host.id} (${err.message}); combining without dissolve`);
|
||||
fallbackUnions++;
|
||||
}
|
||||
if (!unionGeom) {
|
||||
// fallback: no dissolve — keep both shapes in one MultiPolygon
|
||||
const polys = [...(r.geom.type === 'Polygon' ? [r.geom.coordinates] : r.geom.coordinates), ...(host.geom.type === 'Polygon' ? [host.geom.coordinates] : host.geom.coordinates)];
|
||||
rawUnion = { type: 'MultiPolygon', coordinates: polys };
|
||||
unionGeom = normalizeGeom(rawUnion);
|
||||
if (!unionGeom) fallbackUnions++;
|
||||
}
|
||||
if (!unionGeom) {
|
||||
console.error(` [error] could not merge ${r.id} into ${host.id}; leaving ${r.id} as-is`);
|
||||
continue;
|
||||
}
|
||||
host.geom = unionGeom;
|
||||
host.rawGeom = rawUnion;
|
||||
host.metrics();
|
||||
r.dead = true;
|
||||
merged++;
|
||||
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
|
||||
}
|
||||
const alive = regions.filter((r) => !r.dead).length;
|
||||
console.log(
|
||||
`pass ${pass + 1}: ${items.length} small non-island region(s) → ${merged} merged` +
|
||||
` (${skippedGrew} crossed the floor, ${skippedIsland} became island) → ${alive} regions remain`,
|
||||
);
|
||||
if (merged === 0) {
|
||||
// items exist but nothing could merge (all became islands this pass) → stable
|
||||
console.log(`pass ${pass + 1}: no merges possible — stable state`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- assemble output ----------------------------------------------------------
|
||||
const alive = regions.filter((r) => !r.dead);
|
||||
const sortById = (a, b) =>
|
||||
a.parent !== b.parent ? (a.parent < b.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true });
|
||||
alive.sort(sortById);
|
||||
|
||||
const build = (tol) => {
|
||||
const feats = [];
|
||||
for (const r of alive) {
|
||||
// Never drop a region: if the simplified/normalized geometry degenerated,
|
||||
// fall back to the raw (pre-normalization) union output, then to the
|
||||
// untouched input geometry.
|
||||
const g =
|
||||
transformGeom(r.geom, tol) ??
|
||||
(r.rawGeom ? transformGeom(r.rawGeom, 0) ?? r.rawGeom : r.geom);
|
||||
feats.push({ type: 'Feature', id: r.id, properties: { name: r.name, parent: r.parent }, geometry: g });
|
||||
}
|
||||
return { type: 'FeatureCollection', features: feats };
|
||||
};
|
||||
|
||||
let out = build(0);
|
||||
let json = JSON.stringify(out);
|
||||
let sizeKB = Buffer.byteLength(json) / 1024;
|
||||
console.log(`\ncoarsened: ${fc0.features.length} → ${out.features.length} regions (${fc0.features.length - out.features.length} merged away)`);
|
||||
|
||||
let usedTol = 0;
|
||||
if (sizeKB > SIZE_GUARD_KB) {
|
||||
for (const t of DP_STEPS) {
|
||||
out = build(t);
|
||||
json = JSON.stringify(out);
|
||||
sizeKB = Buffer.byteLength(json) / 1024;
|
||||
usedTol = t;
|
||||
console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`);
|
||||
if (sizeKB < SIZE_TARGET_KB) break;
|
||||
}
|
||||
}
|
||||
writeFileSync(FILE, json + '\n');
|
||||
|
||||
// --- summary -------------------------------------------------------------------
|
||||
const areaOf = new Map();
|
||||
const counts = new Map();
|
||||
let maxA = 0;
|
||||
let maxR = null;
|
||||
for (const f of out.features) {
|
||||
const a = geomAreaKm2(f.geometry);
|
||||
areaOf.set(f.id, a);
|
||||
counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
|
||||
if (a > maxA) {
|
||||
maxA = a;
|
||||
maxR = f;
|
||||
}
|
||||
}
|
||||
const ccounts = [...counts.values()].sort((a, b) => a - b);
|
||||
const median =
|
||||
ccounts.length % 2 ? ccounts[(ccounts.length - 1) / 2] : (ccounts[ccounts.length / 2 - 1] + ccounts[ccounts.length / 2]) / 2;
|
||||
const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
// islands = small regions with no close same-country neighbor in the FINAL set
|
||||
const finalByParent = new Map();
|
||||
for (const f of out.features) {
|
||||
if (!finalByParent.has(f.properties.parent)) finalByParent.set(f.properties.parent, []);
|
||||
finalByParent.get(f.properties.parent).push(f);
|
||||
}
|
||||
// rebuild final metrics (light: only need island flag on the < FLOOR set)
|
||||
const finalRegions = out.features.map((f) => new Region(f));
|
||||
const finalByParent2 = new Map();
|
||||
for (const r of finalRegions) {
|
||||
if (!finalByParent2.has(r.parent)) finalByParent2.set(r.parent, []);
|
||||
finalByParent2.get(r.parent).push(r);
|
||||
}
|
||||
let smallIslands = 0;
|
||||
let smallNonIslands = 0;
|
||||
const islandSamples = [];
|
||||
for (const r of finalRegions) {
|
||||
if (r.area >= AREA_FLOOR_KM2) continue;
|
||||
let hasNeighbor = false;
|
||||
for (const s of finalByParent2.get(r.parent)) {
|
||||
if (s !== r && r.closeTo(s)) {
|
||||
hasNeighbor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNeighbor) smallNonIslands++;
|
||||
else {
|
||||
smallIslands++;
|
||||
if (islandSamples.length < 12) islandSamples.push(`${r.id} ${r.name} (${Math.round(r.area)} km2)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`wrote ${FILE} — ${sizeKB.toFixed(1)} KB (${(sizeKB / 1024).toFixed(2)} MB)` + (usedTol ? ` [DP @ ${usedTol}°]` : ''));
|
||||
console.log(`per-country count: min ${ccounts[0]} / median ${median} / max ${ccounts[ccounts.length - 1]} (max: ${maxCountry[0]})`);
|
||||
console.log(`max region: ${maxR.id} (${maxR.properties.name}) ≈ ${Math.round(maxA).toLocaleString('en-US')} km2`);
|
||||
console.log(`small (<${AREA_FLOOR_KM2.toLocaleString('en-US')} km2) in final set: ${smallIslands + smallNonIslands} — ${smallIslands} islands (exempt) / ${smallNonIslands} non-island residuals`);
|
||||
if (islandSamples.length) console.log(`island samples: ${islandSamples.join(', ')}`);
|
||||
console.log(`fallback (non-dissolved) unions: ${fallbackUnions}`);
|
||||
console.log(`merge log (${merges.length} merges): first 15:`);
|
||||
for (const m of merges.slice(0, 15)) {
|
||||
console.log(` ${m.from} (${m.fromName}, ${Math.round(m.fromArea)} km2) → ${m.to} (${m.toName}, now ${Math.round(m.toAreaAfter)} km2)`);
|
||||
}
|
||||
const logPath = '/tmp/opencode/coarsen-log.txt';
|
||||
writeFileSync(logPath, merges.map((m) => `${m.from}\t${m.fromName}\t${Math.round(m.fromArea)}\t${m.to}\t${m.toName}\t${Math.round(m.toAreaAfter)}`).join('\n') + '\n');
|
||||
console.log(`full merge log → ${logPath}; 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}`);
|
||||
+191
-459
@@ -2,49 +2,45 @@
|
||||
/**
|
||||
* scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`.
|
||||
*
|
||||
* Subdivides every country in `public/countries.geo.json` into sub-national
|
||||
* regions sized ~100–200 km on a side (adaptive: a regular lat/lon grid
|
||||
* clipped to the country, so vast/sparse countries — Siberia, Sahara,
|
||||
* Antarctica — end up with bigger regions while Europe gets smaller ones).
|
||||
* Each country gets a few to tens of regions (capped at 36); tiny countries
|
||||
* (and tiny antimeridian islet groups) stay a single region.
|
||||
* Builds the sub-national region choropleth from REAL named administrative
|
||||
* regions (Natural Earth admin-1: states, provinces, governorates, oblasts, …)
|
||||
* instead of the old geometric grid the user rejected ("some stupid geometric
|
||||
* grid" — it looked abstract, not like recognizable territories).
|
||||
*
|
||||
* Model: a country's lat/lon bbox is tiled with a cols × rows grid of
|
||||
* ~square-in-km cells. Each grid cell yields at most ONE region feature:
|
||||
* every sub-polygon of the country is clipped against the cell with
|
||||
* Sutherland–Hodgman (edges in order: left, bottom, right, top), clipped
|
||||
* pieces smaller than 300 km² are dropped, and the kept pieces become the
|
||||
* rings of that cell's region (a MultiPolygon when there is more than one).
|
||||
* Region ids are `<ISO_A3>-<nn>`, nn = 1..k in row-major order (rows
|
||||
* top→bottom by descending lat, left→right by ascending lon); names are
|
||||
* `<Country> R<nn>`. Coordinates are rounded to 4 decimal places (≈11 m).
|
||||
* Because a cell produces at most one region, the per-country count can
|
||||
* never exceed the (capped) cell count ≤ 36, even for archipelagos.
|
||||
* Sources (both GeoJSON):
|
||||
* (a) the Natural Earth admin-1 file — real, named sub-national boundaries.
|
||||
* It is read from a NON-committed temp path (default /tmp/ne10m_admin1.geojson,
|
||||
* override with the NE_ADMIN1 env var) so the repo only stores the derived
|
||||
* output, not the 40 MB source. Natural Earth is public domain (CC0).
|
||||
* (b) the committed `public/countries.geo.json` (177 countries) — the
|
||||
* authoritative ISO_A3 set, the country names, and the whole-country
|
||||
* geometries used as the fallback for any country with no admin-1 data.
|
||||
*
|
||||
* Area: local planar approximation, x = lon·111.32·cos(midLat), y = lat·
|
||||
* 110.574 (km), shoelace per sub-polygon ring, summed in abs. Target region
|
||||
* area T = 12,000 km² (≈110 km side); target count n = clamp(round(area /
|
||||
* T), 1, 36). S = sqrt(bboxAreaKm2 / n); cols = max(1, round(wKm/S)),
|
||||
* rows = max(1, round(hKm/S)); the grid is then reduced if cols·rows > 36.
|
||||
* Output contract (UNCHANGED from the old generator, so `WorldMap.tsx`,
|
||||
* `App.tsx`, `shared/protocol.ts` and `server/map.ts` all keep working —
|
||||
* ownership is resolved via `properties.parent`, the parent country's ISO_A3):
|
||||
* - a GeoJSON FeatureCollection
|
||||
* - each feature: a unique top-level `id`, `properties = { name, parent }`
|
||||
* (`parent` = the country ISO_A3 = the RegionMap key; `name` = the region's
|
||||
* human name), and a Polygon/MultiPolygon geometry.
|
||||
*
|
||||
* Antimeridian handling (three cases):
|
||||
* - Small antimeridian island groups (area ≤ 50,000 km² — Fiji, Kiribati,
|
||||
* Tuvalu, …): one region = the whole country (`<ISO_A3>-01`, plain name).
|
||||
* - Large antimeridian countries made of ordinary sub-polygons (Russia):
|
||||
* the sub-polygons are shifted into longitude space [0, 360) (lons < 0 →
|
||||
* +360) so the country sits in one contiguous band, tiled there, and
|
||||
* output rings that straddle ±180° are split at the antimeridian into a
|
||||
* MultiPolygon with each ring fully on one side — standard −180..180
|
||||
* GeoJSON that renders without cross-map lines.
|
||||
* - Full-360° "polar" wrap rings (Antarctica's main ring, whose only
|
||||
* longitude jump sits at the pole and which crosses the prime meridian
|
||||
* once): such a ring self-intersects in any single planar representation,
|
||||
* so it is replaced by two simple "wedge" rings — the eastern and western
|
||||
* hemispheric halves, each closed along the prime meridian, the pole
|
||||
* (lat −90), and the antimeridian. The wedges tile −180..180 space
|
||||
* without overlap and are tiled like any other ring (no shifting needed).
|
||||
* For each country in `countries.geo.json` (in file order, deterministic):
|
||||
* - if it has admin-1 sub-regions -> emit each as a feature, in the source
|
||||
* file's order. Region `id` = its ISO 3166-2 code (e.g. `US-CA`, `FR-75`,
|
||||
* `RU-MOW`) when present, else `<ISO_A3>-<nn>` (1-based index in file
|
||||
* order). Because ISO 3166-2 codes are not always unique WITHIN a country
|
||||
* (e.g. all nine of Bosnia's entities report `BA-BIH`), ids are
|
||||
* disambiguated with a `-2/-3/…` suffix on collision (BA-BIH, BA-BIH-2, …).
|
||||
* - if it has none (tiny / not covered by admin-1) -> emit ONE whole-country
|
||||
* region: `id = <ISO_A3>-01`, `name` = the country's name, geometry = the
|
||||
* country's geometry.
|
||||
*
|
||||
* Every coordinate is rounded to 4 decimal places (0.0001° ≈ 11 m). If the
|
||||
* resulting file exceeds ~8 MB, a light Douglas–Peucker pass (0.05°, escalating
|
||||
* through a small table if still too big) trims it under the guard while keeping
|
||||
* shapes recognizable, and the before/after sizes are reported. Features are
|
||||
* sorted by (parent, id). Fully deterministic: same inputs -> same bytes.
|
||||
*
|
||||
* Deterministic: same input → byte-identical output.
|
||||
* Run: `npm run generate:regions`
|
||||
*/
|
||||
|
||||
@@ -55,466 +51,202 @@ import { fileURLToPath } from 'node:url';
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- parameters ------------------------------------------------------------------
|
||||
const T = 12_000; // target region area, km² (≈110 km side)
|
||||
const MAX_REGIONS = 36; // per-country cap ("tens")
|
||||
const SLIVER_KM2 = 300; // drop clipped pieces smaller than this
|
||||
const BIG_KM2 = 50_000; // antimeridian countries at/above this are tiled, not 1 region
|
||||
const KM_LON = 111.32; // km per degree of longitude (at the equator)
|
||||
const KM_LAT = 110.574; // km per degree of latitude
|
||||
// --- parameters ----------------------------------------------------------------
|
||||
const SIZE_GUARD_KB = 8192; // ~8 MB file-size guard (task spec)
|
||||
const SIZE_TARGET_KB = 6144; // land under ~6 MB once simplified (margin under the guard)
|
||||
const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees)
|
||||
const R4 = 4; // base coordinate precision (decimal places, ≈11 m)
|
||||
|
||||
// --- geo helpers (same planar approximation as scripts/regions.test.ts) -----------
|
||||
const rad = (deg) => (deg * Math.PI) / 180;
|
||||
const r4 = (v) => Math.round(v * 1e4) / 1e4;
|
||||
// Natural Earth admin-1 source (NON-committed temp file; override with NE_ADMIN1).
|
||||
const INPUT = process.env.NE_ADMIN1 || '/tmp/ne10m_admin1.geojson';
|
||||
|
||||
function polygons(geom) {
|
||||
return geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||
}
|
||||
// --- small geo helpers ---------------------------------------------------------
|
||||
const rN = (v, N) => Math.round(v * Math.pow(10, N)) / Math.pow(10, N);
|
||||
|
||||
function allCoords(geom) {
|
||||
/** Round a ring to N decimals, drop consecutive duplicates, and close it. */
|
||||
function roundRing(ring, N) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) for (const ring of poly) for (const c of ring) out.push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
function meanLat(geom) {
|
||||
const cs = allCoords(geom);
|
||||
let s = 0;
|
||||
for (const c of cs) s += c[1];
|
||||
return cs.length ? s / cs.length : 0;
|
||||
}
|
||||
|
||||
function bbox(rings) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity,
|
||||
minLat = Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const ring of rings)
|
||||
for (const [lo, la] of ring) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
if (la < minLat) minLat = la;
|
||||
if (la > maxLat) maxLat = la;
|
||||
}
|
||||
return { minLon, maxLon, minLat, maxLat };
|
||||
}
|
||||
|
||||
/** |shoelace| of a (possibly open) ring in km², using one mid-latitude for x. */
|
||||
function ringAreaKm2(ring, midLat) {
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
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);
|
||||
}
|
||||
|
||||
/** Remove consecutive duplicate points and a trailing closing point equal to the first. */
|
||||
function dedupeRing(ring) {
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
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);
|
||||
}
|
||||
// drop a trailing point equal to the first (a redundant close), then re-close
|
||||
if (
|
||||
out.length > 1 &&
|
||||
out[0][0] === out[out.length - 1][0] &&
|
||||
out[0][1] === out[out.length - 1][1]
|
||||
)
|
||||
out.pop();
|
||||
return out;
|
||||
if (out.length < 3) return out; // degenerate; caller drops it
|
||||
return [...out, [...out[0]]];
|
||||
}
|
||||
|
||||
/** Close an open ring by appending a copy of the first point. */
|
||||
function closeRing(ring) {
|
||||
return ring.length ? [...ring, [...ring[0]]] : ring;
|
||||
/** Perpendicular distance (degrees) from point p to the segment a→b. */
|
||||
function perpDist(p, a, b) {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const l2 = dx * dx + dy * dy;
|
||||
if (l2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
||||
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiling/area subject rings of a feature, in standard −180..180 space:
|
||||
* one open ring per sub-polygon (outer ring only — holes are ignored by
|
||||
* design: Sutherland–Hodgman clips simple rings and any enclosed water is
|
||||
* at most a lake, which the area-sanity tolerance in the test absorbs).
|
||||
* A full-360° wrap ring is replaced by its two hemispheric wedges (see
|
||||
* makeWedges); if that is not possible the ring is returned as-is (the
|
||||
* caller may still shift it).
|
||||
*/
|
||||
function subjectRings(geom) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) {
|
||||
const ring = dedupeRing(poly[0]);
|
||||
if (hasAMJump(ring)) {
|
||||
const wedges = makeWedges(ring);
|
||||
if (wedges) out.push(...wedges);
|
||||
else out.push(ring);
|
||||
} else {
|
||||
out.push(ring);
|
||||
/** Douglas–Peucker simplification of an OPEN point sequence (keeps endpoints). */
|
||||
function dpOpen(pts, tol) {
|
||||
if (pts.length < 3) return pts;
|
||||
let maxD = 0;
|
||||
let idx = 0;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const d = perpDist(pts[i], a, b);
|
||||
if (d > maxD) {
|
||||
maxD = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Sutherland–Hodgman: clip an open subject ring against one half-plane --------
|
||||
function clipEdge(poly, inside, intersect) {
|
||||
const out = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const aIn = inside(a);
|
||||
const bIn = inside(b);
|
||||
if (aIn && bIn) out.push(b);
|
||||
else if (aIn && !bIn) out.push(intersect(a, b));
|
||||
else if (!aIn && bIn) {
|
||||
out.push(intersect(a, b));
|
||||
out.push(b);
|
||||
}
|
||||
if (maxD > tol) {
|
||||
const left = dpOpen(pts.slice(0, idx + 1), tol);
|
||||
const right = dpOpen(pts.slice(idx), tol);
|
||||
return left.concat(right.slice(1));
|
||||
}
|
||||
return out;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip an open subject ring against an axis-aligned (lon/lat) rectangle.
|
||||
* Edges are processed in order: left, bottom, right, top. Returns the clipped
|
||||
* open ring, or [] if empty.
|
||||
*/
|
||||
function clipRect(ring, [x0, y0, x1, y1]) {
|
||||
let poly = clipEdge(
|
||||
ring,
|
||||
(p) => p[0] >= x0,
|
||||
(a, b) => {
|
||||
const t = (x0 - a[0]) / (b[0] - a[0]);
|
||||
return [x0, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] >= y0,
|
||||
(a, b) => {
|
||||
const t = (y0 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y0];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[0] <= x1,
|
||||
(a, b) => {
|
||||
const t = (x1 - a[0]) / (b[0] - a[0]);
|
||||
return [x1, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] <= y1,
|
||||
(a, b) => {
|
||||
const t = (y1 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y1];
|
||||
},
|
||||
);
|
||||
return poly;
|
||||
/** Douglas–Peucker a CLOSED ring (first == last) at tolerance `tol` (0 = skip). */
|
||||
function dpRing(ring, tol) {
|
||||
if (tol <= 0 || ring.length < 3) return ring;
|
||||
let pts = ring;
|
||||
if (
|
||||
pts[0][0] === pts[pts.length - 1][0] &&
|
||||
pts[0][1] === pts[pts.length - 1][1]
|
||||
)
|
||||
pts = pts.slice(0, -1);
|
||||
const simplified = dpOpen(pts, tol);
|
||||
return [...simplified, [...simplified[0]]];
|
||||
}
|
||||
|
||||
// --- antimeridian handling ---------------------------------------------------------
|
||||
|
||||
/** True if the ring has an edge with |Δlon| > 180 (an antimeridian jump). */
|
||||
function hasAMJump(ring) {
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++)
|
||||
if (Math.abs(ring[i][0] - ring[(i + 1) % n][0]) > 180) return true;
|
||||
return false;
|
||||
/** Round (and optionally simplify) every ring of a Polygon/MultiPolygon geometry. */
|
||||
function transformGeom(geom, tol) {
|
||||
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||
const out = polys
|
||||
.map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : ring)))
|
||||
.filter((poly) => poly.length >= 1 && poly.every((ring) => ring.length >= 3));
|
||||
if (!out.length) return geom; // nothing survived simplification — keep the rounded original
|
||||
if (geom.type === 'Polygon') return { type: 'Polygon', coordinates: out[0] };
|
||||
return { type: 'MultiPolygon', coordinates: out };
|
||||
}
|
||||
|
||||
function crossesAntimeridian(geom) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity;
|
||||
for (const [lo] of allCoords(geom)) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
}
|
||||
return maxLon > 0 && minLon < 0 && maxLon - minLon > 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a full-360° wrap ring (one antimeridian jump, one prime-meridian
|
||||
* crossing) by two simple hemispheric "wedge" rings, each closed along the
|
||||
* prime meridian (x = 0), the pole (lat = −90), and the antimeridian
|
||||
* (x = ±180). Both wedges lie in standard −180..180 space, are disjoint, and
|
||||
* together cover exactly the original ring's enclosed region. Returns
|
||||
* [westRing, eastRing] (open rings), or null if the ring has an unexpected
|
||||
* shape (no wrap handling possible).
|
||||
*/
|
||||
function makeWedges(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return null;
|
||||
const jAM = [];
|
||||
const jPM = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[(i + 1) % n];
|
||||
if (Math.abs(a[0] - b[0]) > 180) jAM.push(i);
|
||||
else if (a[0] !== 0 && b[0] !== 0 && (a[0] < 0) !== (b[0] < 0)) jPM.push(i);
|
||||
}
|
||||
if (jAM.length !== 1 || jPM.length !== 1) return null;
|
||||
|
||||
const iAM = jAM[0];
|
||||
const a = ring[iAM]; // the +180 end of the jump
|
||||
const b = ring[(iAM + 1) % n]; // the −180 end of the jump
|
||||
if (Math.abs(a[0] - 180) > 1e-6 || Math.abs(b[0] + 180) > 1e-6) return null;
|
||||
|
||||
const iPM = jPM[0];
|
||||
const p = ring[iPM];
|
||||
const q = ring[(iPM + 1) % n];
|
||||
const t = (0 - p[0]) / (q[0] - p[0]);
|
||||
const P = [0, p[1] + (q[1] - p[1]) * t]; // the exact prime-meridian crossing
|
||||
|
||||
// Chain from b (−180) forward to P: everything in between must have lon ≤ 0.
|
||||
const west = [];
|
||||
for (let i = (iAM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] > 0) return null;
|
||||
west.push(pt);
|
||||
if (i === iPM) break;
|
||||
}
|
||||
west.push(P);
|
||||
// Chain from P forward to a (+180): everything in between must have lon ≥ 0.
|
||||
const east = [P];
|
||||
for (let i = (iPM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] < 0) return null;
|
||||
east.push(pt);
|
||||
if (i === iAM) break;
|
||||
}
|
||||
|
||||
const pole = (x) => [x, -90];
|
||||
// West wedge: P → pole along x=0 → pole along the pole → up the −180 side →
|
||||
// the western coast back to P. East wedge: the mirror image on x ≥ 0.
|
||||
const westWedge = dedupeRing([P, pole(0), pole(-180), b, ...west.slice(1)]);
|
||||
// The east chain q→…→a runs in *increasing* lon, so to trace the coast back
|
||||
// from a to q (keeping the wedge simple) we walk it in reverse.
|
||||
const eastWedge = dedupeRing([P, pole(0), pole(180), a, ...east.slice(1, -1).reverse()]);
|
||||
if (westWedge.length < 3 || eastWedge.length < 3) return null;
|
||||
return [westWedge, eastWedge];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an open ring (in shifted [0,360) space) at the antimeridian (x = 180).
|
||||
*
|
||||
* Instead of walking the ring between crossings (which breaks on jagged
|
||||
* coastlines that cross the antimeridian many times), clip the ring against
|
||||
* the two half-planes x ≤ 180 and x ≥ 180 with Sutherland–Hodgman. Each piece
|
||||
* is a proper closed-on-the-split-line polygon, the two pieces share only the
|
||||
* x = 180 boundary, and the split is area-preserving (the boundary has zero
|
||||
* area). Both pieces are returned in shifted space; the caller remaps any
|
||||
* x > 180 piece back into −180..0.
|
||||
*/
|
||||
function splitAtAM(ring) {
|
||||
if (ring.length < 3) return [];
|
||||
const cross = (a, b) => {
|
||||
const t = (180 - a[0]) / (b[0] - a[0]);
|
||||
return [180, a[1] + (b[1] - a[1]) * t];
|
||||
};
|
||||
const left = clipEdge(ring, (p) => p[0] <= 180, cross); // x in [0, 180]
|
||||
// Right piece: x in [180, 360). Remap EVERY vertex by -360 (including the
|
||||
// shared x = 180 boundary vertex → -180) so the piece sits fully on the west
|
||||
// side of the antimeridian with no ±180 jump inside the ring.
|
||||
const right = clipEdge(ring, (p) => p[0] >= 180, cross).map(
|
||||
([lo, la]) => [lo - 360, la],
|
||||
);
|
||||
const pieces = [];
|
||||
if (left.length >= 3) pieces.push(left);
|
||||
if (right.length >= 3) pieces.push(right);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// --- feature processing ------------------------------------------------------------
|
||||
|
||||
function makeFeature(id, name, parent, rings) {
|
||||
// Each ring is an INDEPENDENT piece (a separate island or a separate clipped
|
||||
// cell fragment), never a hole. GeoJSON: a MultiPolygon is an array of
|
||||
// polygons, each polygon an array of rings — so wrap every ring in its own
|
||||
// (single-ring) polygon. ([rings] would instead make ring 0 the outer and
|
||||
// every other ring a hole, swallowing e.g. Fiji's other islands.)
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
properties: { name, parent },
|
||||
geometry:
|
||||
rings.length === 1
|
||||
? { type: 'Polygon', coordinates: [rings[0]] }
|
||||
: { type: 'MultiPolygon', coordinates: rings.map((r) => [r]) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-country single region (no tiling, no sliver drops): all sub-polygons
|
||||
* in one feature, coordinates rounded. Used for tiny countries (n = 1) and
|
||||
* small antimeridian-crossing island groups.
|
||||
*/
|
||||
function wholeCountryFeature(feature) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const rings = polygons(feature.geometry).map((poly) =>
|
||||
closeRing(dedupeRing(poly[0]).map(([lo, la]) => [r4(lo), r4(la)])),
|
||||
);
|
||||
return [makeFeature(`${id}-01`, name, id, rings)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile the country's bbox with a cols × rows grid; each non-empty cell yields
|
||||
* one region feature (its kept clipped pieces as a Polygon/MultiPolygon).
|
||||
* `shifted` = true for large non-wrapping antimeridian countries (Russia):
|
||||
* work in [0, 360) longitude space, then split output rings at ±180°.
|
||||
*/
|
||||
function tileFeature(feature, rings, shifted) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const midLat = meanLat(feature.geometry);
|
||||
const area = rings.reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
|
||||
const n = Math.min(MAX_REGIONS, Math.max(1, Math.round(area / T)));
|
||||
if (n === 1) return wholeCountryFeature(feature);
|
||||
|
||||
const shiftLon = (lo) => (shifted && lo < 0 ? lo + 360 : lo);
|
||||
const work = rings.map((r) => r.map(([lo, la]) => [shiftLon(lo), la]));
|
||||
const bb = bbox(work);
|
||||
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
const wKm = (bb.maxLon - bb.minLon) * kx;
|
||||
const hKm = (bb.maxLat - bb.minLat) * KM_LAT;
|
||||
const S = Math.sqrt((wKm * hKm) / n);
|
||||
let cols = Math.max(1, Math.round(wKm / S));
|
||||
let rows = Math.max(1, Math.round(hKm / S));
|
||||
while (cols * rows > MAX_REGIONS) {
|
||||
if (cols >= rows) cols = Math.max(1, Math.floor(MAX_REGIONS / rows));
|
||||
else rows = Math.max(1, Math.floor(MAX_REGIONS / cols));
|
||||
}
|
||||
|
||||
const dLon = (bb.maxLon - bb.minLon) / cols;
|
||||
const dLat = (bb.maxLat - bb.minLat) / rows;
|
||||
|
||||
const out = [];
|
||||
let nn = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const yTop = bb.maxLat - r * dLat;
|
||||
const yBot = bb.maxLat - (r + 1) * dLat;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const xLeft = bb.minLon + c * dLon;
|
||||
const xRight = bb.minLon + (c + 1) * dLon;
|
||||
// One region per cell: clip every sub-ring, keep the pieces that clear
|
||||
// the sliver threshold, and merge them into a single feature.
|
||||
const cellPieces = [];
|
||||
for (const ring of work) {
|
||||
const clipped = clipRect(ring, [xLeft, yBot, xRight, yTop]);
|
||||
if (clipped.length < 3) continue;
|
||||
if (ringAreaKm2(clipped, midLat) < SLIVER_KM2) continue;
|
||||
// splitAtAM returns pieces already in standard −180..180 space (the
|
||||
// x > 180 half remapped), so no further per-vertex remap is needed.
|
||||
const pieces = shifted
|
||||
? splitAtAM(dedupeRing(clipped)).map((pc) => dedupeRing(pc))
|
||||
: [dedupeRing(clipped)];
|
||||
for (const pc of pieces)
|
||||
if (pc.length >= 3) cellPieces.push(pc.map(([lo, la]) => [r4(lo), r4(la)]));
|
||||
}
|
||||
if (!cellPieces.length) continue;
|
||||
const closed = cellPieces
|
||||
.map(dedupeRing)
|
||||
.filter((pc) => pc.length >= 3)
|
||||
.map(closeRing);
|
||||
if (!closed.length) continue;
|
||||
nn += 1;
|
||||
const nnStr = String(nn).padStart(2, '0');
|
||||
out.push(makeFeature(`${id}-${nnStr}`, `${name} R${nnStr}`, id, closed));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function processFeature(feature) {
|
||||
const geom = feature.geometry;
|
||||
const am = crossesAntimeridian(geom);
|
||||
if (am && featureAreaKm2(feature) <= BIG_KM2) {
|
||||
// Small antimeridian island group (Fiji, Kiribati, Tuvalu, …): one region.
|
||||
return wholeCountryFeature(feature);
|
||||
}
|
||||
// Wrapping rings (Antarctica) are already in standard space as wedges — no
|
||||
// shifting; non-wrapping antimeridian countries (Russia) are shifted.
|
||||
const rings = subjectRings(geom);
|
||||
const hasWrap = polygons(geom).some((p) => hasAMJump(dedupeRing(p[0])));
|
||||
const shifted = am && !hasWrap;
|
||||
return tileFeature(feature, rings, shifted);
|
||||
}
|
||||
|
||||
/** Total planar area of a feature's tiling rings (same convention as tileFeature). */
|
||||
function featureAreaKm2(feature) {
|
||||
const midLat = meanLat(feature.geometry);
|
||||
return subjectRings(feature.geometry).reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
}
|
||||
|
||||
// --- main ---------------------------------------------------------------------------
|
||||
|
||||
// --- main ----------------------------------------------------------------------
|
||||
function main() {
|
||||
const src = readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8');
|
||||
const countries = JSON.parse(src);
|
||||
const countries = JSON.parse(readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'));
|
||||
const admin1 = JSON.parse(readFileSync(INPUT, 'utf8'));
|
||||
const cset = new Set(countries.features.map((c) => c.id));
|
||||
|
||||
const features = [];
|
||||
const amSingletons = [];
|
||||
const amTiled = [];
|
||||
for (const f of countries.features) {
|
||||
if (crossesAntimeridian(f.geometry)) {
|
||||
(featureAreaKm2(f) <= BIG_KM2 ? amSingletons : amTiled).push(f.id);
|
||||
// Group admin-1 features by parent ISO_A3 (their `adm0_a3`), in file order,
|
||||
// keeping only parents that are one of our 177 countries.
|
||||
const byParent = new Map();
|
||||
for (const f of admin1.features) {
|
||||
const p = f.properties.adm0_a3;
|
||||
if (typeof p === 'string' && cset.has(p)) {
|
||||
if (!byParent.has(p)) byParent.set(p, []);
|
||||
byParent.get(p).push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the base specs (raw geometry + unique id + name + parent) in
|
||||
// countries.geo.json file order, then per-region source order — deterministic.
|
||||
const specs = [];
|
||||
const seenIds = new Set();
|
||||
const nextId = (base) => {
|
||||
let id = base;
|
||||
let k = 1;
|
||||
while (seenIds.has(id)) {
|
||||
k += 1;
|
||||
id = `${base}-${k}`;
|
||||
}
|
||||
seenIds.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
let fallbackCount = 0;
|
||||
const fallbackCountries = [];
|
||||
for (const c of countries.features) {
|
||||
const regs = byParent.get(c.id) ?? [];
|
||||
if (regs.length) {
|
||||
let nn = 0;
|
||||
for (const r of regs) {
|
||||
nn += 1;
|
||||
const iso = r.properties.iso_3166_2;
|
||||
const base = typeof iso === 'string' && iso ? iso : `${c.id}-${String(nn).padStart(2, '0')}`;
|
||||
const name = typeof r.properties.name === 'string' && r.properties.name ? r.properties.name : c.properties.name;
|
||||
specs.push({ id: nextId(base), name, parent: c.id, geom: r.geometry });
|
||||
}
|
||||
} else {
|
||||
fallbackCount += 1;
|
||||
fallbackCountries.push(c.id);
|
||||
specs.push({ id: nextId(`${c.id}-01`), name: c.properties.name, parent: c.id, geom: c.geometry });
|
||||
}
|
||||
}
|
||||
|
||||
const sortById = (a, b) => (a.properties.parent !== b.properties.parent ? (a.properties.parent < b.properties.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true }));
|
||||
|
||||
const build = (tol) => {
|
||||
const fc = {
|
||||
type: 'FeatureCollection',
|
||||
features: specs.map((s) => ({
|
||||
type: 'Feature',
|
||||
id: s.id,
|
||||
properties: { name: s.name, parent: s.parent },
|
||||
geometry: transformGeom(s.geom, tol),
|
||||
})),
|
||||
};
|
||||
fc.features.sort(sortById);
|
||||
return fc;
|
||||
};
|
||||
|
||||
// Base: 4 decimal places, no simplification.
|
||||
let fc = build(0);
|
||||
let json = JSON.stringify(fc);
|
||||
let sizeKB = Buffer.byteLength(json) / 1024;
|
||||
console.log(`base (4dp, no DP): ${sizeKB.toFixed(1)} KB (${fc.features.length} features)`);
|
||||
|
||||
// File-size guard: if the base is over the guard, simplify until it is under
|
||||
// the target (or we exhaust the tolerance table).
|
||||
let usedTol = 0;
|
||||
if (sizeKB > SIZE_GUARD_KB) {
|
||||
for (const t of DP_STEPS) {
|
||||
fc = build(t);
|
||||
json = JSON.stringify(fc);
|
||||
sizeKB = Buffer.byteLength(json) / 1024;
|
||||
usedTol = t;
|
||||
console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`);
|
||||
if (sizeKB < SIZE_TARGET_KB) break;
|
||||
}
|
||||
features.push(...processFeature(f));
|
||||
}
|
||||
// Sorted by (parent, nn) — the processing order already guarantees this, but
|
||||
// sort explicitly so the contract holds regardless of traversal changes.
|
||||
features.sort((a, b) => {
|
||||
if (a.properties.parent !== b.properties.parent)
|
||||
return a.properties.parent < b.properties.parent ? -1 : 1;
|
||||
return a.id.localeCompare(b.id, 'en', { numeric: true });
|
||||
});
|
||||
|
||||
const out = { type: 'FeatureCollection', features };
|
||||
const json = JSON.stringify(out);
|
||||
writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n');
|
||||
|
||||
// --- summary ----------------------------------------------------------------------
|
||||
// --- summary ----------------------------------------------------------------
|
||||
const counts = new Map();
|
||||
for (const f of features)
|
||||
counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
|
||||
for (const f of fc.features) counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
|
||||
const sortedCounts = [...counts.values()].sort((a, b) => a - b);
|
||||
const median =
|
||||
sortedCounts.length % 2
|
||||
? sortedCounts[Math.floor(sortedCounts.length / 2)]
|
||||
: (sortedCounts[sortedCounts.length / 2 - 1] + sortedCounts[sortedCounts.length / 2]) / 2;
|
||||
const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
|
||||
|
||||
// Max region area (planar approximation, same mid-latitude convention as the
|
||||
// tiling: the parent country's mean latitude).
|
||||
const midLatByParent = new Map(countries.features.map((f) => [f.id, meanLat(f.geometry)]));
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
for (const f of features) {
|
||||
const midLat = midLatByParent.get(f.properties.parent);
|
||||
let a = 0;
|
||||
for (const poly of polygons(f.geometry)) for (const ring of poly) a += ringAreaKm2(ring, midLat);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`regions: ${features.length} total (${countries.features.length} countries)`);
|
||||
console.log(`file: public/regions.geo.json — ${(Buffer.byteLength(json + '\n') / 1024).toFixed(1)} KB`);
|
||||
console.log(
|
||||
`count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]} per country`,
|
||||
);
|
||||
console.log(`max region: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
if (amSingletons.length) console.log(`AM → 1 region: ${amSingletons.join(', ')}`);
|
||||
if (amTiled.length) console.log(`AM → tiled (shifted/wedges): ${amTiled.join(', ')}`);
|
||||
const finalKB = Buffer.byteLength(json) / 1024;
|
||||
console.log(`\nwrote public/regions.geo.json — ${finalKB.toFixed(1)} KB (${(finalKB / 1024).toFixed(2)} MB)`);
|
||||
console.log(`features: ${fc.features.length} total across ${counts.size} of ${countries.features.length} countries`);
|
||||
console.log(`per-country count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]}`);
|
||||
console.log(`max-count country: ${maxCountry[0]} (${maxCountryName}) with ${maxCountry[1]}`);
|
||||
if (usedTol) console.log(`simplification: Douglas–Peucker @ ${usedTol}° (base was ${SIZE_GUARD_KB}+ KB)`);
|
||||
console.log(`whole-country fallback: ${fallbackCount} ${fallbackCount ? `(${fallbackCountries.join(', ')})` : '(none)'}`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
+449
-70
@@ -1,15 +1,38 @@
|
||||
/**
|
||||
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
|
||||
*
|
||||
* Self-contained (no import of the generator): it re-implements the generator's
|
||||
* exact planar area model so the assertions measure the same quantity the
|
||||
* generator used to size and clip regions:
|
||||
* The dataset is REAL named sub-national administrative regions (Natural
|
||||
* Earth admin-1: states, provinces, governorates, oblasts, …) that the
|
||||
* coarsening step (`scripts/coarsen-regions.mjs`, v0.14) has merged down to a
|
||||
* ~100×100 km (10,000 km²) floor: every small non-island region was dissolved
|
||||
* into its nearest same-country neighbor, so the only regions left below the
|
||||
* floor are islands (no same-country sibling nearby). One feature per named
|
||||
* region, each carrying `properties = { name, parent }` where `parent` is the
|
||||
* country's ISO_A3 (the RegionMap key the map resolves ownership through).
|
||||
* Tiny or uncovered countries collapse to a single whole-country region
|
||||
* (`<ISO_A3>-01`).
|
||||
*
|
||||
* v0.17 (`scripts/fill-gaps.mjs`) additionally fills the no-one's-land left
|
||||
* between merged sub-regions: per country, the uncovered slivers of the
|
||||
* 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)
|
||||
*
|
||||
* with a shoelace per outer ring, where each ring is measured at its own mean
|
||||
* latitude. Outer rings only — the generator never emits holes (every ring is an
|
||||
* independent piece: a separate island or a separate clipped cell fragment).
|
||||
* with a shoelace per OUTER ring, each ring measured at its own mean latitude.
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
@@ -21,7 +44,7 @@ import { describe, expect, it } from 'vitest';
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- area model (must match scripts/generate-regions.mjs exactly) -----------------
|
||||
// --- area model (planar approximation; matches scripts/generate-regions.mjs) --
|
||||
const KM_LON = 111.32;
|
||||
const KM_LAT = 110.574;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
@@ -59,7 +82,58 @@ function featureAreaKm2(geom: Geometry): number {
|
||||
}
|
||||
return total;
|
||||
}
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
function median(nums: number[]): number {
|
||||
if (!nums.length) return 0;
|
||||
const s = [...nums].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 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 {
|
||||
type: 'Feature';
|
||||
@@ -73,96 +147,293 @@ interface CountryFeature {
|
||||
properties: { name: string };
|
||||
geometry: Geometry;
|
||||
}
|
||||
interface FeatureCollection<T> {
|
||||
type: 'FeatureCollection';
|
||||
features: T[];
|
||||
}
|
||||
|
||||
// Read each file exactly once.
|
||||
const regions = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<RegionFeature>;
|
||||
) as { type: 'FeatureCollection'; features: RegionFeature[] };
|
||||
const countries = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<CountryFeature>;
|
||||
) as { type: 'FeatureCollection'; features: CountryFeature[] };
|
||||
|
||||
// Precompute areas once (O(total vertices)).
|
||||
// Precompute once (O(total vertices)).
|
||||
const countryIds = new Set(countries.features.map((c) => c.id));
|
||||
const countryArea = new Map<string, number>();
|
||||
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
|
||||
const regionArea = new Map<string, number>();
|
||||
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
|
||||
const regionSumByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
regionSumByParent.set(
|
||||
f.properties.parent,
|
||||
(regionSumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0),
|
||||
);
|
||||
}
|
||||
const sumByParent = new Map<string, number>();
|
||||
for (const f of regions.features)
|
||||
sumByParent.set(f.properties.parent, (sumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0));
|
||||
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);
|
||||
// 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) ------
|
||||
|
||||
interface LonInt {
|
||||
a: number;
|
||||
b: number;
|
||||
full: boolean;
|
||||
}
|
||||
interface RegionBBox {
|
||||
latMin: number;
|
||||
latMax: number;
|
||||
lon: LonInt;
|
||||
}
|
||||
|
||||
const COARSE_FLOOR_KM2 = 10_000; // the coarsener's ~100×100 km floor
|
||||
const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this)
|
||||
|
||||
/**
|
||||
* Unwrap a ring's longitudes to a continuous line (np.unwrap shortest-diff
|
||||
* scheme, anchored at the first vertex's [0,360) value). `full` when the
|
||||
* unwrapped span is ≥ 300° (a full-360° antimeridian band, e.g. Antarctica).
|
||||
* Must stay in sync with the coarsening script.
|
||||
*/
|
||||
function unwrapRing(ring: Ring) {
|
||||
const n = ring.length;
|
||||
const norm = (x: number) => ((x % 360) + 360) % 360;
|
||||
const u: number[] = new Array(n);
|
||||
u[0] = norm(ring[0][0]);
|
||||
for (let i = 1; i < n; i++) {
|
||||
// the previous UNWRAPPED value may lie outside [0,360); normalize it
|
||||
// before measuring the shortest signed difference, or a bogus ~180° jump
|
||||
// is inserted and the ring's span balloons.
|
||||
const a = norm(u[i - 1]);
|
||||
const b = norm(ring[i][0]);
|
||||
const d = (((b - a) % 360) + 540) % 360 - 180;
|
||||
u[i] = u[i - 1] + d;
|
||||
}
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const v of u) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { lons: u, full: max - min >= 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cyclic 1-D gap (degrees) between two tight longitude intervals in their own
|
||||
* unwrapped spaces; a `full` interval is close to everything.
|
||||
*/
|
||||
function lonGap(intA: LonInt, intB: LonInt): number {
|
||||
if (intA.full || intB.full) return 0;
|
||||
let best = Infinity;
|
||||
for (const k of [-1, 0, 1]) {
|
||||
const a2 = intB.a + 360 * k;
|
||||
const b2 = intB.b + 360 * k;
|
||||
const gap = intA.b < a2 ? a2 - intA.b : b2 < intA.a ? intA.a - b2 : 0;
|
||||
if (gap < best) best = gap;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Per-region bbox in antimeridian-safe unwrapped longitude space.
|
||||
const regionBBox = new Map<string, RegionBBox>();
|
||||
for (const f of regions.features) {
|
||||
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||
let latMin = Infinity;
|
||||
let latMax = -Infinity;
|
||||
let a = Infinity;
|
||||
let b = -Infinity;
|
||||
let full = false;
|
||||
for (const poly of polys) {
|
||||
const outer = poly[0];
|
||||
for (const [, la] of outer) {
|
||||
if (la < latMin) latMin = la;
|
||||
if (la > latMax) latMax = la;
|
||||
}
|
||||
const { lons, full: fl } = unwrapRing(outer);
|
||||
if (fl) full = true;
|
||||
for (const v of lons) {
|
||||
if (v < a) a = v;
|
||||
if (v > b) b = v;
|
||||
}
|
||||
}
|
||||
regionBBox.set(f.id, { latMin, latMax, lon: { a, b, full } });
|
||||
}
|
||||
|
||||
/** Bounding-box closeness (same definition the coarsening script uses). */
|
||||
function bboxesClose(x: RegionBBox, y: RegionBBox): boolean {
|
||||
const latGap = x.latMax < y.latMin ? y.latMin - x.latMax : y.latMax < x.latMin ? x.latMin - y.latMax : 0;
|
||||
if (latGap > CLOSE_DEG) return false;
|
||||
return lonGap(x.lon, y.lon) <= CLOSE_DEG;
|
||||
}
|
||||
|
||||
describe('regions.geo.json', () => {
|
||||
it('is a valid GeoJSON FeatureCollection', () => {
|
||||
it('is a valid GeoJSON FeatureCollection of well-formed features', () => {
|
||||
expect(regions.type).toBe('FeatureCollection');
|
||||
expect(Array.isArray(regions.features)).toBe(true);
|
||||
expect(regions.features.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every region is a well-formed feature with a valid id and a valid parent country', () => {
|
||||
const countryIds = new Set(countries.features.map((c) => c.id));
|
||||
const idRe = /^[A-Z]{3}-\d+$/;
|
||||
for (const f of regions.features) {
|
||||
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
|
||||
expect(idRe.test(f.id), `${f.id ?? '?'}: bad id`).toBe(true);
|
||||
expect(typeof f.id, `${f.id ?? '?'}: id must be a string`).toBe('string');
|
||||
expect(f.id.length, `${f.id ?? '?'}: empty id`).toBeGreaterThan(0);
|
||||
expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string');
|
||||
expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0);
|
||||
expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string');
|
||||
expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true);
|
||||
expect(f.properties.parent, `${f.id}: parent != ISO_A3 prefix`).toBe(f.id.slice(0, 3));
|
||||
const g = f.geometry;
|
||||
expect(
|
||||
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
|
||||
`${f.id}: bad geometry type ${g?.type}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every country 1..36 regions, and >=2 for any country > 50,000 km²', () => {
|
||||
for (const c of countries.features) {
|
||||
const n = countByParent.get(c.id) ?? 0;
|
||||
expect(n, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
|
||||
expect(n, `country ${c.id} has ${n} regions (> 36)`).toBeLessThanOrEqual(36);
|
||||
if ((countryArea.get(c.id) ?? 0) > 50_000) {
|
||||
expect(n, `large country ${c.id} should have >= 2 regions`).toBeGreaterThanOrEqual(2);
|
||||
if (g) {
|
||||
const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates;
|
||||
for (const poly of polys)
|
||||
for (const ring of poly)
|
||||
expect(ring.length, `${f.id}: ring with ${ring.length} pts`).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => {
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
it('has globally unique feature ids (ISO 3166-2 disambiguated on within-country collision)', () => {
|
||||
const seen = new Set<string>();
|
||||
const dups = new Set<string>();
|
||||
for (const f of regions.features) {
|
||||
if (seen.has(f.id)) dups.add(f.id);
|
||||
seen.add(f.id);
|
||||
}
|
||||
expect(
|
||||
dups.size,
|
||||
`duplicate ids: ${[...dups].slice(0, 10).join(', ')}`,
|
||||
).toBe(0);
|
||||
expect(seen.size).toBe(regions.features.length);
|
||||
});
|
||||
|
||||
it('covers every one of the 177 countries with at least one region', () => {
|
||||
const counts = countries.features.map((c) => countByParent.get(c.id) ?? 0);
|
||||
for (const c of countries.features) {
|
||||
expect(countByParent.get(c.id) ?? 0, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
const min = Math.min(...counts);
|
||||
const max = Math.max(...counts);
|
||||
const med = median(counts);
|
||||
const maxCountry = [...countByParent.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
|
||||
console.log(
|
||||
`\n per-country region count: min ${min} / median ${med} / max ${max}` +
|
||||
` (max: ${maxCountry[0]} ${maxCountryName} with ${maxCountry[1]})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('has sane region areas: median >= 100 km2, and no non-Antarctica region >= 5,000,000 km2', () => {
|
||||
const areas = regions.features.map((f) => regionArea.get(f.id) ?? 0);
|
||||
const med = median(areas);
|
||||
const maxRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) > (regionArea.get(m.id) ?? 0) ? f : m));
|
||||
const minRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) < (regionArea.get(m.id) ?? 0) ? f : m));
|
||||
const tiny = areas.filter((a) => a < 1).length;
|
||||
// Antarctica (ATA) is a full-360° wrap ring, so the planar model can't
|
||||
// measure it (it reports ~21.8M km2 for a ~14M km2 landmass) — exempt it.
|
||||
for (const f of regions.features) {
|
||||
if (f.properties.parent === 'ATA') continue;
|
||||
const a = regionArea.get(f.id) ?? 0;
|
||||
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km2 is absurdly large`).toBe(true);
|
||||
}
|
||||
expect(med, `median region area ${med.toFixed(1)} km2 is too small`).toBeGreaterThanOrEqual(100);
|
||||
console.log(
|
||||
`\n region area: min ${minRegion.id} (${minRegion.properties.name}) ≈ ${(regionArea.get(minRegion.id) ?? 0).toExponential(2)} km2` +
|
||||
` / median ${med.toLocaleString('en-US')} km2` +
|
||||
` / max ${maxRegion.id} (${maxRegion.properties.name}) ≈ ${Math.round(regionArea.get(maxRegion.id) ?? 0).toLocaleString('en-US')} km2` +
|
||||
` — ${tiny} regions < 1 km2 (legit micro-regions: tiny islands, small urban districts, degenerate disputed enclaves)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves only islands below the ~10,000 km2 coarsening floor (non-island small regions merged)', () => {
|
||||
// v0.14 coarsening: every region smaller than the floor that has a
|
||||
// same-country sibling within CLOSE_DEG of its bbox is dissolved into its
|
||||
// nearest such neighbor, so a below-floor region may only survive if it
|
||||
// is an island. Count the non-island residuals below the floor and allow
|
||||
// a small tolerance band (strictly < 1% of all regions) — the coarsener's
|
||||
// fixed point leaves none, and this catches a regression in the merge
|
||||
// logic (e.g. a broken neighbor test that exempts land regions) without
|
||||
// being brittle to one or two borderline cases.
|
||||
const smallNonIslands: string[] = [];
|
||||
let smallIslands = 0;
|
||||
let smallTotal = 0;
|
||||
for (const f of regions.features) {
|
||||
const a = regionArea.get(f.id) ?? 0;
|
||||
const parentArea = countryArea.get(f.properties.parent) ?? 0;
|
||||
// A whole-country singleton of a sub-200 km² microstate is exempt.
|
||||
expect(a >= 200 || parentArea < 200, `${f.id}: area ${a.toFixed(1)} km² is too small`).toBe(true);
|
||||
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km² is absurdly large`).toBe(true);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
if (a >= COARSE_FLOOR_KM2) continue;
|
||||
smallTotal++;
|
||||
const bb = regionBBox.get(f.id)!;
|
||||
let hasNeighbor = false;
|
||||
for (const g of regions.features) {
|
||||
if (g.id === f.id || g.properties.parent !== f.properties.parent) continue;
|
||||
if (bboxesClose(bb, regionBBox.get(g.id)!)) {
|
||||
hasNeighbor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNeighbor) {
|
||||
smallNonIslands.push(`${f.id} (${Math.round(a)} km2)`);
|
||||
} else smallIslands++;
|
||||
}
|
||||
console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
const frac = regions.features.length ? smallNonIslands.length / regions.features.length : 0;
|
||||
expect(
|
||||
frac < 0.01,
|
||||
`${smallNonIslands.length} non-island region(s) below the ${COARSE_FLOOR_KM2} km2 floor ` +
|
||||
`(${(100 * frac).toFixed(2)}% of all regions): ${smallNonIslands.slice(0, 10).join(', ')}`,
|
||||
).toBe(true);
|
||||
console.log(
|
||||
`\n below-floor regions: ${smallTotal} — ${smallIslands} islands (exempt, no same-country neighbor within ${CLOSE_DEG}°)` +
|
||||
` / ${smallNonIslands.length} non-island residuals (must stay < 1% of all regions)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('covers each country to within [45%, 108%] of its own area', () => {
|
||||
// The band is a planar-approximation sanity check. The measured range is
|
||||
// ~[64%, 105%]: Antarctica's hemispheric wedges under-fill to ~64%, and
|
||||
// Norway's fjord-heavy, high-latitude coast over-fills to ~105.1% under the
|
||||
// single-mid-latitude approximation. [45%, 108%] brackets that with margin
|
||||
// while still catching a genuinely missing or doubled-up region.
|
||||
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
|
||||
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
|
||||
// independently of the admin-0 country polygons (they are NOT clipped to
|
||||
// them). v0.17's gap fill removed the large UNDER-fills (the old minimum
|
||||
// was Brunei at ~52% — its regions now cover the landmass, ≈ 79%), but
|
||||
// the OVER-fill tail is a pre-existing admin-1 artifact: a few tiny island
|
||||
// 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[] = [];
|
||||
let minRatio = Infinity;
|
||||
let maxRatio = -Infinity;
|
||||
let minId = '';
|
||||
@@ -170,22 +441,130 @@ describe('regions.geo.json', () => {
|
||||
for (const c of countries.features) {
|
||||
const ca = countryArea.get(c.id) ?? 0;
|
||||
if (ca <= 0) continue;
|
||||
const ratio = (regionSumByParent.get(c.id) ?? 0) / ca;
|
||||
if (ratio < minRatio) {
|
||||
minRatio = ratio;
|
||||
minId = c.id;
|
||||
}
|
||||
if (ratio > maxRatio) {
|
||||
maxRatio = ratio;
|
||||
maxId = c.id;
|
||||
}
|
||||
const ratio = (sumByParent.get(c.id) ?? 0) / ca;
|
||||
ratios.push(ratio);
|
||||
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
|
||||
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
|
||||
expect(
|
||||
ratio >= 0.45 && ratio <= 1.08,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [45%, 108%]`,
|
||||
ratio >= 0.4 && ratio <= 1.9,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
const medRatio = median(ratios);
|
||||
expect(
|
||||
medRatio >= 0.7 && medRatio <= 1.3,
|
||||
`median coverage ${(100 * medRatio).toFixed(1)}% is outside [70%, 130%]`,
|
||||
).toBe(true);
|
||||
console.log(
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`,
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
|
||||
` / median ${(100 * medRatio).toFixed(1)}%` +
|
||||
` / 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();
|
||||
+42
-20
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRegionMap } from './map';
|
||||
import { nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
const state: GameState = {
|
||||
@@ -74,40 +74,62 @@ describe('regionOwner', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* #6 regression guard. The click/hover handlers resolve the owning nation from a
|
||||
* map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3
|
||||
* string) into a *numeric index* when it stores the source, so a feature the map
|
||||
* hands back at interaction time has a number for `id` and the ISO_A3 only lives
|
||||
* in `properties.regionId`. This test pins the exact data shape and proves the
|
||||
* fix: resolution via `properties.regionId` works, while the pre-fix path that
|
||||
* read `feature.id` does not.
|
||||
* #6 regression guard (sub-national map). The click/hover handlers resolve the
|
||||
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
|
||||
* source's top-level `id` into a *numeric index* when it stores the source, so a
|
||||
* feature the map hands back at interaction time has a number for `id`. The
|
||||
* choropleth is now sub-national: a feature's id is a sub-region code
|
||||
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
|
||||
* client stamps into `properties.parent` (the RegionMap is keyed by that
|
||||
* country code, never the sub-region id). This pins the exact data shape and
|
||||
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
|
||||
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
|
||||
* `regionId`, which is not a RegionMap key) do not.
|
||||
*/
|
||||
describe('#6 — owner must be resolved from properties.regionId, not the repacked feature.id', () => {
|
||||
// The shape MapLibre returns for a source feature after setData(): `id` is a
|
||||
// number (here 0, as observed in the live map), and the ISO_A3 is preserved
|
||||
// only in properties (the client stamps it as `regionId`).
|
||||
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.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 —
|
||||
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
|
||||
const repackedFeature = {
|
||||
id: 0,
|
||||
properties: { name: 'DE', regionId: 'DEU' },
|
||||
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
|
||||
};
|
||||
|
||||
it('resolves the owner from properties.regionId (the fix)', () => {
|
||||
const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : '';
|
||||
expect(regionOwner(regions, code)).toEqual({
|
||||
it('resolves the owner from properties.parent (the fix)', () => {
|
||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
ownerId: 'de',
|
||||
});
|
||||
});
|
||||
|
||||
it('the old path (feature.id as a string key) does NOT resolve — the bug', () => {
|
||||
// String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed
|
||||
// and the click popup reported "No owner" even for owned regions.
|
||||
expect(regions[String(repackedFeature.id)]).toBeUndefined();
|
||||
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'.
|
||||
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
|
||||
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
|
||||
// reported "No owner" even for owned regions.
|
||||
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFeatureOwner', () => {
|
||||
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
|
||||
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a parent country nobody owns', () => {
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nationColor', () => {
|
||||
it('is deterministic per id and distinct across different ids', () => {
|
||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||
|
||||
+33
-12
@@ -57,20 +57,41 @@ export interface RegionOwner {
|
||||
export type RegionMap = Record<string, RegionOwner>;
|
||||
|
||||
/**
|
||||
* Look up the owner record for a map region from the authoritative RegionMap by
|
||||
* its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned
|
||||
* region. Pure + shared: the client resolves a clicked or hovered map feature by
|
||||
* first reading the feature's stable ISO_A3 code (stored in `properties.regionId`
|
||||
* by the client when it repacks the source — see WorldMap) and then calling this.
|
||||
* 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 LLM stay at country granularity — see #16), so `key` is a country code such
|
||||
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
|
||||
* empty/missing key or an unowned country.
|
||||
*
|
||||
* The code must NOT be taken from the feature's top-level `id`: MapLibre repacks
|
||||
* a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores
|
||||
* the source, so at click/hover time `feature.id` is a number, never the ISO_A3
|
||||
* — resolving from it is exactly the "no owner" bug (#6).
|
||||
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
||||
* which reads the feature's `properties.parent` — never the repacked top-level
|
||||
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
|
||||
* the sub-region id.
|
||||
*/
|
||||
export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined {
|
||||
if (!isoA3) return undefined;
|
||||
return regions[isoA3];
|
||||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||
if (!key) return undefined;
|
||||
return regions[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the owning-nation record for a **rendered map feature** from the
|
||||
* authoritative RegionMap. The choropleth renders sub-national regions, but the
|
||||
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
|
||||
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
|
||||
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
|
||||
* `regionOwner`. This is the single shared source of truth for that resolution,
|
||||
* so the "use `parent`, never the sub-region `id`/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.
|
||||
*/
|
||||
export function mapFeatureOwner(
|
||||
regions: RegionMap,
|
||||
feature: { properties?: { parent?: unknown } } | null | undefined,
|
||||
): RegionOwner | undefined {
|
||||
const parent = feature?.properties?.parent;
|
||||
return regionOwner(regions, typeof parent === 'string' ? parent : '');
|
||||
}
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
|
||||
+26
-2
@@ -589,6 +589,17 @@ function GameScreen({
|
||||
}: GameScreenProps) {
|
||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||
// 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
|
||||
// dossier. Cleared when the user picks a nation via the manual Inspect select.
|
||||
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
|
||||
const focusNation = useCallback(
|
||||
(id: string, info?: { parent?: string; name?: string }) => {
|
||||
setViewingId(id);
|
||||
setSelectedRegion(info ?? null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const player = game.nations[game.playerNationId];
|
||||
const viewed = game.nations[viewingId] ?? player;
|
||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -641,7 +652,7 @@ function GameScreen({
|
||||
playerNationId={game.playerNationId}
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
onFocusNation={focusNation}
|
||||
/>
|
||||
|
||||
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
||||
@@ -747,7 +758,14 @@ function GameScreen({
|
||||
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Inspect
|
||||
</div>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<Select
|
||||
value={viewingId}
|
||||
onValueChange={(id) => {
|
||||
// A manual pick supersedes a map-click selection: clear the label.
|
||||
setViewingId(id);
|
||||
setSelectedRegion(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="inspect-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -759,6 +777,12 @@ function GameScreen({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRegion && (
|
||||
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
|
||||
Selected: {selectedRegion.name ?? selectedRegion.parent ?? 'region'}
|
||||
{selectedRegion.parent ? ` (${selectedRegion.parent})` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
+149
-92
@@ -8,11 +8,14 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||
maplibregl.setWorkerUrl(workerUrl);
|
||||
import { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||
import type { Nation } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
|
||||
// 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
|
||||
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
|
||||
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
|
||||
interface GeoFeature {
|
||||
type: 'Feature';
|
||||
id?: string | number;
|
||||
@@ -27,7 +30,7 @@ type GeoSource = maplibregl.GeoJSONSource;
|
||||
|
||||
// Inline basemap style — a visible dark-navy background plus a subtle
|
||||
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
|
||||
// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn
|
||||
// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn
|
||||
// on top and is the real star.
|
||||
//
|
||||
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
|
||||
@@ -69,11 +72,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||||
],
|
||||
};
|
||||
|
||||
const COUNTRIES_SOURCE = 'countries';
|
||||
const REGIONS_SOURCE = 'regions';
|
||||
const HOVER_SOURCE = 'hover';
|
||||
const FLASH_SOURCE = 'flash';
|
||||
const FILL_LAYER = 'countries-fill';
|
||||
const LINE_LAYER = 'countries-line';
|
||||
const FILL_LAYER = 'regions-fill';
|
||||
const LINE_LAYER = 'regions-line';
|
||||
const PLAYER_LINE = 'player-line';
|
||||
const FLASH_LINE = 'flash-line';
|
||||
const HOVER_FILL = 'hover-fill';
|
||||
@@ -97,29 +100,39 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the current ownership + colors onto a clone of the base geo. Each region
|
||||
* carries its owner's stable fill color, a subtle stroke, and a flag for the
|
||||
* player's own regions (rendered as a brighter, thicker border by a dedicated
|
||||
* layer). Unknown owners fall back to a faint neutral fill — never an error.
|
||||
* 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
|
||||
* owns whole countries, so every sub-region of a country shares that country's
|
||||
* owning nation's color — the map looks like the old country choropleth, but
|
||||
* subdivided into finer cells. Each sub-region therefore carries:
|
||||
*
|
||||
* Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is
|
||||
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
|
||||
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
|
||||
* stores it, so a later click/hover event exposes that number on
|
||||
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
|
||||
* interaction time (the root cause of #6). `properties`, however, survive into the
|
||||
* rendered features, so the interaction handlers resolve the owning nation from
|
||||
* `properties.regionId` via the RegionMap (see `regionOwner`).
|
||||
* - `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
|
||||
* (the top-level `id` is repacked into a numeric index, #6);
|
||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
|
||||
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
|
||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
|
||||
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
||||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||||
* player-highlight border).
|
||||
*
|
||||
* Crucially, the owner lookup keys the RegionMap by `properties.parent` (the
|
||||
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
|
||||
* `FRA-03` entries, only `FRA`.
|
||||
*/
|
||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||
for (const f of out.features) {
|
||||
f.properties = f.properties ?? {};
|
||||
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
|
||||
// survives into the features MapLibre hands back on click/hover.
|
||||
const code = f.id == null ? '' : String(f.id);
|
||||
f.properties.regionId = code;
|
||||
const r = code ? regions[code] : undefined;
|
||||
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||||
// survives into the rendered features (the top-level `id` gets repacked).
|
||||
const subId = f.id == null ? '' : String(f.id);
|
||||
f.properties.regionId = subId;
|
||||
// Ownership lives at the parent country: resolve `properties.parent`
|
||||
// (the ISO_A3) against the RegionMap. Never the sub-region id.
|
||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
||||
f.properties.parent = parent;
|
||||
const r = parent ? regions[parent] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
@@ -137,10 +150,18 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */
|
||||
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): FeatureCollection {
|
||||
const set = new Set(codes);
|
||||
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
|
||||
/**
|
||||
* Build a FeatureCollection of just the sub-regions whose **parent** country is
|
||||
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
|
||||
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
|
||||
* must light up **all** of its sub-regions, not a single feature.
|
||||
*/
|
||||
function featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
|
||||
const set = new Set(parents);
|
||||
const features = geo.features.filter((f) => {
|
||||
const parent = f.properties?.parent;
|
||||
return typeof parent === 'string' && set.has(parent);
|
||||
});
|
||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||
}
|
||||
|
||||
@@ -152,14 +173,20 @@ function statRow(label: string, value?: number): string {
|
||||
return `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">${Math.round(value)}</span></div>`;
|
||||
}
|
||||
|
||||
/** Compact hover tooltip: country, its owner (color-chipped), and 1–2 key stats. */
|
||||
function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
|
||||
/**
|
||||
* Compact hover tooltip: the sub-region's name, its parent country (ISO_A3), its
|
||||
* owner (color-chipped), and 1–2 key stats. When the parent country has no
|
||||
* owning nation, a minimal "No owner" card is shown instead.
|
||||
*/
|
||||
function hoverHtml(subRegionName: string, parentCode: string, region?: RegionOwner, nation?: Nation): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || parentCode || '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 sub = parentCode ? `<div class="hm-popup-sub">${esc(parentCode)}</div>` : '';
|
||||
return `<div class="hm-popup">
|
||||
<div class="hm-popup-title">${esc(countryName || 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-stats">${stats}</div>
|
||||
</div>`;
|
||||
@@ -174,27 +201,38 @@ interface Props {
|
||||
presetId: string;
|
||||
className?: string;
|
||||
/**
|
||||
* Called with a region's owning-nation id when that region is clicked, so the
|
||||
* dossier switches to that nation. Not called for unowned regions (the dossier
|
||||
* keeps its current target). See the component doc for the full behavior.
|
||||
* 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
|
||||
* country's owning nation. The optional `info` carries the clicked sub-region's
|
||||
* own id/name and its parent country's ISO_A3, for a small "which region" label
|
||||
* in the dossier. Not called for unowned regions (the dossier keeps its
|
||||
* current target). See the component doc for the full behavior.
|
||||
*/
|
||||
onFocusNation?: (id: string) => void;
|
||||
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
||||
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
||||
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
||||
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
|
||||
* border for the player, a hover tooltip (owner + key stats), a **click that
|
||||
* switches the dossier to the region's owning nation** (no popup — see #7), a
|
||||
* transient flash on territory changes, and a per-scenario initial camera.
|
||||
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
||||
* 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
|
||||
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
|
||||
* nation owns whole countries, so all of a country's sub-regions share that
|
||||
* country's owning-nation color. Rendered as a fixed, full-viewport background
|
||||
* layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle
|
||||
* terrain, per-nation stable fills (uniform per country, the choropleth is the
|
||||
* star), thin sub-region borders, a brighter/thicker amber border for the
|
||||
* player's countries, a hover tooltip (sub-region name + parent country + owner
|
||||
* + key stats), a **click that switches the dossier to the sub-region's parent
|
||||
* country's owning nation** (no popup — see #7), a transient flash when a
|
||||
* country's owner changes (lighting up all its sub-regions), and a per-scenario
|
||||
* initial camera.
|
||||
*
|
||||
* Ownership for a hovered/clicked region is resolved from the authoritative
|
||||
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
|
||||
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
|
||||
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
|
||||
* Unowned regions (no RegionMap entry) simply do not change the dossier.
|
||||
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
|
||||
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
||||
* `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
|
||||
* RegionMap entry for the parent) simply do not change the dossier.
|
||||
*
|
||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||
* — no floating control and no external API.
|
||||
@@ -255,9 +293,9 @@ export default function WorldMap({
|
||||
const loadGeo = (): Promise<FeatureCollection> =>
|
||||
baseGeoRef.current
|
||||
? Promise.resolve(baseGeoRef.current)
|
||||
: fetch('/countries.geo.json')
|
||||
: fetch('/regions.geo.json')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
|
||||
if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((g: FeatureCollection) => {
|
||||
@@ -268,12 +306,14 @@ export default function WorldMap({
|
||||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
||||
// to zero over a few seconds and self-clean. Idempotent per call.
|
||||
// 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
|
||||
// up all of that country's sub-regions.
|
||||
function animateFlash(codes: string[]) {
|
||||
const geo = baseGeoRef.current;
|
||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||
if (!geo || !src || cancelled) return;
|
||||
src.setData(featuresByCodes(geo, codes));
|
||||
src.setData(featuresByParents(geo, codes));
|
||||
if (!map.getLayer(FLASH_LINE)) {
|
||||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||||
// hover highlight when that layer exists; otherwise just append.
|
||||
@@ -306,33 +346,34 @@ export default function WorldMap({
|
||||
}
|
||||
flashApiRef.current = animateFlash;
|
||||
|
||||
// The region a hovered/clicked feature belongs to is identified by its stable
|
||||
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
|
||||
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
|
||||
// source's `id` into a numeric index when it stores the source, so the value
|
||||
// exposed on `e.features[i].id` at interaction time is a number, never the
|
||||
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
|
||||
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||||
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||||
// and its **owner** is resolved from the authoritative RegionMap via the
|
||||
// feature's `properties.parent` (the parent country's ISO_A3) through
|
||||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||
// index, #6) and never from the sub-region id (not a RegionMap key).
|
||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { name?: string; regionId?: string };
|
||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
if (code && code !== lastHoverId) {
|
||||
lastHoverId = code;
|
||||
const p = f.properties as { name?: string; regionId?: string; parent?: string };
|
||||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
const parent = typeof p?.parent === 'string' ? p.parent : '';
|
||||
if (subId && subId !== lastHoverId) {
|
||||
lastHoverId = subId;
|
||||
const geo = baseGeoRef.current;
|
||||
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
|
||||
// that to recover the feature for the hover-highlight outline (the
|
||||
// feature MapLibre hands us has a repacked numeric `id` we can't use).
|
||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
|
||||
// The base GeoJSON feature's top-level `id` IS the sub-region id, so
|
||||
// match on it to recover the exact feature for the hover-highlight
|
||||
// outline (the feature MapLibre hands us has a repacked numeric `id`).
|
||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId);
|
||||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
||||
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
||||
);
|
||||
}
|
||||
const region = regionOwner(regionsRef.current, code);
|
||||
const region = mapFeatureOwner(regionsRef.current, f);
|
||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? code, region, nation))
|
||||
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
@@ -345,17 +386,24 @@ export default function WorldMap({
|
||||
};
|
||||
|
||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||
// owning nation. The owner is resolved from the stable ISO_A3 in
|
||||
// `properties.regionId` via the RegionMap (never the repacked top-level
|
||||
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
|
||||
// leaves the current dossier target unchanged — never a broken dossier.
|
||||
// owning nation. For a sub-region, the owner is the **parent country's**
|
||||
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
|
||||
// RegionMap (never the repacked top-level `f.id`, #6, and never the
|
||||
// sub-region id). A sub-region whose parent country has no owner leaves the
|
||||
// current dossier target unchanged — never a broken dossier.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { regionId?: string };
|
||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
const region = regionOwner(regionsRef.current, code);
|
||||
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
||||
const region = mapFeatureOwner(regionsRef.current, f);
|
||||
if (!region?.ownerId) return;
|
||||
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
|
||||
// the dossier can show which region was selected.
|
||||
const p = f.properties as { regionId?: string; parent?: string; name?: string };
|
||||
const info = {
|
||||
parent: typeof p?.parent === 'string' ? p.parent : undefined,
|
||||
name: typeof p?.name === 'string' ? p.name : undefined,
|
||||
};
|
||||
onFocusNationRef.current?.(region.ownerId, info);
|
||||
};
|
||||
|
||||
// Idempotent: (re)creates the sources + layers after any style (re)load and
|
||||
@@ -363,16 +411,16 @@ export default function WorldMap({
|
||||
// automatically, so no setWorkerUrl is needed.)
|
||||
//
|
||||
// The three choropleth sources are created up front (empty until the base
|
||||
// geo arrives) so the layer structure always exists; `paintCountries` then
|
||||
// stamps ownership + colors onto the countries source. This makes it immune
|
||||
// geo arrives) so the layer structure always exists; `paintRegions` then
|
||||
// stamps ownership + colors onto the regions source. This makes it immune
|
||||
// to load-order races: if `ensureLayers` runs before the geo fetch
|
||||
// resolves, the data is applied the moment the fetch lands; if the geo
|
||||
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
|
||||
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
|
||||
// regions effect re-paints whenever ownership changes.
|
||||
const ensureSources = () => {
|
||||
if (!map.getSource(COUNTRIES_SOURCE)) {
|
||||
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||
if (!map.getSource(REGIONS_SOURCE)) {
|
||||
map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||
}
|
||||
if (!map.getSource(HOVER_SOURCE)) {
|
||||
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||
@@ -382,10 +430,10 @@ export default function WorldMap({
|
||||
}
|
||||
};
|
||||
|
||||
// Stamp ownership + colors onto the countries source (no-op until geo loads).
|
||||
const paintCountries = () => {
|
||||
// Stamp ownership + colors onto the regions source (no-op until geo loads).
|
||||
const paintRegions = () => {
|
||||
const geo = baseGeoRef.current;
|
||||
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
|
||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||
if (!geo || !src) return;
|
||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
||||
};
|
||||
@@ -394,32 +442,39 @@ export default function WorldMap({
|
||||
if (cancelled) return;
|
||||
ensureSources();
|
||||
if (baseGeoRef.current) {
|
||||
paintCountries();
|
||||
paintRegions();
|
||||
} else {
|
||||
try {
|
||||
await loadGeo();
|
||||
} catch (err) {
|
||||
console.error('failed to load countries geojson', err);
|
||||
console.error('failed to load regions geojson', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
paintCountries();
|
||||
paintRegions();
|
||||
}
|
||||
if (!map.getLayer(FILL_LAYER)) {
|
||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||
// colors read clearly even on the dark background / over the terrain.
|
||||
// Every sub-region carries its parent country's owner color, so a whole
|
||||
// country reads as one uniform fill — same as the old country map, but
|
||||
// subdivided.
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: COUNTRIES_SOURCE,
|
||||
source: REGIONS_SOURCE,
|
||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(LINE_LAYER)) {
|
||||
// Thin internal borders: every sub-region outline at a light weight so
|
||||
// the subdivision reads within each country's uniform fill. The per-
|
||||
// feature `stroke` alpha keeps owned borders a touch brighter than
|
||||
// unowned ones (the v0.10 treatment, thinned for the finer grid).
|
||||
map.addLayer({
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, 'line-opacity': 0.95 },
|
||||
source: REGIONS_SOURCE,
|
||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.75, 'line-opacity': 0.95 },
|
||||
});
|
||||
}
|
||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||
@@ -430,7 +485,7 @@ export default function WorldMap({
|
||||
map.addLayer({
|
||||
id: PLAYER_LINE,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
source: REGIONS_SOURCE,
|
||||
filter: ['==', ['get', 'isPlayer'], 1],
|
||||
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
|
||||
});
|
||||
@@ -482,8 +537,10 @@ export default function WorldMap({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-color the choropleth whenever ownership changes, and flash any regions
|
||||
// whose owner changed since the last update (transient, self-cleaning).
|
||||
// Re-color the choropleth whenever ownership changes, and flash any country
|
||||
// (all of its sub-regions) whose owner changed since the last update (a
|
||||
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
|
||||
// `animateFlash` expands each changed country to every one of its sub-regions.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const geo = baseGeoRef.current;
|
||||
@@ -494,7 +551,7 @@ export default function WorldMap({
|
||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||
|
||||
if (map && geo) {
|
||||
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
|
||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user