Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa1bd3c5aa | ||
|
|
f6ad554823 | ||
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 |
@@ -28,13 +28,17 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
|
|||||||
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
|
- 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)
|
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
||||||
choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
choropleth from `public/regions.geo.json` (1,800 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||||
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
||||||
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
||||||
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
||||||
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
||||||
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
||||||
function-calling tool) instead of whole countries.
|
function-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK
|
||||||
|
mega-region is split into the mainland (`RU-ARK`, key unchanged) plus the New Earth
|
||||||
|
(`RU-ARK-NE`) and Severnaya Zemlya (`RU-ARK-SZ`) island groups; since v0.26 (#41) the
|
||||||
|
mainland's 28-part Arctic-coast band is further re-partitioned into Pechora (`RU-PEO`)
|
||||||
|
and Yamal (`RU-YAM`) (the 5-part lat < 68 core stays `RU-ARK`/`arkhangel-sk-lhbk`).
|
||||||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
||||||
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
||||||
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||||||
@@ -52,18 +56,19 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 151 tests)
|
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 168 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
||||||
│ ├── regions.geo.json # 1,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||||
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
|
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
||||||
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
|
│ └── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
│ │ # fill-gaps → build-region-keys → build-region-adjacency
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
|
│ │ # → build-region-adjacency
|
||||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
@@ -110,10 +115,12 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||||
| `npm test` | vitest unit tests (`server/` + `scripts/`; 151 tests) |
|
| `npm test` | vitest unit tests (`server/` + `scripts/`; 168 tests) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `npm run lint` | ESLint flat config |
|
||||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||||
|
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
|
||||||
|
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||||
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||||
|
|
||||||
@@ -188,7 +195,22 @@ history/
|
|||||||
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
||||||
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||||||
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||||||
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
|
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
|
||||||
|
(#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
|
||||||
|
`arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
|
||||||
|
Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
|
||||||
|
`severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
|
||||||
|
`fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
|
||||||
|
region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
|
||||||
|
Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
|
||||||
|
Since v0.26 (#41) the mainland's 28-part Arctic-coast band (centroid lat ≥ 68) is further
|
||||||
|
re-partitioned by the idempotent `scripts/split-arkhangel-band.mjs` stage (run between
|
||||||
|
`split:regions` and `build:region-keys`, no-op once applied) into Pechora (`RU-PEO`,
|
||||||
|
`pechora-qwhs`, 13 parts) and Yamal (`RU-YAM`, `yamal-gosw`, 15 parts) — a pure re-partition
|
||||||
|
(no geometry moved → the union is byte-identical) that keeps `RU-ARK`'s id/name/key
|
||||||
|
`arkhangel-sk-lhbk` stable and inserts the two new features immediately after `RU-ARK-SZ`; the
|
||||||
|
5-part lat < 68 core stays `RU-ARK` (a single ~305k km² polygon — splitting it needs turf
|
||||||
|
clipping, not a pure re-partition).
|
||||||
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
|
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
|
||||||
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
|
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
|
||||||
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-10-14 19:24
|
||||||
|
- **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into:
|
||||||
|
- `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border).
|
||||||
|
- `RU-YAM` — Yamal (15 parts, ~51,722 km²) → new region key `yamal-gosw`. The northeastern band (Yamal peninsula).
|
||||||
|
- The 5 remaining lat < 68 parts (the ~305,229 km² inland core) **stay `RU-ARK` and keep its id, name, and region key `arkhangel-sk-lhbk`** — ownership-safe, mirroring the #31 precedent. Splitting that core further requires geometry clipping (a turf-op), not a pure re-partition, and is left as a follow-up issue.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel-band.mjs` (`npm run split:regions:band`), run between `split:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It asserts the mainland is a 33-part MultiPolygon, selects the band as the 28 parts with centroid lat ≥ 68 (exactly 28, or it fails loudly), picks the two largest-area band parts as Voronoi anchors (the smaller-longitude one, `idx 1`, anchors the Pechora side), and assigns each band part to the nearer anchor by km-weighted centroid distance `hypot(Δlon·111.32·cos(midLat), Δlat·110.574)` (ties go west). The resulting partition is exactly PEO={1,4,5,9,11,12,13,14,16,17,18,20,22}, YAM={2,6,15,19,21,23,24,25,26,27,28,29,30,31,32}, core={0,3,7,8,10}. The two new features are inserted **immediately after `RU-ARK-SZ`** (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-PEO` | `RU-YAM` | `RU-AST` | …).
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,800 entries each** (was 1,798); adjacency pairs 4,471 → 4,473. The new pairs are `pechora-qwhs ↔ yamal-gosw`, `pechora-qwhs ↔ nenets-qyga`, and `yamal-gosw ↔ severnaya-zemlya-awqp`, while the mainland `arkhangel-sk-lhbk` **loses** its `severnaya-zemlya-awqp` pair (the islets now touch the Yamal band, not the inland core) and keeps its six real neighbours (`karelia-ohem`, `kirov-gfvm`, `komi-eoeh`, `murmansk-etas`, `nenets-qyga`, `vologda-ynvr`). `new-earth-eikt` stays isolated (empty adjacency).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,798 → 1,800): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after — only the feature grouping changes. The RU-ARK family total stays 78 parts (mainland 5 / NE 42 / SZ 3 / PEO 13 / YAM 15).
|
||||||
|
- **Tests (168, count unchanged — existing cases updated, none added).** `scripts/regions.test.ts` (dataset is now 1,800 features; the Arkhangel feature-pin test now covers all five family members), `scripts/region-keys.test.ts` (1,800 keys; pins the unchanged `arkhangel-sk-lhbk` plus the two new keys), `scripts/region-adjacency.test.ts` (1,800 entries; pins the new PEO/YAM/SZ adjacency and the mainland's loss of the SZ pair), `server/regions.test.ts` (1,800; the RUS country expansion is now **84** sub-regions including all five keys — was 82 after #31). No e2e assertion references the region counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra band regions (82 → 84 sub-regions); the LLM's reference table and the player's frontier now include `pechora-qwhs` and `yamal-gosw`.
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
|
## v0.25.0 — 2026-10-14 17:53
|
||||||
|
- **#31: the `RU-ARK` mega-region is split into a mainland region plus two island groups** — a pure re-grouping of the existing polygon parts: no new geometry, no coordinate changes. The old single feature (Arkhangel'sk Oblast + the Arctic archipelagos, 78 parts, ~410,187 km²) becomes three features inserted immediately after `RU-ARK` (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-AST`):
|
||||||
|
- `RU-ARK` — the mainland (33 parts, ~393,256 km²); it **keeps its existing id and region key `arkhangel-sk-lhbk`**, so existing `Nation.territory` lists and SQLite ownership are untouched (ownership-safe).
|
||||||
|
- `RU-ARK-NE` — New Earth / Franz Josef Land (42 parts, ~15,721 km²) → new region key `new-earth-eikt`. Genuinely isolated (≥100 km from any land), so its adjacency list is **empty**.
|
||||||
|
- `RU-ARK-SZ` — Severnaya Zemlya (3 parts, ~1,210 km²) → new region key `severnaya-zemlya-awqp`. Its islet rings share boundary vertices with the coarsened mainland ring (a contact that pre-existed inside the single pre-split feature — no coordinate change), so it records **one** adjacency pair (SZ ↔ `arkhangel-sk-lhbk`). This deviates from the original "both islands isolated" expectation and is recorded as-is by the manifest's "shares at least one point" definition.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel.mjs` (`npm run split:regions`), run between `fill:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It classifies each part by centroid (turf — allowed in `.mjs` only): main = lat < 76 (plus any 76–79.5° band parts west of 55°E — none exist), Severnaya Zemlya = 76 ≤ lat < 79.5 AND lon ≥ 55, New Earth = lat ≥ 79.5. It fails loudly on a mis-split, a missing feature, or a non-MultiPolygon geometry.
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,798 entries each** (was 1,796); adjacency pairs 4,470 → 4,471 (the one new pair is the SZ ↔ mainland contact; the mainland keeps its 6 real neighbours).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,796 → 1,798): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after the split.
|
||||||
|
- **Tests (164 → 168).** `scripts/regions.test.ts` (dataset is now 1,798 features; a new test pins the three Arkhangel features and their part counts), `scripts/region-keys.test.ts` (1,798 keys; a new test pins the unchanged main key plus the two new keys), `scripts/region-adjacency.test.ts` (1,798 entries; a new island-adjacency test: New Earth empty, Severnaya Zemlya = [mainland], mainland's neighbour list extended by exactly the SZ pair), `server/regions.test.ts` (1,798; the RUS country expansion is now **82** sub-regions including all three keys — was 80). No e2e assertion references the old counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra island regions (80 → 82 sub-regions).
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
## v0.24.0 — 2026-10-14 13:41
|
## v0.24.0 — 2026-10-14 13:41
|
||||||
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
|
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
|
||||||
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
|
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.24.0",
|
"version": "0.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||||
"smooth:regions": "node scripts/smooth-regions.mjs",
|
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||||
"fill:regions": "node scripts/fill-gaps.mjs",
|
"fill:regions": "node scripts/fill-gaps.mjs",
|
||||||
|
"split:regions": "node scripts/split-arkhangel.mjs",
|
||||||
|
"split:regions:band": "node scripts/split-arkhangel-band.mjs",
|
||||||
"build:region-keys": "node scripts/build-region-keys.mjs",
|
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -7587,6 +7587,7 @@
|
|||||||
"nenets-qyga": [
|
"nenets-qyga": [
|
||||||
"arkhangel-sk-lhbk",
|
"arkhangel-sk-lhbk",
|
||||||
"komi-eoeh",
|
"komi-eoeh",
|
||||||
|
"pechora-qwhs",
|
||||||
"yamal-nenets-aqsx"
|
"yamal-nenets-aqsx"
|
||||||
],
|
],
|
||||||
"neuquen-yvne": [
|
"neuquen-yvne": [
|
||||||
@@ -7614,6 +7615,7 @@
|
|||||||
"ontario-bzqu",
|
"ontario-bzqu",
|
||||||
"quebec-yegk"
|
"quebec-yegk"
|
||||||
],
|
],
|
||||||
|
"new-earth-eikt": [],
|
||||||
"new-hampshire-koki": [
|
"new-hampshire-koki": [
|
||||||
"maine-maiz",
|
"maine-maiz",
|
||||||
"massachusetts-ggid",
|
"massachusetts-ggid",
|
||||||
@@ -8748,6 +8750,10 @@
|
|||||||
"soriano-ixbv",
|
"soriano-ixbv",
|
||||||
"tacuarembo-yekg"
|
"tacuarembo-yekg"
|
||||||
],
|
],
|
||||||
|
"pechora-qwhs": [
|
||||||
|
"nenets-qyga",
|
||||||
|
"yamal-gosw"
|
||||||
|
],
|
||||||
"peloponnisos-wqyw": [
|
"peloponnisos-wqyw": [
|
||||||
"dytiki-ellada-mlyj",
|
"dytiki-ellada-mlyj",
|
||||||
"sterea-ellada-inyu"
|
"sterea-ellada-inyu"
|
||||||
@@ -10010,6 +10016,9 @@
|
|||||||
"medea-elbf",
|
"medea-elbf",
|
||||||
"skikda-odba"
|
"skikda-odba"
|
||||||
],
|
],
|
||||||
|
"severnaya-zemlya-awqp": [
|
||||||
|
"yamal-gosw"
|
||||||
|
],
|
||||||
"sevilla-oxma": [
|
"sevilla-oxma": [
|
||||||
"badajoz-ciwt",
|
"badajoz-ciwt",
|
||||||
"cadiz-aazc",
|
"cadiz-aazc",
|
||||||
@@ -12160,6 +12169,10 @@
|
|||||||
"iwate-uvfz",
|
"iwate-uvfz",
|
||||||
"niigata-idhx"
|
"niigata-idhx"
|
||||||
],
|
],
|
||||||
|
"yamal-gosw": [
|
||||||
|
"pechora-qwhs",
|
||||||
|
"severnaya-zemlya-awqp"
|
||||||
|
],
|
||||||
"yamal-nenets-aqsx": [
|
"yamal-nenets-aqsx": [
|
||||||
"khanty-mansiy-tkto",
|
"khanty-mansiy-tkto",
|
||||||
"komi-eoeh",
|
"komi-eoeh",
|
||||||
|
|||||||
@@ -1305,6 +1305,10 @@
|
|||||||
"RU-ALT": "altay-mfuq",
|
"RU-ALT": "altay-mfuq",
|
||||||
"RU-AMU": "amur-clbp",
|
"RU-AMU": "amur-clbp",
|
||||||
"RU-ARK": "arkhangel-sk-lhbk",
|
"RU-ARK": "arkhangel-sk-lhbk",
|
||||||
|
"RU-ARK-NE": "new-earth-eikt",
|
||||||
|
"RU-ARK-SZ": "severnaya-zemlya-awqp",
|
||||||
|
"RU-PEO": "pechora-qwhs",
|
||||||
|
"RU-YAM": "yamal-gosw",
|
||||||
"RU-AST": "astrakhan-anec",
|
"RU-AST": "astrakhan-anec",
|
||||||
"RU-BA": "bashkortostan-uovk",
|
"RU-BA": "bashkortostan-uovk",
|
||||||
"RU-BEL": "belgorod-wxjk",
|
"RU-BEL": "belgorod-wxjk",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@
|
|||||||
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
||||||
* regions all named "Western") the 4-char codes are made distinct by
|
* regions all named "Western") the 4-char codes are made distinct by
|
||||||
* re-deriving with a deterministic salt on collision, which guarantees the
|
* re-deriving with a deterministic salt on collision, which guarantees the
|
||||||
* full key is unique across all 1,796 regions.
|
* full key is unique across all 1,800 regions.
|
||||||
*
|
*
|
||||||
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||||
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
||||||
@@ -107,8 +107,8 @@ const geoFile = path.join(root, 'public', 'regions.geo.json');
|
|||||||
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||||
const features = Array.isArray(geo?.features) ? geo.features : [];
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||||
|
|
||||||
if (features.length !== 1796) {
|
if (features.length !== 1800) {
|
||||||
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
throw new Error(`expected 1,800 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group features by their (computed) name-slug, preserving file order, so the
|
// Group features by their (computed) name-slug, preserving file order, so the
|
||||||
@@ -154,5 +154,5 @@ writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
|
|||||||
const western = Object.entries(manifest)
|
const western = Object.entries(manifest)
|
||||||
.filter(([, k]) => k.startsWith('western-'))
|
.filter(([, k]) => k.startsWith('western-'))
|
||||||
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||||
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
|
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,800 expected)`);
|
||||||
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
|
|||||||
const adjacencyKeys = new Set(Object.keys(adjacency));
|
const adjacencyKeys = new Set(Object.keys(adjacency));
|
||||||
expect(adjacencyKeys).toEqual(manifestKeys);
|
expect(adjacencyKeys).toEqual(manifestKeys);
|
||||||
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
||||||
expect(adjacencyKeys.size).toBe(1796);
|
expect(adjacencyKeys.size).toBe(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('every neighbour is itself a valid region key (referential integrity)', () => {
|
it('every neighbour is itself a valid region key (referential integrity)', () => {
|
||||||
@@ -84,4 +84,32 @@ describe('region-adjacency.json', () => {
|
|||||||
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
|
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
|
||||||
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
|
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the #31 + #41 Arctic splits: New Earth isolated; Severnaya Zemlya, Pechora and Yamal adjacency", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into the mainland (arkhangel-sk-lhbk), the
|
||||||
|
// Franz Josef Land islets (new-earth-eikt) and the Severnaya Zemlya
|
||||||
|
// islets (severnaya-zemlya-awqp). New Earth sits ~100+ km from any land
|
||||||
|
// → a genuinely isolated island (empty list).
|
||||||
|
// v0.26 (#41) repartitioned the mainland: the 5 lat < 68 parts stay
|
||||||
|
// arkhangel-sk-lhbk, and the 28 lat ≥ 68 band parts became pechora-qwhs
|
||||||
|
// (the SW cell) and yamal-gosw (the NE cell). The Severnaya Zemlya
|
||||||
|
// islets' digitized rings touch the NE band part (now yamal-gosw), not
|
||||||
|
// the trimmed core, so SZ now borders Yamal (its former mainland contact
|
||||||
|
// moved to Yamal). The trimmed core keeps its real mainland neighbours
|
||||||
|
// but loses the SZ contact. Pechora (SW) borders the existing Nenets
|
||||||
|
// region to the south and Yamal to the north.
|
||||||
|
expect(adjacency['new-earth-eikt']).toEqual([]);
|
||||||
|
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['yamal-gosw']);
|
||||||
|
expect(adjacency['pechora-qwhs']).toEqual(['nenets-qyga', 'yamal-gosw']);
|
||||||
|
expect(adjacency['yamal-gosw']).toEqual(['pechora-qwhs', 'severnaya-zemlya-awqp']);
|
||||||
|
// the trimmed core keeps its real neighbours (the band's SZ contact moved to Yamal)
|
||||||
|
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
|
||||||
|
'karelia-ohem',
|
||||||
|
'kirov-gfvm',
|
||||||
|
'komi-eoeh',
|
||||||
|
'murmansk-etas',
|
||||||
|
'nenets-qyga',
|
||||||
|
'vologda-ynvr',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
|
|||||||
const keys = Object.values(manifest);
|
const keys = Object.values(manifest);
|
||||||
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||||
|
|
||||||
it('has exactly 1,796 entries (one per region)', () => {
|
it('has exactly 1,800 entries (one per region)', () => {
|
||||||
expect(Object.keys(manifest)).toHaveLength(1796);
|
expect(Object.keys(manifest)).toHaveLength(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has 1,796 unique keys (no collisions)', () => {
|
it('has 1,800 unique keys (no collisions)', () => {
|
||||||
expect(new Set(keys).size).toBe(keys.length);
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
expect(new Set(keys).size).toBe(1796);
|
expect(new Set(keys).size).toBe(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
|
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
|
||||||
@@ -50,11 +50,26 @@ describe('region-keys.json', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps RU-ARK's key stable across the #31 + #41 splits and mints the island and band keys", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ, and v0.26
|
||||||
|
// (#41) repartitioned the mainland into the core (RU-ARK) + RU-PEO +
|
||||||
|
// RU-YAM. The MAIN feature keeps its id (and therefore its key — FNV-1a
|
||||||
|
// of the id), which is what keeps existing Nation.territory / SQLite
|
||||||
|
// ownership valid; the new features get fresh keys derived the same way
|
||||||
|
// from their ids (no within-country name-collision salt needed — the
|
||||||
|
// slugs are unique).
|
||||||
|
expect(manifest['RU-ARK']).toBe('arkhangel-sk-lhbk'); // unchanged since v0.19
|
||||||
|
expect(manifest['RU-ARK-NE']).toBe('new-earth-eikt');
|
||||||
|
expect(manifest['RU-ARK-SZ']).toBe('severnaya-zemlya-awqp');
|
||||||
|
expect(manifest['RU-PEO']).toBe('pechora-qwhs');
|
||||||
|
expect(manifest['RU-YAM']).toBe('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
|
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
|
||||||
// The manifest is looked up by feature id, so its key set must line up
|
// The manifest is looked up by feature id, so its key set must line up
|
||||||
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||||
// rebuilding the keys).
|
// rebuilding the keys).
|
||||||
expect(featureIds).toHaveLength(1796);
|
expect(featureIds).toHaveLength(1800);
|
||||||
const manifestIds = Object.keys(manifest);
|
const manifestIds = Object.keys(manifest);
|
||||||
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
||||||
});
|
});
|
||||||
|
|||||||
+70
-12
@@ -530,16 +530,22 @@ describe('regions.geo.json', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
|
it('is at the v0.26 fixed point: 1,800 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
|
// 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
|
// 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
|
// 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
|
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
|
||||||
// per-feature ids/names/parents and their order are unchanged; only the
|
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
|
||||||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
|
||||||
// the signature guards against a non-idempotent or destructive re-run.
|
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
|
||||||
// The fill is byte-deterministic per input and idempotent (a second run
|
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
|
||||||
// on the filled file changes nothing).
|
// re-groupings: no part/ring/vertex moves, so only the region count
|
||||||
|
// changed (1,796 → 1,798 → 1,800); the part/ring/vertex totals, the
|
||||||
|
// per-feature ids/names/parents, and the feature order (the new features
|
||||||
|
// sit immediately after RU-ARK-SZ) are as pinned below. Pinning the
|
||||||
|
// signature guards against a non-idempotent or destructive re-run; both
|
||||||
|
// stages are byte-deterministic per input and idempotent (a second run
|
||||||
|
// changes nothing).
|
||||||
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||||
let verts = 0,
|
let verts = 0,
|
||||||
rings = 0,
|
rings = 0,
|
||||||
@@ -556,15 +562,67 @@ describe('regions.geo.json', () => {
|
|||||||
for (const ring of poly) verts += ring.length;
|
for (const ring of poly) verts += ring.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
expect(regions.features.length, 'region count').toBe(1800); // 1,796 + the #31 islands (+2) + the #41 band regions (+2)
|
||||||
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the #31/#41 re-partitions
|
||||||
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
|
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(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
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
|
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the #31/#41 re-partitions
|
||||||
console.log(
|
console.log(
|
||||||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
|
||||||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the five Arkhangel\'sk-family features: #31 islands + #41 band (RU-ARK, New Earth, Severnaya Zemlya, Pechora, Yamal)', () => {
|
||||||
|
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
|
||||||
|
// mixing mainland and far-north islands) was repartitioned by part
|
||||||
|
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
|
||||||
|
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
|
||||||
|
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
|
||||||
|
// v0.26 (#41): the 33-part mainland (post-#31) was repartitioned again —
|
||||||
|
// the 5 lat < 68 parts stay RU-ARK (id/name/key unchanged), and the 28
|
||||||
|
// lat ≥ 68 "Arctic-coast band" parts split into RU-PEO "Pechora" (the SW
|
||||||
|
// Voronoi cell, 13 parts) and RU-YAM "Yamal" (the NE Voronoi cell, 15
|
||||||
|
// parts). Pure re-grouping: all five keep parent RUS and all 78 original
|
||||||
|
// parts of the pre-#31 feature are accounted for, verbatim.
|
||||||
|
const byId = new Map(regions.features.map((f) => [f.id, f]));
|
||||||
|
const ark = byId.get('RU-ARK');
|
||||||
|
const ne = byId.get('RU-ARK-NE');
|
||||||
|
const sz = byId.get('RU-ARK-SZ');
|
||||||
|
const peo = byId.get('RU-PEO');
|
||||||
|
const yam = byId.get('RU-YAM');
|
||||||
|
for (const f of [ark, ne, sz, peo, yam]) {
|
||||||
|
expect(f, `${f?.id ?? '?'} exists`).toBeDefined();
|
||||||
|
expect(f!.properties.parent, `${f!.id} parent`).toBe('RUS');
|
||||||
|
expect(f!.geometry.type, `${f!.id} geometry`).toBe('MultiPolygon');
|
||||||
|
}
|
||||||
|
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
|
||||||
|
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
|
||||||
|
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
|
||||||
|
expect(peo!.properties.name, 'RU-PEO name').toBe('Pechora');
|
||||||
|
expect(yam!.properties.name, 'RU-YAM name').toBe('Yamal');
|
||||||
|
// the 78 parts of the pre-split feature are all accounted for, verbatim
|
||||||
|
const countParts = (f: (typeof regions.features)[number]) =>
|
||||||
|
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
|
||||||
|
expect(
|
||||||
|
countParts(ark!) + countParts(ne!) + countParts(sz!) + countParts(peo!) + countParts(yam!),
|
||||||
|
'total parts across the five features',
|
||||||
|
).toBe(78);
|
||||||
|
// the #41 split: the mainland core keeps its 5 lat < 68 parts; the band's
|
||||||
|
// 28 parts split 13 (Pechora) / 15 (Yamal)
|
||||||
|
expect(countParts(ark!), 'RU-ARK core part count').toBe(5);
|
||||||
|
expect(countParts(peo!), 'RU-PEO part count').toBe(13);
|
||||||
|
expect(countParts(yam!), 'RU-YAM part count').toBe(15);
|
||||||
|
// stable file order: RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM
|
||||||
|
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
|
||||||
|
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
|
||||||
|
expect(regions.features[idx + 3]?.id).toBe('RU-PEO');
|
||||||
|
expect(regions.features[idx + 4]?.id).toBe('RU-YAM');
|
||||||
|
console.log(
|
||||||
|
`\n #31 + #41: RU-ARK ${countParts(ark!)} parts (core), RU-ARK-NE ${countParts(ne!)} (New Earth), ` +
|
||||||
|
`RU-ARK-SZ ${countParts(sz!)} (Severnaya Zemlya), RU-PEO ${countParts(peo!)} (Pechora), RU-YAM ${countParts(yam!)} (Yamal)`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel-band.mjs — split the RU-ARK Arctic-coast band into
|
||||||
|
* two new regions, RU-PEO ("Pechora", the SW group) and RU-YAM ("Yamal", the
|
||||||
|
* NE group) (issue #41, a follow-up to #31).
|
||||||
|
*
|
||||||
|
* The #31 split (`scripts/split-arkhangel.mjs`) left `RU-ARK` as a 33-part
|
||||||
|
* MultiPolygon (~393,256 km²). Measured on that feature (centroid latitude /
|
||||||
|
* planar area), the parts divide cleanly by centroid latitude:
|
||||||
|
*
|
||||||
|
* lat < 68 4 parts ~305,000 km² (Arkhangel'sk proper + 3 slivers)
|
||||||
|
* 66 ≤ lat < 68 1 part ~47 km²
|
||||||
|
* lat ≥ 68 28 parts ~88,000 km² (the "Arctic-coast band")
|
||||||
|
*
|
||||||
|
* The band is really *other* Arctic oblasts (Nenets / Pechora / Yamal / Taimyr
|
||||||
|
* side) mis-lumped into Arkhangel'sk during the v0.14 coarsening. This stage
|
||||||
|
* re-coarsens it: the 28 band parts are split into two new regions by a
|
||||||
|
* Voronoi of the two band parts with the largest planar area (the SW anchor
|
||||||
|
* vs the NE anchor), while the 5 lat < 68 parts stay in `RU-ARK`.
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — the 5 lat < 68 parts (original order)
|
||||||
|
* → still the `arkhangel-sk-lhbk` key
|
||||||
|
* RU-PEO "Pechora" — the band's SW Voronoi cell (13 parts)
|
||||||
|
* RU-YAM "Yamal" — the band's NE Voronoi cell (15 parts)
|
||||||
|
*
|
||||||
|
* Deterministic, turf-free threshold spec (all data-derived — no magic
|
||||||
|
* coordinates; the same planar model the tests/adjacency build use):
|
||||||
|
* 1. centroid of a part = the mean of its outer-ring vertices (verified to
|
||||||
|
* give the same band assignment as turf's area-weighted centroid, 0 flips).
|
||||||
|
* 2. band = the parts whose centroid lat ≥ 68 (asserted to be exactly 28).
|
||||||
|
* 3. anchors = the 2 band parts with the largest planar area (≈ 47,223 and
|
||||||
|
* ≈ 32,907 km², indices 2 and 1 in the 33-part feature); anchorW = the
|
||||||
|
* anchor with the smaller centroid longitude.
|
||||||
|
* 4. each non-anchor band part → the nearer anchor by km-weighted centroid
|
||||||
|
* distance, `dist(a,b) = hypot(Δlon·111.32·cos(midLat), Δlat·110.574)`;
|
||||||
|
* a tie → west (anchorW). Each anchor → its own group. This yields
|
||||||
|
* PEO = {1,4,5,9,11,12,13,14,16,17,18,20,22} and
|
||||||
|
* YAM = {2,6,15,19,21,23,24,25,26,27,28,29,30,31,32} (0-based indices).
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the others. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible (the same guarantee as #31). `RU-ARK` keeps its stable feature id,
|
||||||
|
* so its region key (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-PEO`, `RU-YAM`) get their own keys derived by the same FNV-1a
|
||||||
|
* rule in `scripts/build-region-keys.mjs` (`pechora-qwhs`, `yamal-gosw` —
|
||||||
|
* run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after `RU-ARK-SZ` (stable file
|
||||||
|
* order, deterministic bytes: `RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM,
|
||||||
|
* …`). Each new feature's `properties` copies `RU-ARK`'s (only `name` and
|
||||||
|
* `parent` exist in this dataset) with `name` overridden; `parent` stays RUS.
|
||||||
|
* Each feature's geometry is a MultiPolygon of its assigned parts (in
|
||||||
|
* original order), or a single Polygon if it ended up with one part (it won't
|
||||||
|
* here, but the builder handles it).
|
||||||
|
*
|
||||||
|
* Pipeline position: AFTER `split-arkhangel` and BEFORE `build-region-keys`
|
||||||
|
* (the manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → split-arkhangel
|
||||||
|
* → **split-arkhangel-band** → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-PEO and RU-YAM already exist the file is left untouched
|
||||||
|
* (no write at all), so a full re-run of the pipeline reproduces byte-identical
|
||||||
|
* output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel-band.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions:band`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* Deliberately turf-free: no `@turf/*` import (turf is only ever imported by
|
||||||
|
* the geo-generator `.mjs` stages, never by this re-partition or by any `.ts`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// A part whose centroid lat is ≥ this is part of the Arctic-coast band; the
|
||||||
|
// lat < 68 core stays in RU-ARK (issue #41).
|
||||||
|
const BAND_MIN_LAT = 68;
|
||||||
|
|
||||||
|
// The post-#31 mainland feature is a 33-part MultiPolygon; any other shape
|
||||||
|
// means the file is not the state this stage expects → fail loud.
|
||||||
|
const EXPECTED_PARTS = 33;
|
||||||
|
// The Arctic-coast band is exactly 28 of those parts (lat ≥ 68).
|
||||||
|
const EXPECTED_BAND = 28;
|
||||||
|
|
||||||
|
// Planar km model (matches the test's area model and the adjacency build):
|
||||||
|
// x = lon · 111.32 · cos(midLat), y = lat · 110.574.
|
||||||
|
const KM_LON = 111.32;
|
||||||
|
const KM_LAT = 110.574;
|
||||||
|
const rad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean of a part's outer-ring vertices — a turf-free centroid. Verified to give
|
||||||
|
* the same band assignment as turf's area-weighted centroid (0 flips).
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
for (const [lo, la] of ring) {
|
||||||
|
lon += lo;
|
||||||
|
lat += la;
|
||||||
|
}
|
||||||
|
return [lon / ring.length, lat / ring.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar area of a part (km²), shoelace over its outer ring at its mean latitude. */
|
||||||
|
function areaKm2(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
const midLat = lat / ring.length;
|
||||||
|
const kx = KM_LON * Math.cos(rad(midLat));
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [lon1, lat1] = ring[i];
|
||||||
|
const [lon2, lat2] = ring[(i + 1) % ring.length];
|
||||||
|
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||||
|
}
|
||||||
|
return Math.abs(a / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar km distance between two centroids (midpoint-latitude weighted). */
|
||||||
|
function kmDist(a, b) {
|
||||||
|
const dLon = b[0] - a[0];
|
||||||
|
const dLat = b[1] - a[1];
|
||||||
|
const midLat = (a[1] + b[1]) / 2;
|
||||||
|
return Math.hypot(dLon * KM_LON * Math.cos(rad(midLat)), dLat * KM_LAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-PEO') && ids.has('RU-YAM')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] RU-PEO and RU-YAM already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load + validate RU-ARK ---------------------------------------------------
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the 33-part post-#31 mainland)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
if (parts.length !== EXPECTED_PARTS) {
|
||||||
|
throw new Error(`RU-ARK has ${parts.length} parts, expected ${EXPECTED_PARTS} (the post-#31 mainland)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- band selection (centroid lat ≥ 68) and the lat < 68 core -----------------
|
||||||
|
const partsByIndex = parts.map((p, i) => ({ i, c: centroidOf(p) }));
|
||||||
|
const band = partsByIndex.filter((o) => o.c[1] >= BAND_MIN_LAT);
|
||||||
|
if (band.length !== EXPECTED_BAND) {
|
||||||
|
throw new Error(`expected exactly ${EXPECTED_BAND} Arctic-coast band parts (lat ≥ ${BAND_MIN_LAT}), found ${band.length}`);
|
||||||
|
}
|
||||||
|
const core = partsByIndex.filter((o) => o.c[1] < BAND_MIN_LAT);
|
||||||
|
|
||||||
|
// --- anchors: the two band parts with the largest planar area -----------------
|
||||||
|
// (≈ 47,223 and ≈ 32,907 km², indices 2 and 1 in the 33-part feature).
|
||||||
|
const byArea = [...band].sort((a, b) => areaKm2(parts[b.i]) - areaKm2(parts[a.i]));
|
||||||
|
const big = byArea[0];
|
||||||
|
const small = byArea[1];
|
||||||
|
const anchorW = big.c[0] <= small.c[0] ? big : small; // the west (SW) anchor
|
||||||
|
const anchorE = big.c[0] <= small.c[0] ? small : big; // the east (NE) anchor
|
||||||
|
|
||||||
|
// --- assignment (Voronoi by km-weighted centroid distance; tie → west) --------
|
||||||
|
const peo = new Set([anchorW.i]);
|
||||||
|
const yam = new Set([anchorE.i]);
|
||||||
|
for (const o of band) {
|
||||||
|
if (o.i === anchorW.i || o.i === anchorE.i) continue;
|
||||||
|
const dW = kmDist(o.c, anchorW.c);
|
||||||
|
const dE = kmDist(o.c, anchorE.c);
|
||||||
|
(dW <= dE ? peo : yam).add(o.i); // a tie goes to the west (anchorW) group
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim, original order; MultiPolygon/Polygon) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1 ? { type: 'MultiPolygon', coordinates: ps } : { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
const coreParts = [...core].sort((a, b) => a.i - b.i).map((o) => parts[o.i]);
|
||||||
|
const peoParts = [...peo].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
const yamParts = [...yam].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
|
||||||
|
ark.geometry = geom(coreParts);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const peoF = mkFeature('RU-PEO', 'Pechora', peoParts);
|
||||||
|
const yamF = mkFeature('RU-YAM', 'Yamal', yamParts);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + areaKm2(p), 0);
|
||||||
|
console.log(`[split-arkhangel-band] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (core, lat < 68) ${coreParts.length} parts (of ${parts.length}), ≈ ${Math.round(km2(coreParts)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-PEO (Pechora, SW) ${peoParts.length} parts, ≈ ${Math.round(km2(peoParts)).toLocaleString('en-US')} km² [${[...peo].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-YAM (Yamal, NE) ${yamParts.length} parts, ≈ ${Math.round(km2(yamParts)).toLocaleString('en-US')} km² [${[...yam].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
if (peoParts.length === 0 || yamParts.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: Pechora=${peoParts.length} parts, Yamal=${yamParts.length} parts — check the band before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK-SZ) and write -------
|
||||||
|
// RU-ARK-SZ must exist (the #31 split ran first); if it doesn't, this stage is
|
||||||
|
// being run out of order → fail loud.
|
||||||
|
const szIdx = features.findIndex((f) => f.id === 'RU-ARK-SZ');
|
||||||
|
if (szIdx < 0) {
|
||||||
|
throw new Error('RU-ARK-SZ not found — the #31 split (scripts/split-arkhangel.mjs) must run before this stage');
|
||||||
|
}
|
||||||
|
features.splice(szIdx + 1, 0, peoF, yamF);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] wrote ${outFile} — features ${features.length - 2} → ${features.length}` +
|
||||||
|
` (order …, RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM, …)`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region
|
||||||
|
* into main + two detached island archipelagos (issue #31).
|
||||||
|
*
|
||||||
|
* `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk
|
||||||
|
* mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north
|
||||||
|
* island groups that belong nowhere near the mainland — the Franz Josef Land
|
||||||
|
* islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets
|
||||||
|
* (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing
|
||||||
|
* polygon parts between three features so each island group is its own
|
||||||
|
* addressable sub-region:
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — every part whose centroid has
|
||||||
|
* lat < 76 (the mainland, Arctic-coast
|
||||||
|
* mainland parts, islets, one south dot)
|
||||||
|
* RU-ARK-NE "New Earth" — every part whose centroid has
|
||||||
|
* lat ≥ 79.5 (the 42 Franz Josef Land
|
||||||
|
* islets, ~15,700 km²)
|
||||||
|
* RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has
|
||||||
|
* 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3
|
||||||
|
* Severnaya Zemlya islets, ~1,200 km²)
|
||||||
|
*
|
||||||
|
* (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist
|
||||||
|
* in the current dataset — the split is verified clean before it is written.)
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the other. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible. RU-ARK keeps its stable feature id, so its region key
|
||||||
|
* (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same
|
||||||
|
* FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after RU-ARK (stable file order,
|
||||||
|
* deterministic bytes). Each new feature's `properties` copies RU-ARK's
|
||||||
|
* (only `name` and `parent` exist in this dataset) with `name` overridden;
|
||||||
|
* `parent` stays RUS. Each feature's geometry is a MultiPolygon of its
|
||||||
|
* assigned parts, or a single Polygon if it ends up with one part.
|
||||||
|
*
|
||||||
|
* Pipeline position: between `fill-gaps` and `build-region-keys` (the
|
||||||
|
* manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel**
|
||||||
|
* → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left
|
||||||
|
* untouched (no write at all), so a full re-run of the pipeline reproduces
|
||||||
|
* byte-identical output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* `@turf/centroid` + `@turf/helpers` are used only to classify each part by
|
||||||
|
* its centroid — the same measurement the split thresholds are defined in.
|
||||||
|
* (Turf is allowed in .mjs scripts only; never imported by any .ts file.)
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import centroid from '@turf/centroid';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import { polygon } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// The split thresholds (degrees), per issue #31.
|
||||||
|
const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land)
|
||||||
|
const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)…
|
||||||
|
const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya
|
||||||
|
// (anything else — including the 76–79.5° band west of 55°E, of which the
|
||||||
|
// current dataset has none — stays in the main Arkhangel'sk feature)
|
||||||
|
|
||||||
|
/** A GeoJSON polygon part as a turf Feature (for centroid/area only). */
|
||||||
|
const partFeature = (part) => polygon(part);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part's centroid [lon, lat]. Strict by design: a centroid that cannot be
|
||||||
|
* computed is a data error, not something to silently route to the mainland.
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
try {
|
||||||
|
return centroid(partFeature(part)).geometry.coordinates;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a centroid [lon, lat] into one of the three output buckets. */
|
||||||
|
function bucketOf([lon, lat]) {
|
||||||
|
if (lat >= NEW_EARTH_MIN_LAT) return 'ne';
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz';
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
}
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
|
||||||
|
// --- partition (stable: original part order is kept inside each bucket) -------
|
||||||
|
const buckets = { main: [], ne: [], sz: [] };
|
||||||
|
let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data)
|
||||||
|
for (const part of parts) {
|
||||||
|
const [lon, lat] = centroidOf(part);
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1;
|
||||||
|
buckets[bucketOf([lon, lat])].push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1
|
||||||
|
? { type: 'MultiPolygon', coordinates: ps }
|
||||||
|
: { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
ark.geometry = geom(buckets.main);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne);
|
||||||
|
const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0);
|
||||||
|
console.log(`[split-arkhangel] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` +
|
||||||
|
(bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
if (buckets.ne.length === 0 || buckets.sz.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK) and write ---------
|
||||||
|
features.splice(idx + 1, 0, ne, sz);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);
|
||||||
+19
-4
@@ -27,9 +27,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(loadRegions(root)).toBe(true);
|
expect(loadRegions(root)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the full manifest: 1,796 unique valid region keys', () => {
|
it('loads the full manifest: 1,800 unique valid region keys', () => {
|
||||||
expect(hasRegionData()).toBe(true);
|
expect(hasRegionData()).toBe(true);
|
||||||
expect(validKeys().size).toBe(1796);
|
expect(validKeys().size).toBe(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
||||||
@@ -51,6 +51,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands RUS to all 84 sub-regions after the #31 + #41 Arkhangel splits (no ownership gap at game init)', () => {
|
||||||
|
// #31 split RU-ARK into the mainland + New Earth + Severnaya Zemlya; #41
|
||||||
|
// repartitioned the mainland into the core (unchanged key) + Pechora +
|
||||||
|
// Yamal. A fresh game's RUS territory (expanded from the preset's
|
||||||
|
// country list) must contain all five sub-region keys — the main one
|
||||||
|
// (ownership-safe, unchanged key) plus the island and band ones.
|
||||||
|
const rus = expandCountriesToKeys(['RUS']);
|
||||||
|
expect(rus).toHaveLength(84);
|
||||||
|
expect(rus).toContain('arkhangel-sk-lhbk');
|
||||||
|
expect(rus).toContain('new-earth-eikt');
|
||||||
|
expect(rus).toContain('severnaya-zemlya-awqp');
|
||||||
|
expect(rus).toContain('pechora-qwhs');
|
||||||
|
expect(rus).toContain('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
||||||
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||||
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
@@ -166,13 +181,13 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
expect(loadRegions(prodRoot)).toBe(true);
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
expect(hasRegionData()).toBe(true);
|
expect(hasRegionData()).toBe(true);
|
||||||
expect(validKeys().size).toBe(1796);
|
expect(validKeys().size).toBe(1800);
|
||||||
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
expect(loadRegions(realRoot)).toBe(true);
|
expect(loadRegions(realRoot)).toBe(true);
|
||||||
expect(validKeys().size).toBe(1796);
|
expect(validKeys().size).toBe(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user