diff --git a/src/App.tsx b/src/App.tsx index a704bf4..7ba8a3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -591,12 +591,11 @@ function GameScreen({ const [viewingId, setViewingId] = useState(game.playerNationId); // When the dossier target was set by a map click, remember which sub-region // was clicked, for a small label in the dossier. The label shows only the - // region's name — the static parent-country code (which never updates on a - // transfer) is no longer displayed, though the info object still carries it. - // Cleared when the user picks a nation via the manual Inspect select. - const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null); + // region's name. Cleared when the user picks a nation via the manual Inspect + // select. + const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null); const focusNation = useCallback( - (id: string, info?: { parent?: string; name?: string }) => { + (id: string, info?: { name?: string }) => { setViewingId(id); setSelectedRegion(info ?? null); }, diff --git a/src/components/NationPanel.tsx b/src/components/NationPanel.tsx index e3dd212..ec326ec 100644 --- a/src/components/NationPanel.tsx +++ b/src/components/NationPanel.tsx @@ -1,8 +1,10 @@ +import { useEffect, useState } from 'react'; import { Badge } from './ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; import StatBar from './StatBar'; import { nationColor } from '@shared/protocol'; import { RELATION_COLORS } from '@/lib/colors'; +import { keyToName } from '@/lib/regions'; import type { Nation, RelationStatus } from '@shared/types'; /** Human labels for a diplomatic status (the raw value is kept in data-attrs). */ @@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b)); const regions = nation.territory; + // Human region names for the territory chips (region key -> name), loaded once + // (cached in @/lib/regions). A key with no known name falls back to the raw key. + const [keyNames, setKeyNames] = useState>({}); + useEffect(() => { + let cancelled = false; + keyToName() + .then((m) => { + if (!cancelled) setKeyNames(m); + }) + .catch(() => { + /* names are cosmetic — a failed fetch just leaves the raw keys */ + }); + return () => { + cancelled = true; + }; + }, []); + const identity = (
{regions.length > 0 ? (
- {regions.map((code) => ( - - {code} - - ))} + {regions.map((key) => { + const label = keyNames[key] ?? key; + return ( + + {label} + + ); + })}
) : (

No territory.

diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 30b407e..67e7c38 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -11,6 +11,7 @@ maplibregl.setWorkerUrl(workerUrl); import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import type { Nation } from '@shared/types'; import { cn } from '@/lib/utils'; +import { idToKey } from '@/lib/regions'; // 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 @@ -101,26 +102,32 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu /** * Stamp the current ownership + colors onto a clone of the sub-national base geo. - * Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation - * owns whole countries, so every sub-region of a country shares that country's - * owning nation's color — the map looks like the old country choropleth, but - * subdivided into finer cells. Each sub-region therefore carries: + * Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has + * its own region key (from `public/region-keys.json`, looked up by the feature's + * stable id) and the RegionMap is keyed by that region key, so a single + * sub-region can change owner while its neighbours keep theirs. Each feature + * therefore carries: * * - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that * survives into the features MapLibre hands back on click/hover * (the top-level `id` is repacked into a numeric index, #6); - * - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key; - * - `fill` – the owning nation's stable color (from `RegionMap[parent]`); - * - `owner`/`ownerId` – that nation's name/id (empty for an unowned country); + * - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key; + * - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`); + * - `owner`/`ownerId` – that nation's name/id (empty for an unowned region); * - `stroke` – a per-border alpha (owned brighter than unowned); * - `isPlayer` – 1 when the owning nation is the player (drives the amber * player-highlight border). * - * Crucially, the owner lookup keys the RegionMap by `properties.parent` (the - * country ISO_A3), **never** by the sub-region id — the RegionMap has no - * `FRA-03` entries, only `FRA`. + * Crucially, the owner lookup keys the RegionMap by the stamped + * `properties.regionKey`, **never** by the sub-region id or the repacked + * top-level `id` — the RegionMap has no `FRA-03` entries, only region keys. */ -function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { +function applyRegions( + geo: FeatureCollection, + regions: RegionMap, + playerNationId: string, + idKey: Record, +): FeatureCollection { const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); for (const f of out.features) { f.properties = f.properties ?? {}; @@ -128,11 +135,11 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId // survives into the rendered features (the top-level `id` gets repacked). const subId = f.id == null ? '' : String(f.id); f.properties.regionId = subId; - // Ownership lives at the parent country: resolve `properties.parent` - // (the ISO_A3) against the RegionMap. Never the sub-region id. - const parent = typeof f.properties.parent === 'string' ? f.properties.parent : ''; - f.properties.parent = parent; - const r = parent ? regions[parent] : undefined; + // Resolve this feature's **region key** from the manifest (id -> key) and + // key the RegionMap by it — never the sub-region id or the repacked id. + const key = idKey[subId] ?? ''; + f.properties.regionKey = key; + const r = key ? regions[key] : undefined; if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; @@ -151,16 +158,21 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId } /** - * Build a FeatureCollection of just the sub-regions whose **parent** country is - * in `parents` (cloned). Used by the territory-change flash: a turn changes a - * whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code - * must light up **all** of its sub-regions, not a single feature. + * Build a FeatureCollection of just the sub-regions whose **region key** is in + * `keys` (cloned). Used by the territory-change flash: a turn can change + * individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each + * changed region key lights up exactly the sub-region that owns it. */ -function featuresByParents(geo: FeatureCollection, parents: Iterable): FeatureCollection { - const set = new Set(parents); +function featuresByRegionKeys( + geo: FeatureCollection, + keys: Iterable, + idKey: Record, +): FeatureCollection { + const set = new Set(keys); const features = geo.features.filter((f) => { - const parent = f.properties?.parent; - return typeof parent === 'string' && set.has(parent); + const id = f.id == null ? '' : String(f.id); + const key = idKey[id] ?? ''; + return key !== '' && set.has(key); }); return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; } @@ -203,37 +215,36 @@ interface Props { className?: string; /** * Called with a region's owning-nation id when a sub-region is clicked, so the - * dossier switches to that nation. For a sub-region, the owner is the parent - * country's owning nation. The optional `info` carries the clicked sub-region's - * own id/name and its parent country's ISO_A3, for a small "which region" label - * in the dossier. Not called for unowned regions (the dossier keeps its - * current target). See the component doc for the full behavior. + * dossier switches to that nation. The optional `info` carries the clicked + * sub-region's name, 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, info?: { parent?: string; name?: string }) => void; + onFocusNation?: (id: string, info?: { name?: string }) => void; } /** * World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders * the finer `regions.geo.json` dataset: each country is subdivided into * ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border - * lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a - * nation owns whole countries, so all of a country's sub-regions share that - * country's owning-nation color. Rendered as a fixed, full-viewport background - * layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle - * terrain, per-nation stable fills (uniform per country, the choropleth is the - * star), thin sub-region borders, a brighter/thicker amber border for the - * player's countries, a hover tooltip (sub-region name + current 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. + * lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each + * sub-region has its own region key (from `public/region-keys.json`, looked up + * by the feature's stable id) and the RegionMap is keyed by that key, so a + * single sub-region can change owner independently of its neighbours. Rendered + * as a fixed, full-viewport background layer (z-0) behind the floating HUD + * windows: a dark-navy basemap with subtle terrain, per-nation stable fills, + * thin sub-region borders, a brighter/thicker amber border for the player's + * regions, a hover tooltip (sub-region name + current owner + key stats), a + * **click that switches the dossier to the sub-region's owning nation** (no + * popup — see #7), a transient flash when a sub-region's owner changes, and a + * per-scenario initial camera. * * Ownership for a hovered/clicked sub-region is resolved from the authoritative - * RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via + * RegionMap by its stamped **region key** (`properties.regionKey`, via * `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level * `id` (MapLibre repacks it into a numeric index at interaction time, #6) and - * never from the sub-region id (not a RegionMap key). Unowned countries (no - * RegionMap entry for the parent) simply do not change the dossier. + * never from the raw sub-region id (not a RegionMap key). Unowned sub-regions + * (no RegionMap entry for their key) simply do not change the dossier. * * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * — no floating control and no external API. @@ -249,6 +260,9 @@ export default function WorldMap({ const containerRef = useRef(null); const mapRef = useRef(null); const baseGeoRef = useRef(null); + // The region-key manifest (feature id -> region key), loaded once; used to + // stamp each feature's region key and to expand the flash to its sub-region. + const idKeyRef = useRef | null>(null); // Latest props, so the mount effect's (re)establish closure always sees them. const regionsRef = useRef(regions); const nationsRef = useRef(nations); @@ -304,17 +318,28 @@ export default function WorldMap({ return g; }); + // The region-key manifest (feature id -> region key), fetched once and + // cached in a ref so both the paint path and the flash can resolve keys. + const loadIdKey = (): Promise> => + idKeyRef.current + ? Promise.resolve(idKeyRef.current) + : idToKey().then((k) => { + idKeyRef.current = k; + return k; + }); + const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] }); // Transient flash: pulse a bright outline over the changed regions, then fade // to zero over a few seconds and self-clean. Idempotent per call. `codes` are - // parent-country ISO_A3 codes (a turn changes whole countries); each lights - // up all of that country's sub-regions. + // region keys (a turn can change individual sub-regions, v0.19 #16 P1); each + // lights up exactly the sub-region that owns that key. function animateFlash(codes: string[]) { const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; - if (!geo || !src || cancelled) return; - src.setData(featuresByParents(geo, codes)); + if (!geo || !idKey || !src || cancelled) return; + src.setData(featuresByRegionKeys(geo, codes, idKey)); if (!map.getLayer(FLASH_LINE)) { // Defensive re-add (normally created by ensureLayers). Keep it beneath the // hover highlight when that layer exists; otherwise just append. @@ -350,9 +375,9 @@ export default function WorldMap({ // The sub-region a hovered/clicked feature belongs to is identified by its // stable sub-region id in `properties.regionId` (stamped by `applyRegions`), // and its **owner** is resolved from the authoritative RegionMap via the - // feature's `properties.parent` (the parent country's ISO_A3) through + // feature's stamped `properties.regionKey` through // `mapFeatureOwner` — never from the repacked top-level `id` (a numeric - // index, #6) and never from the sub-region id (not a RegionMap key). + // index, #6) and never from the raw sub-region id (not a RegionMap key). const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; @@ -386,23 +411,19 @@ export default function WorldMap({ }; // #7: a click does NOT open a popup — it switches the dossier to the region's - // owning nation. For a sub-region, the owner is the **parent country's** - // owning nation, resolved from `properties.parent` (the ISO_A3) via the - // RegionMap (never the repacked top-level `f.id`, #6, and never the - // sub-region id). A sub-region whose parent country has no owner leaves the - // current dossier target unchanged — never a broken dossier. + // owning nation, resolved from the feature's stamped `properties.regionKey` + // via the RegionMap (never the repacked top-level `f.id`, #6, and never the + // raw sub-region id). A sub-region whose key has no owner leaves the current + // dossier target unchanged — never a broken dossier. const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const region = mapFeatureOwner(regionsRef.current, f); if (!region?.ownerId) return; - // Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so - // the dossier can show which region was selected. - const p = f.properties as { regionId?: string; parent?: string; name?: string }; - const info = { - parent: typeof p?.parent === 'string' ? p.parent : undefined, - name: typeof p?.name === 'string' ? p.name : undefined, - }; + // Carry the clicked sub-region's name so the dossier can show which region + // was selected. + const p = f.properties as { name?: string }; + const info = { name: typeof p?.name === 'string' ? p.name : undefined }; onFocusNationRef.current?.(region.ownerId, info); }; @@ -430,34 +451,40 @@ export default function WorldMap({ } }; - // Stamp ownership + colors onto the regions source (no-op until geo loads). + // Stamp ownership + colors onto the regions source (no-op until both the + // base geo and the region-key manifest are loaded). const paintRegions = () => { const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; - if (!geo || !src) return; - src.setData(applyRegions(geo, regionsRef.current, playerRef.current)); + if (!geo || !idKey || !src) return; + src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey)); }; const ensureLayers = async () => { if (cancelled) return; ensureSources(); - if (baseGeoRef.current) { - paintRegions(); - } else { + if (!baseGeoRef.current) { try { await loadGeo(); } catch (err) { console.error('failed to load regions geojson', err); } - if (cancelled) return; - paintRegions(); } + if (!idKeyRef.current) { + try { + await loadIdKey(); + } catch (err) { + console.error('failed to load region keys', err); + } + } + if (cancelled) return; + paintRegions(); if (!map.getLayer(FILL_LAYER)) { // The choropleth is the star: a high opacity so the vivid per-nation // colors read clearly even on the dark background / over the terrain. - // Every sub-region carries its parent country's owner color, so a whole - // country reads as one uniform fill — same as the old country map, but - // subdivided. + // Each sub-region carries its own owning nation's color (v0.19 #16 P1), + // so the fill traces region-key ownership, not country borders. map.addLayer({ id: FILL_LAYER, type: 'fill', @@ -537,28 +564,30 @@ export default function WorldMap({ }; }, []); - // Re-color the choropleth whenever ownership changes, and flash any country - // (all of its sub-regions) whose owner changed since the last update (a - // transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap; - // `animateFlash` expands each changed country to every one of its sub-regions. + // Re-color the choropleth whenever ownership changes, and flash each + // sub-region whose owner changed since the last update (a transient, + // self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19 + // #16 P1); `animateFlash` lights up exactly the sub-region for each changed + // key. useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; + const idKey = idKeyRef.current; const cur = regions; const prev = prevRegionsRef.current; prevRegionsRef.current = cur; const wasPrimed = primedRef.current; if (Object.keys(cur).length > 0) primedRef.current = true; - if (map && geo) { + if (map && geo && idKey) { const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; - if (src) void src.setData(applyRegions(geo, cur, playerNationId)); + if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey)); } - if (wasPrimed && prev) { + if (wasPrimed && prev && idKey) { const changed = new Set(); - for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { - if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); + for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) { + if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key); } if (changed.size > 0) flashApiRef.current?.([...changed]); } diff --git a/src/lib/regions.ts b/src/lib/regions.ts new file mode 100644 index 0000000..2c77296 --- /dev/null +++ b/src/lib/regions.ts @@ -0,0 +1,46 @@ +// Client-side region-key data access. Fetches the two static JSON files served +// from public/ and builds the lookups the region-level UI needs: +// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner) +// - keyToName(): region key -> human name (to show region names in the dossier) +// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel +// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a +// hardcoded origin. +type GeoLike = { + features?: Array<{ id?: string | number; properties?: { name?: string } }>; +}; + +let idKeyPromise: Promise> | null = null; +let keyNamePromise: Promise> | null = null; + +async function fetchJson(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`); + return (await r.json()) as T; +} + +/** feature id (from regions.geo.json) -> region key (from region-keys.json). */ +export function idToKey(): Promise> { + idKeyPromise ??= fetchJson>('/region-keys.json'); + return idKeyPromise; +} + +/** + * region key -> human region name. Built by joining region-keys.json (id -> key) + * with regions.geo.json (id -> properties.name). A key with no known name is + * simply absent (callers fall back to the raw key). + */ +export function keyToName(): Promise> { + keyNamePromise ??= Promise.all([idToKey(), fetchJson('/regions.geo.json')]).then( + ([keys, geo]) => { + const out: Record = {}; + for (const f of geo.features ?? []) { + const id = f.id == null ? '' : String(f.id); + const key = keys[id]; + const name = f.properties?.name; + if (key && typeof name === 'string') out[key] = name; + } + return out; + }, + ); + return keyNamePromise; +}