Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db46e1a5ec | ||
|
|
99188b5b15 | ||
|
|
0ebdb36daa | ||
|
|
06cdbf3d81 | ||
|
|
344b5b4a5e | ||
|
|
d5285ce28d | ||
|
|
8252b665b5 | ||
|
|
7d50cba061 |
@@ -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
|
- 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`
|
- 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 country choropleth
|
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
||||||
from `public/countries.geo.json` (`id` = ISO_A3)
|
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
|
- 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
|
**injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
|
||||||
(`LLM_STUB=1`) is used by all tests/e2e.
|
(`LLM_STUB=1`) is used by all tests/e2e.
|
||||||
@@ -48,7 +50,8 @@ history/
|
|||||||
├── 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 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
|
├── 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, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
|
│ └── 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).
|
(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
|
- **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.
|
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
|
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
|
||||||
NOT open a popup — it switches the dossier to that region's owning nation
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
(`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set
|
**parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records
|
||||||
the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap`
|
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
|
||||||
by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId`
|
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved
|
||||||
(properties survive into the rendered features) and the shared `regionOwner()` helper looks up.
|
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's
|
||||||
It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's
|
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the
|
||||||
`id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a
|
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level
|
||||||
number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no
|
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region
|
||||||
`RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The
|
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave
|
||||||
hover path (a non-closeable tooltip with owner + key stats) uses the same resolution.
|
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
|
- **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
|
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
|
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||||
@@ -239,5 +247,6 @@ history/
|
|||||||
caught.
|
caught.
|
||||||
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
|
- **`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.
|
(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
|
- The `dist/` client is committed to the build output only; the `public/` geo files
|
||||||
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
|
(`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,31 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Fixes #16.
|
||||||
|
|
||||||
|
## v0.10.0 — 2026-10-11 21:25
|
||||||
|
- **Fix world map style (#2)** — made the choropleth fill fully opaque (`fill-opacity` 0.85 → 1.0) and darkened the Natural Earth terrain raster (`raster-opacity` 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
|
||||||
|
- **Strengthened country border lines** — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
|
||||||
|
- Fixes #2.
|
||||||
|
|
||||||
## v0.9.0 — 2026-10-11 20:57
|
## v0.9.0 — 2026-10-11 20:57
|
||||||
- **Reduced turn latency (prompt-level):** capped `recentEvents` in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (`reasoning_effort: low`); the deterministic stub, the `TurnResult` contract, `max_tokens`, and `reasoning_effort` are all unchanged.
|
- **Reduced turn latency (prompt-level):** capped `recentEvents` in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (`reasoning_effort: low`); the deterministic stub, the `TurnResult` contract, `max_tokens`, and `reasoning_effort` are all unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default tseslint.config(
|
|||||||
'vitest.config.ts',
|
'vitest.config.ts',
|
||||||
'playwright.config.ts',
|
'playwright.config.ts',
|
||||||
'scripts/**/*.mjs',
|
'scripts/**/*.mjs',
|
||||||
|
'scripts/**/*.ts',
|
||||||
],
|
],
|
||||||
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
|
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.9.0",
|
"version": "0.13.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).",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
|
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
|
||||||
"start": "node server/dist/index.js",
|
"start": "node server/dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"generate:regions": "node scripts/generate-regions.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`.
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Run: `npm run generate:regions`
|
||||||
|
*/
|
||||||
|
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
// --- 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)
|
||||||
|
|
||||||
|
// Natural Earth admin-1 source (NON-committed temp file; override with NE_ADMIN1).
|
||||||
|
const INPUT = process.env.NE_ADMIN1 || '/tmp/ne10m_admin1.geojson';
|
||||||
|
|
||||||
|
// --- small geo helpers ---------------------------------------------------------
|
||||||
|
const rN = (v, N) => Math.round(v * Math.pow(10, N)) / Math.pow(10, N);
|
||||||
|
|
||||||
|
/** Round a ring to N decimals, drop consecutive duplicates, and close it. */
|
||||||
|
function roundRing(ring, N) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 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();
|
||||||
|
if (out.length < 3) return out; // degenerate; caller drops it
|
||||||
|
return [...out, [...out[0]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 (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]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main ----------------------------------------------------------------------
|
||||||
|
function main() {
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n');
|
||||||
|
|
||||||
|
// --- summary ----------------------------------------------------------------
|
||||||
|
const counts = new Map();
|
||||||
|
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 ?? '';
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
|
||||||
|
*
|
||||||
|
* The dataset is now REAL named sub-national administrative regions (Natural
|
||||||
|
* Earth admin-1: states, provinces, governorates, oblasts, …) rather than the
|
||||||
|
* old geometric grid — 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`).
|
||||||
|
*
|
||||||
|
* This test is self-contained (no import of the generator): 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, 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.
|
||||||
|
*
|
||||||
|
* It reads each file exactly once and runs in O(total vertices).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
|
||||||
|
type Ring = [number, number][];
|
||||||
|
type Geometry =
|
||||||
|
| { type: 'Polygon'; coordinates: Ring[] }
|
||||||
|
| { type: 'MultiPolygon'; coordinates: Ring[][] };
|
||||||
|
|
||||||
|
function ringAreaKm2(ring: Ring, midLat: number): number {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outerRings(geom: Geometry): Ring[] {
|
||||||
|
if (geom.type === 'Polygon') return [geom.coordinates[0]];
|
||||||
|
if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */
|
||||||
|
function featureAreaKm2(geom: Geometry): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const ring of outerRings(geom)) {
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RegionFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
id: string;
|
||||||
|
properties: { name: string; parent: string };
|
||||||
|
geometry: Geometry;
|
||||||
|
}
|
||||||
|
interface CountryFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
id: string;
|
||||||
|
properties: { name: string };
|
||||||
|
geometry: Geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read each file exactly once.
|
||||||
|
const regions = JSON.parse(
|
||||||
|
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
|
||||||
|
) as { type: 'FeatureCollection'; features: RegionFeature[] };
|
||||||
|
const countries = JSON.parse(
|
||||||
|
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||||||
|
) as { type: 'FeatureCollection'; features: CountryFeature[] };
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
||||||
|
|
||||||
|
describe('regions.geo.json', () => {
|
||||||
|
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);
|
||||||
|
for (const f of regions.features) {
|
||||||
|
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
|
||||||
|
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);
|
||||||
|
const g = f.geometry;
|
||||||
|
expect(
|
||||||
|
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
|
||||||
|
`${f.id}: bad geometry type ${g?.type}`,
|
||||||
|
).toBe(true);
|
||||||
|
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('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('sums to within [40%, 175%] 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), so for a handful of small/island nations the region union
|
||||||
|
// over/under-fills the country: measured range is ~[52%, 151%]
|
||||||
|
// (Brunei under-fills, Vanuatu over-fills). The band brackets that with
|
||||||
|
// margin while still catching a genuinely missing or doubled-up region.
|
||||||
|
const ratios: number[] = [];
|
||||||
|
let minRatio = Infinity;
|
||||||
|
let maxRatio = -Infinity;
|
||||||
|
let minId = '';
|
||||||
|
let maxId = '';
|
||||||
|
for (const c of countries.features) {
|
||||||
|
const ca = countryArea.get(c.id) ?? 0;
|
||||||
|
if (ca <= 0) continue;
|
||||||
|
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.4 && ratio <= 1.75,
|
||||||
|
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`,
|
||||||
|
).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)}%` +
|
||||||
|
` / median ${(100 * medRatio).toFixed(1)}%` +
|
||||||
|
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–175%)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
-20
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildRegionMap } from './map';
|
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';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
@@ -74,40 +74,62 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #6 regression guard. The click/hover handlers resolve the owning nation from a
|
* #6 regression guard (sub-national map). The click/hover handlers resolve the
|
||||||
* map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3
|
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
|
||||||
* string) into a *numeric index* when it stores the source, so a feature the map
|
* source's top-level `id` into a *numeric index* when it stores the source, so a
|
||||||
* hands back at interaction time has a number for `id` and the ISO_A3 only lives
|
* feature the map hands back at interaction time has a number for `id`. The
|
||||||
* in `properties.regionId`. This test pins the exact data shape and proves the
|
* choropleth is now sub-national: a feature's id is a sub-region code
|
||||||
* fix: resolution via `properties.regionId` works, while the pre-fix path that
|
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
|
||||||
* read `feature.id` does not.
|
* 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', () => {
|
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 source feature after setData(): `id` is a
|
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
||||||
// number (here 0, as observed in the live map), and the ISO_A3 is preserved
|
// is a number (repacked), and the stable identifiers live only in properties —
|
||||||
// only in properties (the client stamps it as `regionId`).
|
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
|
||||||
const repackedFeature = {
|
const repackedFeature = {
|
||||||
id: 0,
|
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)', () => {
|
it('resolves the owner from properties.parent (the fix)', () => {
|
||||||
const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : '';
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||||
expect(regionOwner(regions, code)).toEqual({
|
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
ownerId: 'de',
|
ownerId: 'de',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the old path (feature.id as a string key) does NOT resolve — the bug', () => {
|
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
||||||
// String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed
|
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
|
||||||
// and the click popup reported "No owner" even for owned regions.
|
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||||
expect(regions[String(repackedFeature.id)]).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();
|
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', () => {
|
describe('nationColor', () => {
|
||||||
it('is deterministic per id and distinct across different ids', () => {
|
it('is deterministic per id and distinct across different ids', () => {
|
||||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||||
|
|||||||
+33
-12
@@ -57,20 +57,41 @@ export interface RegionOwner {
|
|||||||
export type RegionMap = Record<string, RegionOwner>;
|
export type RegionMap = Record<string, RegionOwner>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the owner record for a map region from the authoritative RegionMap by
|
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
||||||
* its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned
|
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
|
||||||
* region. Pure + shared: the client resolves a clicked or hovered map feature by
|
* the LLM stay at country granularity — see #16), so `key` is a country code such
|
||||||
* first reading the feature's stable ISO_A3 code (stored in `properties.regionId`
|
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
|
||||||
* by the client when it repacks the source — see WorldMap) and then calling this.
|
* empty/missing key or an unowned country.
|
||||||
*
|
*
|
||||||
* The code must NOT be taken from the feature's top-level `id`: MapLibre repacks
|
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||||||
* a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores
|
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
||||||
* the source, so at click/hover time `feature.id` is a number, never the ISO_A3
|
* which reads the feature's `properties.parent` — never the repacked top-level
|
||||||
* — resolving from it is exactly the "no owner" bug (#6).
|
* `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 {
|
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||||
if (!isoA3) return undefined;
|
if (!key) return undefined;
|
||||||
return regions[isoA3];
|
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). */
|
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||||
|
|||||||
+26
-2
@@ -589,6 +589,17 @@ function GameScreen({
|
|||||||
}: GameScreenProps) {
|
}: GameScreenProps) {
|
||||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||||
|
// When the dossier target was set by a map click, remember which sub-region
|
||||||
|
// (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 player = game.nations[game.playerNationId];
|
||||||
const viewed = game.nations[viewingId] ?? player;
|
const viewed = game.nations[viewingId] ?? player;
|
||||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
@@ -641,7 +652,7 @@ function GameScreen({
|
|||||||
playerNationId={game.playerNationId}
|
playerNationId={game.playerNationId}
|
||||||
nations={game.nations}
|
nations={game.nations}
|
||||||
presetId={game.presetId}
|
presetId={game.presetId}
|
||||||
onFocusNation={setViewingId}
|
onFocusNation={focusNation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
{/* 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">
|
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Inspect
|
Inspect
|
||||||
</div>
|
</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">
|
<SelectTrigger className="w-full" data-testid="inspect-select">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -759,6 +777,12 @@ function GameScreen({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|||||||
+153
-96
@@ -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.
|
// 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';
|
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||||
maplibregl.setWorkerUrl(workerUrl);
|
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 type { Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface GeoFeature {
|
||||||
type: 'Feature';
|
type: 'Feature';
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
@@ -27,7 +30,7 @@ type GeoSource = maplibregl.GeoJSONSource;
|
|||||||
|
|
||||||
// Inline basemap style — a visible dark-navy background plus a subtle
|
// Inline basemap style — a visible dark-navy background plus a subtle
|
||||||
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
|
// 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.
|
// on top and is the real star.
|
||||||
//
|
//
|
||||||
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
|
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
|
||||||
@@ -61,7 +64,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
|
|||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: 'terrain',
|
source: 'terrain',
|
||||||
paint: {
|
paint: {
|
||||||
'raster-opacity': 0.5,
|
'raster-opacity': 0.15,
|
||||||
'raster-brightness-max': 0.8,
|
'raster-brightness-max': 0.8,
|
||||||
'raster-fade-duration': 200,
|
'raster-fade-duration': 200,
|
||||||
},
|
},
|
||||||
@@ -69,11 +72,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const COUNTRIES_SOURCE = 'countries';
|
const REGIONS_SOURCE = 'regions';
|
||||||
const HOVER_SOURCE = 'hover';
|
const HOVER_SOURCE = 'hover';
|
||||||
const FLASH_SOURCE = 'flash';
|
const FLASH_SOURCE = 'flash';
|
||||||
const FILL_LAYER = 'countries-fill';
|
const FILL_LAYER = 'regions-fill';
|
||||||
const LINE_LAYER = 'countries-line';
|
const LINE_LAYER = 'regions-line';
|
||||||
const PLAYER_LINE = 'player-line';
|
const PLAYER_LINE = 'player-line';
|
||||||
const FLASH_LINE = 'flash-line';
|
const FLASH_LINE = 'flash-line';
|
||||||
const HOVER_FILL = 'hover-fill';
|
const HOVER_FILL = 'hover-fill';
|
||||||
@@ -97,38 +100,48 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp the current ownership + colors onto a clone of the base geo. Each region
|
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||||||
* carries its owner's stable fill color, a subtle stroke, and a flag for the
|
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
|
||||||
* player's own regions (rendered as a brighter, thicker border by a dedicated
|
* owns whole countries, so every sub-region of a country shares that country's
|
||||||
* layer). Unknown owners fall back to a faint neutral fill — never an error.
|
* 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
|
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
|
||||||
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
|
* survives into the features MapLibre hands back on click/hover
|
||||||
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
|
* (the top-level `id` is repacked into a numeric index, #6);
|
||||||
* stores it, so a later click/hover event exposes that number on
|
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
|
||||||
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
|
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
|
||||||
* interaction time (the root cause of #6). `properties`, however, survive into the
|
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
|
||||||
* rendered features, so the interaction handlers resolve the owning nation from
|
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
||||||
* `properties.regionId` via the RegionMap (see `regionOwner`).
|
* - `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 {
|
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
f.properties = f.properties ?? {};
|
f.properties = f.properties ?? {};
|
||||||
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
|
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||||||
// survives into the features MapLibre hands back on click/hover.
|
// survives into the rendered features (the top-level `id` gets repacked).
|
||||||
const code = f.id == null ? '' : String(f.id);
|
const subId = f.id == null ? '' : String(f.id);
|
||||||
f.properties.regionId = code;
|
f.properties.regionId = subId;
|
||||||
const r = code ? regions[code] : undefined;
|
// 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) {
|
if (r) {
|
||||||
f.properties.fill = r.color;
|
f.properties.fill = r.color;
|
||||||
f.properties.owner = r.owner;
|
f.properties.owner = r.owner;
|
||||||
f.properties.ownerId = r.ownerId;
|
f.properties.ownerId = r.ownerId;
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.10)';
|
f.properties.stroke = 'rgba(255,255,255,0.30)';
|
||||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
f.properties.fill = 'rgba(148,163,184,0.05)';
|
f.properties.fill = 'rgba(148,163,184,0.05)';
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.06)';
|
f.properties.stroke = 'rgba(255,255,255,0.14)';
|
||||||
f.properties.owner = '';
|
f.properties.owner = '';
|
||||||
f.properties.ownerId = '';
|
f.properties.ownerId = '';
|
||||||
f.properties.isPlayer = 0;
|
f.properties.isPlayer = 0;
|
||||||
@@ -137,10 +150,18 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */
|
/**
|
||||||
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): FeatureCollection {
|
* Build a FeatureCollection of just the sub-regions whose **parent** country is
|
||||||
const set = new Set(codes);
|
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
|
||||||
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
|
* 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)) };
|
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>`;
|
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) {
|
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 stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||||
|
const sub = parentCode ? `<div class="hm-popup-sub">${esc(parentCode)}</div>` : '';
|
||||||
return `<div class="hm-popup">
|
return `<div class="hm-popup">
|
||||||
<div class="hm-popup-title">${esc(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-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||||
<div class="hm-stats">${stats}</div>
|
<div class="hm-stats">${stats}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -174,27 +201,38 @@ interface Props {
|
|||||||
presetId: string;
|
presetId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Called with a region's owning-nation id when that region is clicked, so the
|
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||||||
* dossier switches to that nation. Not called for unowned regions (the dossier
|
* dossier switches to that nation. For a sub-region, the owner is the parent
|
||||||
* keeps its current target). See the component doc for the full behavior.
|
* 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
|
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
||||||
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
* the finer `regions.geo.json` dataset: each country is subdivided into
|
||||||
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
|
||||||
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
|
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
|
||||||
* border for the player, a hover tooltip (owner + key stats), a **click that
|
* nation owns whole countries, so all of a country's sub-regions share that
|
||||||
* switches the dossier to the region's owning nation** (no popup — see #7), a
|
* country's owning-nation color. Rendered as a fixed, full-viewport background
|
||||||
* transient flash on territory changes, and a per-scenario initial camera.
|
* 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
|
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||||
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
|
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
|
||||||
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
|
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
||||||
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
|
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
||||||
* Unowned regions (no RegionMap entry) simply do not change the dossier.
|
* 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)
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
* — no floating control and no external API.
|
* — no floating control and no external API.
|
||||||
@@ -255,9 +293,9 @@ export default function WorldMap({
|
|||||||
const loadGeo = (): Promise<FeatureCollection> =>
|
const loadGeo = (): Promise<FeatureCollection> =>
|
||||||
baseGeoRef.current
|
baseGeoRef.current
|
||||||
? Promise.resolve(baseGeoRef.current)
|
? Promise.resolve(baseGeoRef.current)
|
||||||
: fetch('/countries.geo.json')
|
: fetch('/regions.geo.json')
|
||||||
.then((r) => {
|
.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();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then((g: FeatureCollection) => {
|
.then((g: FeatureCollection) => {
|
||||||
@@ -268,12 +306,14 @@ export default function WorldMap({
|
|||||||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
// Transient flash: pulse a bright outline over the changed regions, then fade
|
||||||
// to zero over a few seconds and self-clean. Idempotent per call.
|
// 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[]) {
|
function animateFlash(codes: string[]) {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src || cancelled) return;
|
if (!geo || !src || cancelled) return;
|
||||||
src.setData(featuresByCodes(geo, codes));
|
src.setData(featuresByParents(geo, codes));
|
||||||
if (!map.getLayer(FLASH_LINE)) {
|
if (!map.getLayer(FLASH_LINE)) {
|
||||||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||||||
// hover highlight when that layer exists; otherwise just append.
|
// hover highlight when that layer exists; otherwise just append.
|
||||||
@@ -306,33 +346,34 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
flashApiRef.current = animateFlash;
|
flashApiRef.current = animateFlash;
|
||||||
|
|
||||||
// The region a hovered/clicked feature belongs to is identified by its stable
|
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||||||
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
|
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||||||
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
|
// and its **owner** is resolved from the authoritative RegionMap via the
|
||||||
// source's `id` into a numeric index when it stores the source, so the value
|
// feature's `properties.parent` (the parent country's ISO_A3) through
|
||||||
// exposed on `e.features[i].id` at interaction time is a number, never the
|
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||||
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
|
// index, #6) and never from the sub-region id (not a RegionMap key).
|
||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const p = f.properties as { name?: string; regionId?: string };
|
const p = f.properties as { name?: string; regionId?: string; parent?: string };
|
||||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
if (code && code !== lastHoverId) {
|
const parent = typeof p?.parent === 'string' ? p.parent : '';
|
||||||
lastHoverId = code;
|
if (subId && subId !== lastHoverId) {
|
||||||
|
lastHoverId = subId;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
|
// The base GeoJSON feature's top-level `id` IS the sub-region id, so
|
||||||
// that to recover the feature for the hover-highlight outline (the
|
// match on it to recover the exact feature for the hover-highlight
|
||||||
// feature MapLibre hands us has a repacked numeric `id` we can't use).
|
// outline (the feature MapLibre hands us has a repacked numeric `id`).
|
||||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
|
const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId);
|
||||||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
||||||
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
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;
|
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? code, region, nation))
|
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
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
|
// #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
|
// owning nation. For a sub-region, the owner is the **parent country's**
|
||||||
// `properties.regionId` via the RegionMap (never the repacked top-level
|
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
|
||||||
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
|
// RegionMap (never the repacked top-level `f.id`, #6, and never the
|
||||||
// leaves the current dossier target unchanged — never a broken dossier.
|
// 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 onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const p = f.properties as { regionId?: string };
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
if (!region?.ownerId) return;
|
||||||
const region = regionOwner(regionsRef.current, code);
|
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
|
||||||
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
// 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
|
// 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.)
|
// automatically, so no setWorkerUrl is needed.)
|
||||||
//
|
//
|
||||||
// The three choropleth sources are created up front (empty until the base
|
// The three choropleth sources are created up front (empty until the base
|
||||||
// geo arrives) so the layer structure always exists; `paintCountries` then
|
// geo arrives) so the layer structure always exists; `paintRegions` then
|
||||||
// stamps ownership + colors onto the countries source. This makes it immune
|
// stamps ownership + colors onto the regions source. This makes it immune
|
||||||
// to load-order races: if `ensureLayers` runs before the geo fetch
|
// 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, the data is applied the moment the fetch lands; if the geo
|
||||||
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
|
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
|
||||||
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
|
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
|
||||||
// regions effect re-paints whenever ownership changes.
|
// regions effect re-paints whenever ownership changes.
|
||||||
const ensureSources = () => {
|
const ensureSources = () => {
|
||||||
if (!map.getSource(COUNTRIES_SOURCE)) {
|
if (!map.getSource(REGIONS_SOURCE)) {
|
||||||
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
|
map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||||
}
|
}
|
||||||
if (!map.getSource(HOVER_SOURCE)) {
|
if (!map.getSource(HOVER_SOURCE)) {
|
||||||
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
|
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).
|
// Stamp ownership + colors onto the regions source (no-op until geo loads).
|
||||||
const paintCountries = () => {
|
const paintRegions = () => {
|
||||||
const geo = baseGeoRef.current;
|
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;
|
if (!geo || !src) return;
|
||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
||||||
};
|
};
|
||||||
@@ -394,32 +442,39 @@ export default function WorldMap({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
ensureSources();
|
ensureSources();
|
||||||
if (baseGeoRef.current) {
|
if (baseGeoRef.current) {
|
||||||
paintCountries();
|
paintRegions();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await loadGeo();
|
await loadGeo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load countries geojson', err);
|
console.error('failed to load regions geojson', err);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
paintCountries();
|
paintRegions();
|
||||||
}
|
}
|
||||||
if (!map.getLayer(FILL_LAYER)) {
|
if (!map.getLayer(FILL_LAYER)) {
|
||||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||||
// colors read clearly even on the dark background / over the terrain.
|
// colors read clearly even on the dark background / over the terrain.
|
||||||
|
// Every sub-region carries its parent country's owner color, so a whole
|
||||||
|
// country reads as one uniform fill — same as the old country map, but
|
||||||
|
// subdivided.
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: COUNTRIES_SOURCE,
|
source: REGIONS_SOURCE,
|
||||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
|
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!map.getLayer(LINE_LAYER)) {
|
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({
|
map.addLayer({
|
||||||
id: LINE_LAYER,
|
id: LINE_LAYER,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: COUNTRIES_SOURCE,
|
source: REGIONS_SOURCE,
|
||||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
|
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
|
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||||
@@ -430,7 +485,7 @@ export default function WorldMap({
|
|||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: PLAYER_LINE,
|
id: PLAYER_LINE,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: COUNTRIES_SOURCE,
|
source: REGIONS_SOURCE,
|
||||||
filter: ['==', ['get', 'isPlayer'], 1],
|
filter: ['==', ['get', 'isPlayer'], 1],
|
||||||
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
|
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
|
// Re-color the choropleth whenever ownership changes, and flash any country
|
||||||
// whose owner changed since the last update (transient, self-cleaning).
|
// (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(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
@@ -494,7 +551,7 @@ export default function WorldMap({
|
|||||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||||
|
|
||||||
if (map && geo) {
|
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));
|
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
|
"include": ["src", "server", "shared", "e2e", "scripts", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['server/**/*.test.ts'],
|
include: ['server/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||||
testTimeout: 20000,
|
testTimeout: 20000,
|
||||||
hookTimeout: 20000,
|
hookTimeout: 20000,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user