CI / build + unit + e2e (pull_request) Successful in 2m31s
v0.29.0 — the LLM can now target individual regions' stats via TurnResult.regionChanges (set/add per stat, clamped 0-100), applied after nation-wide stateChanges and before the weighted-median recompute. The map hover tooltip shows each region's own four stats. The stub stays deterministic (regionChanges: []). Fixes #46 (PR 2 of 2)
1019 lines
42 KiB
TypeScript
1019 lines
42 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
import * as maplibregl from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
// MapLibre v5+ does NOT auto-resolve its web worker in a Vite production bundle
|
||
// (it only worked in the dev server, which serves node_modules on demand). Without
|
||
// an explicit URL the worker fails to load and GeoJSON sources — including our
|
||
// country choropleth — can never process their data, so the map stays blank.
|
||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||
maplibregl.setWorkerUrl(workerUrl);
|
||
import {
|
||
mapFeatureOwner,
|
||
MARKER_TYPE_INFO,
|
||
nationColor,
|
||
regionFillStyle,
|
||
unownedTooltipLabel,
|
||
type RegionMap,
|
||
type RegionOwner,
|
||
} from '@shared/protocol';
|
||
import { MARKER_TYPES, type MapMarker, type Nation, type RegionStats } 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
|
||
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
|
||
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
|
||
interface GeoFeature {
|
||
type: 'Feature';
|
||
id?: string | number;
|
||
properties?: Record<string, unknown>;
|
||
geometry: unknown;
|
||
}
|
||
interface FeatureCollection {
|
||
type: 'FeatureCollection';
|
||
features: GeoFeature[];
|
||
}
|
||
type GeoSource = maplibregl.GeoJSONSource;
|
||
|
||
// Inline basemap style — a visible dark-navy background plus a subtle
|
||
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
|
||
// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn
|
||
// on top and is the real star.
|
||
//
|
||
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
|
||
// are empty at world zoom, so only its very dark raster rendered and the map
|
||
// looked black, and `styledata` was gated on fetching a remote document. An
|
||
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
||
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
||
// raster has no vector tiles, so the basemap itself needs no label layers —
|
||
// the choropleth provides the borders.)
|
||
//
|
||
// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is
|
||
// raster (its labels are baked into the PNG tiles), and the LLM-placed map
|
||
// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol
|
||
// layer — fully self-contained, so they render offline and deterministically
|
||
// without depending on any external font/glyph endpoint (the OpenFreeMap
|
||
// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come
|
||
// from the raster, not from glyphs).
|
||
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
|
||
|
||
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||
version: 8,
|
||
sources: {
|
||
terrain: {
|
||
type: 'raster',
|
||
tiles: NE2SR_TILES,
|
||
tileSize: 256,
|
||
maxzoom: 6,
|
||
attribution: '© <a href="https://openfreemap.org">OpenFreeMap</a>',
|
||
},
|
||
},
|
||
layers: [
|
||
{
|
||
id: 'background',
|
||
type: 'background',
|
||
paint: { 'background-color': '#0b1220' },
|
||
},
|
||
{
|
||
id: 'terrain',
|
||
type: 'raster',
|
||
source: 'terrain',
|
||
paint: {
|
||
'raster-opacity': 0.15,
|
||
'raster-brightness-max': 0.8,
|
||
'raster-fade-duration': 200,
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
const REGIONS_SOURCE = 'regions';
|
||
const HOVER_SOURCE = 'hover';
|
||
const FLASH_SOURCE = 'flash';
|
||
const FILL_LAYER = 'regions-fill';
|
||
const LINE_LAYER = 'regions-line';
|
||
const PLAYER_LINE = 'player-line';
|
||
const FLASH_LINE = 'flash-line';
|
||
const HOVER_FILL = 'hover-fill';
|
||
const HOVER_LINE = 'hover-line';
|
||
const MARKERS_SOURCE = 'markers';
|
||
const MARKER_SYMBOL = 'markers-symbol';
|
||
|
||
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
|
||
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
||
switch (presetId) {
|
||
case 'wwii-1939':
|
||
case 'no-wwii-1940':
|
||
return { center: [12, 48], zoom: 3.4 }; // Europe
|
||
case 'coldwar-1955':
|
||
return { center: [-18, 42], zoom: 2.4 }; // Atlantic: both superpowers
|
||
case 'modern-2025':
|
||
return { center: [10, 25], zoom: 1.7 }; // The whole world
|
||
case 'aegis-2400':
|
||
return { center: [10, 25], zoom: 1.6 }; // Far-future, global view
|
||
default:
|
||
return { center: [10, 25], zoom: 1.8 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||
* 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);
|
||
* - `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 brightest, no man's land next,
|
||
* plain-unowned faintest);
|
||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||
* player-highlight border).
|
||
*
|
||
* 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.
|
||
*
|
||
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
|
||
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
|
||
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
|
||
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
|
||
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
|
||
*/
|
||
function applyRegions(
|
||
geo: FeatureCollection,
|
||
regions: RegionMap,
|
||
playerNationId: string,
|
||
idKey: Record<string, string>,
|
||
unownedSet: ReadonlySet<string>,
|
||
): FeatureCollection {
|
||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||
for (const f of out.features) {
|
||
f.properties = f.properties ?? {};
|
||
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||
// survives into the rendered features (the top-level `id` gets repacked).
|
||
const subId = f.id == null ? '' : String(f.id);
|
||
f.properties.regionId = subId;
|
||
// 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) {
|
||
const style = regionFillStyle(r, false);
|
||
f.properties.fill = style.fill;
|
||
f.properties.stroke = style.stroke;
|
||
f.properties.owner = r.owner;
|
||
f.properties.ownerId = r.ownerId;
|
||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||
} else {
|
||
// Ownerless: visible grey only for keys released mid-game (no man's
|
||
// land); a preset that never assigned this sub-region stays faint.
|
||
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
|
||
f.properties.fill = style.fill;
|
||
f.properties.stroke = style.stroke;
|
||
f.properties.owner = '';
|
||
f.properties.ownerId = '';
|
||
f.properties.isPlayer = 0;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 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 featuresByRegionKeys(
|
||
geo: FeatureCollection,
|
||
keys: Iterable<string>,
|
||
idKey: Record<string, string>,
|
||
): FeatureCollection {
|
||
const set = new Set(keys);
|
||
const features = geo.features.filter((f) => {
|
||
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)) };
|
||
}
|
||
|
||
// --- Map markers (v0.27, #44) ------------------------------------------------
|
||
//
|
||
// The LLM places named point markers (cities, factories, armies, ports,
|
||
// capitals, fleets, rocket sites, gatherings) anchored to a region key. The
|
||
// engine positions each at its region's centroid (deterministic small offset)
|
||
// and recomputes its owner from the region's current owner every turn. The
|
||
// map renders them as a single top-most symbol layer: a per-type glyph
|
||
// character in front of the name, per-type color, dark-theme halo.
|
||
|
||
/**
|
||
* The GeoJSON behind the marker symbol layer: one Point per marker at
|
||
* `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the
|
||
* current owner's name + stable color (empty when the region is unowned).
|
||
* Pure; the caller passes the current marker list + nations record, so a
|
||
* renamed/removed owner is picked up on the next paint.
|
||
*/
|
||
function buildMarkersFC(markers: MapMarker[], nations: Record<string, Nation>): FeatureCollection {
|
||
const features = markers.map((m) => {
|
||
const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined;
|
||
return {
|
||
type: 'Feature' as const,
|
||
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] },
|
||
properties: {
|
||
id: m.id,
|
||
type: m.type,
|
||
name: m.name,
|
||
glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '',
|
||
owner: owner?.name ?? '',
|
||
ownerId: owner?.id ?? '',
|
||
ownerColor: owner ? nationColor(owner.id) : '',
|
||
},
|
||
};
|
||
});
|
||
return { type: 'FeatureCollection', features };
|
||
}
|
||
|
||
// Small canvas-path helpers for the marker icons (self-contained shapes — no
|
||
// unicode/font dependency, so they render identically in any environment).
|
||
function roundRectPath(
|
||
ctx: CanvasRenderingContext2D,
|
||
x: number,
|
||
y: number,
|
||
w: number,
|
||
h: number,
|
||
r: number,
|
||
): void {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.lineTo(x + w - r, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||
ctx.lineTo(x + w, y + h - r);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||
ctx.lineTo(x + r, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||
ctx.lineTo(x, y + r);
|
||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function starPath(
|
||
ctx: CanvasRenderingContext2D,
|
||
cx: number,
|
||
cy: number,
|
||
spikes: number,
|
||
outer: number,
|
||
inner: number,
|
||
): void {
|
||
let rot = (Math.PI / 2) * -1;
|
||
const step = Math.PI / spikes;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||
for (let i = 0; i < spikes; i++) {
|
||
rot += step;
|
||
ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner);
|
||
rot += step;
|
||
ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||
}
|
||
ctx.closePath();
|
||
}
|
||
|
||
/**
|
||
* (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct
|
||
* shape per type, filled with the type's color from the shared
|
||
* `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any
|
||
* choropleth fill. Added to the map as a named image and referenced by the
|
||
* symbol layer's `icon-image` — a real symbol layer, per-type color + form,
|
||
* with zero external font/glyph dependency.
|
||
*/
|
||
function drawMarkerIcon(type: MapMarker['type']): ImageData {
|
||
const S = 44;
|
||
const c = document.createElement('canvas');
|
||
c.width = S;
|
||
c.height = S;
|
||
const ctx = c.getContext('2d')!;
|
||
const cx = S / 2;
|
||
const cy = S / 2;
|
||
ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1';
|
||
ctx.strokeStyle = '#0b1220';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.lineJoin = 'round';
|
||
switch (type) {
|
||
case 'city':
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2);
|
||
break;
|
||
case 'factory': {
|
||
const h = S * 0.5;
|
||
roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5);
|
||
break;
|
||
}
|
||
case 'army': {
|
||
const r = S * 0.34;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - r);
|
||
ctx.lineTo(cx + r, cy + r * 0.8);
|
||
ctx.lineTo(cx - r, cy + r * 0.8);
|
||
ctx.closePath();
|
||
break;
|
||
}
|
||
case 'port': {
|
||
const r = S * 0.36;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - r);
|
||
ctx.lineTo(cx + r, cy);
|
||
ctx.lineTo(cx, cy + r);
|
||
ctx.lineTo(cx - r, cy);
|
||
ctx.closePath();
|
||
break;
|
||
}
|
||
case 'capital':
|
||
starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15);
|
||
break;
|
||
case 'fleet':
|
||
case 'gathering':
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2);
|
||
break;
|
||
case 'rocket': {
|
||
const w = S * 0.14;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - S * 0.36);
|
||
ctx.lineTo(cx + S * 0.22, cy - S * 0.04);
|
||
ctx.lineTo(cx + w, cy - S * 0.04);
|
||
ctx.lineTo(cx + w, cy + S * 0.34);
|
||
ctx.lineTo(cx - w, cy + S * 0.34);
|
||
ctx.lineTo(cx - w, cy - S * 0.04);
|
||
ctx.lineTo(cx - S * 0.22, cy - S * 0.04);
|
||
ctx.closePath();
|
||
break;
|
||
}
|
||
}
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
// fleet and gathering share a circle base; an inner mark tells them apart
|
||
// (fleet = a dark porthole dot, gathering = a white bullseye dot).
|
||
if (type === 'fleet' || type === 'gathering') {
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2);
|
||
ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff';
|
||
ctx.fill();
|
||
}
|
||
// Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe)
|
||
// rather than the canvas element — `addImage` accepts ImageData directly.
|
||
return ctx.getImageData(0, 0, S, S);
|
||
}
|
||
|
||
/** The hover tooltip for a marker: its type label + name, then the owner chip. */
|
||
function markerTooltipHtml(
|
||
type: string,
|
||
name: string,
|
||
owner: string,
|
||
ownerColor: string,
|
||
): string {
|
||
const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker';
|
||
const ownerLine = owner
|
||
? `<div class="hm-popup-owner"><span class="hm-chip" style="background:${ownerColor}"></span>${esc(owner)}</div>`
|
||
: '<div class="hm-popup-sub">No owner</div>';
|
||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(label)}: ${esc(name)}</div>${ownerLine}</div>`;
|
||
}
|
||
|
||
const esc = (s: string) =>
|
||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||
|
||
function statRow(label: string, value?: number): string {
|
||
if (value == null || Number.isNaN(value)) return '';
|
||
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: the sub-region's name, its **current owner**
|
||
* (color-chipped), and 1–2 key stats. When the region's country has no owning
|
||
* nation, a minimal card is shown instead; its sub-line says "No man's land"
|
||
* for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
|
||
* no preset ever assigned. The static parent-country code
|
||
* (`properties.parent`) is deliberately NOT printed here — it never updates
|
||
* when ownership is transferred, so only the live owner chip (resolved from
|
||
* the RegionMap) is shown.
|
||
*/
|
||
function hoverHtml(
|
||
subRegionName: string,
|
||
region?: RegionOwner,
|
||
nation?: Nation,
|
||
noMansLand = false,
|
||
regionKey?: string,
|
||
regionStats?: Record<string, RegionStats>,
|
||
): string {
|
||
if (!region || !nation) {
|
||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
||
}
|
||
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
|
||
// derived aggregate), looked up by region key. When the region has no stats
|
||
// entry (defensive — an owned region should always have one) every stat
|
||
// shows a placeholder dash. The dossier (NationPanel) still shows the
|
||
// nation's derived stats, so this tooltip is the one place a single region's
|
||
// numbers are visible on the map.
|
||
const rs = regionKey ? regionStats?.[regionKey] : undefined;
|
||
const row = (label: string, v?: number): string =>
|
||
rs == null
|
||
? `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">–</span></div>`
|
||
: statRow(label, v);
|
||
const stats = [
|
||
row('Mil', rs?.military),
|
||
row('Eco', rs?.economy),
|
||
row('Stab', rs?.stability),
|
||
row('Pop', rs?.population),
|
||
].join('');
|
||
return `<div class="hm-popup">
|
||
<div class="hm-popup-title">${esc(subRegionName || 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>`;
|
||
}
|
||
|
||
interface Props {
|
||
regions: RegionMap;
|
||
playerNationId: string;
|
||
/** All nations, for the hover tooltip's owner stats and color chips. */
|
||
nations: Record<string, Nation>;
|
||
/** The active scenario id, for a sensible initial camera. */
|
||
presetId: string;
|
||
className?: string;
|
||
/**
|
||
* (v0.22, #16 P3) Region keys currently in no man's land (released
|
||
* mid-game — `GameState.unowned`). Such regions render the distinct visible
|
||
* grey (vs the faint preset-unassigned look of every other ownerless
|
||
* region) and their hover tooltip says "No man's land" instead of "No
|
||
* owner".
|
||
*/
|
||
unowned?: string[];
|
||
/**
|
||
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||
* 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?: { name?: string }) => void;
|
||
/**
|
||
* (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM
|
||
* places/removes them over time, the engine positions each at its region's
|
||
* centroid and keeps its owner in sync with the region's owner. Rendered as
|
||
* a top-most symbol layer (per-type glyph + name, per-type color).
|
||
*/
|
||
markers?: MapMarker[];
|
||
/**
|
||
* (v0.29, #46 PR 2) The game's per-region stats (`GameState.regionStats`,
|
||
* keyed by region key). Used only by the hover tooltip to show a single
|
||
* region's own four 0–100 stats (a region with no entry shows placeholders).
|
||
* The dossier (NationPanel) is unaffected — it keeps the nation's derived
|
||
* stats.
|
||
*/
|
||
regionStats?: Record<string, RegionStats>;
|
||
}
|
||
|
||
/**
|
||
* 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 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 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 raw sub-region id (not a RegionMap key). Unowned sub-regions
|
||
* (no RegionMap entry for their key) simply do not change the dossier.
|
||
*
|
||
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
|
||
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
|
||
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
|
||
* other ownerless region (a preset never assigned it) keeps the faint look and
|
||
* a plain "No owner" sub-line. The colors/labels come from the shared
|
||
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
|
||
*
|
||
* (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`)
|
||
* render on a top-most symbol layer: each is its type's canvas-generated icon
|
||
* (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO`
|
||
* contract, dark-outlined) at the engine-derived position (region centroid +
|
||
* small deterministic offset). The icons are drawn into named map images
|
||
* (`marker-<type>`), so there is no external font/glyph dependency and markers
|
||
* render offline and deterministically. Hovering a marker shows a tooltip
|
||
* (type label + name + owner chip); clicking one switches the dossier to the
|
||
* owning nation (no-op when the marker's region is unowned). The region
|
||
* hover/click handlers detect a marker under the cursor (MapLibre layer events
|
||
* have no propagation) and act on the marker's data.
|
||
*
|
||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||
* — no floating control and no external API.
|
||
*/
|
||
export default function WorldMap({
|
||
regions,
|
||
playerNationId,
|
||
nations,
|
||
presetId,
|
||
className,
|
||
unowned,
|
||
onFocusNation,
|
||
markers,
|
||
regionStats,
|
||
}: Props) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||
// The region-key manifest (feature id -> region key), loaded once; used to
|
||
// stamp each feature's region key and to expand the flash to its sub-region.
|
||
const idKeyRef = useRef<Record<string, string> | null>(null);
|
||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||
const regionsRef = useRef(regions);
|
||
const nationsRef = useRef(nations);
|
||
const playerRef = useRef(playerNationId);
|
||
const presetRef = useRef(presetId);
|
||
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||
const onFocusNationRef = useRef(onFocusNation);
|
||
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
|
||
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
|
||
regionsRef.current = regions;
|
||
nationsRef.current = nations;
|
||
playerRef.current = playerNationId;
|
||
presetRef.current = presetId;
|
||
unownedRef.current = new Set(unowned ?? []);
|
||
onFocusNationRef.current = onFocusNation;
|
||
markersRef.current = markers ?? [];
|
||
regionStatsRef.current = regionStats ?? {};
|
||
|
||
// Diffing state for the transient territory-change flash (owned by this component
|
||
// so a resync with identical data never re-flashes).
|
||
const prevRegionsRef = useRef<RegionMap | null>(null);
|
||
const primedRef = useRef(false);
|
||
const flashApiRef = useRef<((codes: string[]) => void) | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current) return;
|
||
let cancelled = false;
|
||
let flashRaf = 0;
|
||
|
||
const cam = cameraForPreset(presetRef.current);
|
||
const map = new maplibregl.Map({
|
||
container: containerRef.current,
|
||
style: INLINE_STYLE,
|
||
center: cam.center,
|
||
zoom: cam.zoom,
|
||
minZoom: 1.25,
|
||
maxZoom: 8,
|
||
attributionControl: { compact: true },
|
||
});
|
||
mapRef.current = map;
|
||
// Test/debug handle on the DOM node so a Playwright `page.evaluate` can reach
|
||
// the live MapLibre instance (the ref itself isn't reachable from the page).
|
||
// Purely for testability; has no effect on rendering or behavior.
|
||
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
|
||
|
||
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
|
||
let lastHoverId: string | null = null;
|
||
|
||
const loadGeo = (): Promise<FeatureCollection> =>
|
||
baseGeoRef.current
|
||
? Promise.resolve(baseGeoRef.current)
|
||
: fetch('/regions.geo.json')
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`);
|
||
return r.json();
|
||
})
|
||
.then((g: FeatureCollection) => {
|
||
baseGeoRef.current = g;
|
||
return g;
|
||
});
|
||
|
||
// The region-key manifest (feature id -> region key), fetched once and
|
||
// cached in a ref so both the paint path and the flash can resolve keys.
|
||
const loadIdKey = (): Promise<Record<string, string>> =>
|
||
idKeyRef.current
|
||
? Promise.resolve(idKeyRef.current)
|
||
: idToKey().then((k) => {
|
||
idKeyRef.current = k;
|
||
return k;
|
||
});
|
||
|
||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||
|
||
// 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
|
||
// 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 || !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.
|
||
map.addLayer(
|
||
{
|
||
id: FLASH_LINE,
|
||
type: 'line',
|
||
source: FLASH_SOURCE,
|
||
paint: { 'line-color': '#fbbf24', 'line-width': 2.6, 'line-opacity': 0 },
|
||
},
|
||
map.getLayer(HOVER_FILL) ? HOVER_FILL : undefined,
|
||
);
|
||
}
|
||
cancelAnimationFrame(flashRaf);
|
||
const start = performance.now();
|
||
const DURATION = 2600;
|
||
const tick = (now: number) => {
|
||
if (cancelled) return;
|
||
const t = Math.min(1, (now - start) / DURATION);
|
||
const pulse = 0.55 + 0.45 * Math.sin(t * Math.PI * 3);
|
||
const opacity = t >= 1 ? 0 : 0.95 * (1 - t) * pulse;
|
||
try {
|
||
map.setPaintProperty(FLASH_LINE, 'line-opacity', opacity);
|
||
} catch {
|
||
/* map removed mid-animation */
|
||
}
|
||
if (t < 1) flashRaf = requestAnimationFrame(tick);
|
||
};
|
||
flashRaf = requestAnimationFrame(tick);
|
||
}
|
||
flashApiRef.current = animateFlash;
|
||
|
||
// 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 stamped `properties.regionKey` through
|
||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||
// 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;
|
||
// (v0.27, #44) A marker rendered on top of this region wins the hover.
|
||
// The region polygon is always beneath a marker, so this FILL_LAYER
|
||
// delegate fires in both cases; if a marker icon is under the cursor,
|
||
// show the marker's tooltip (name + type + owner) instead of the
|
||
// region's. `e.point` is the cursor's pixel position, so querying the
|
||
// marker layer there tests the icon's (small) hit area, not the whole
|
||
// region.
|
||
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||
if (markerHits.length > 0) {
|
||
const mp = (markerHits[0].properties ?? {}) as {
|
||
type?: string;
|
||
name?: string;
|
||
owner?: string;
|
||
ownerColor?: string;
|
||
};
|
||
hoverPopup
|
||
.setLngLat(e.lngLat)
|
||
.setHTML(
|
||
markerTooltipHtml(
|
||
typeof mp.type === 'string' ? mp.type : '',
|
||
typeof mp.name === 'string' ? mp.name : '',
|
||
typeof mp.owner === 'string' ? mp.owner : '',
|
||
typeof mp.ownerColor === 'string' ? mp.ownerColor : '',
|
||
),
|
||
)
|
||
.addTo(map);
|
||
map.getCanvas().style.cursor = 'pointer';
|
||
// A marker has no region outline to highlight; clear the previous one
|
||
// (and reset lastHoverId so moving back onto a region re-triggers it).
|
||
lastHoverId = null;
|
||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
|
||
return;
|
||
}
|
||
const p = f.properties as { name?: string; regionId?: string };
|
||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||
if (subId && subId !== lastHoverId) {
|
||
lastHoverId = subId;
|
||
const geo = baseGeoRef.current;
|
||
// The base GeoJSON feature's top-level `id` IS the sub-region id, so
|
||
// match on it to recover the exact feature for the hover-highlight
|
||
// outline (the feature MapLibre hands us has a repacked numeric `id`).
|
||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId);
|
||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
||
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
||
);
|
||
}
|
||
const region = mapFeatureOwner(regionsRef.current, f);
|
||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||
// (v0.22, #16 P3) whether this region is no man's land (released
|
||
// mid-game), so the ownerless tooltip can say so.
|
||
const pKey = f.properties?.regionKey;
|
||
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
||
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
||
hoverPopup
|
||
.setLngLat(e.lngLat)
|
||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
|
||
.addTo(map);
|
||
map.getCanvas().style.cursor = 'pointer';
|
||
};
|
||
|
||
const onHoverOut = () => {
|
||
lastHoverId = null;
|
||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
|
||
hoverPopup.remove();
|
||
map.getCanvas().style.cursor = '';
|
||
};
|
||
|
||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||
// 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;
|
||
// (v0.27, #44) A marker under the cursor: focus its owning nation
|
||
// (identical to its region's owner — the engine keeps a marker's owner in
|
||
// sync with the region) and carry the marker's name into the dossier.
|
||
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||
if (markerHits.length > 0) {
|
||
const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string };
|
||
const owner = typeof mp.ownerId === 'string' ? mp.ownerId : '';
|
||
if (owner) {
|
||
const info = typeof mp.name === 'string' ? { name: mp.name } : undefined;
|
||
onFocusNationRef.current?.(owner, info);
|
||
}
|
||
return;
|
||
}
|
||
const region = mapFeatureOwner(regionsRef.current, f);
|
||
if (!region?.ownerId) return;
|
||
// 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);
|
||
};
|
||
|
||
// Idempotent: (re)creates the sources + layers after any style (re)load and
|
||
// (re)applies the ownership data. (Vite serves MapLibre's worker
|
||
// automatically, so no setWorkerUrl is needed.)
|
||
//
|
||
// The three choropleth sources are created up front (empty until the base
|
||
// geo arrives) so the layer structure always exists; `paintRegions` then
|
||
// stamps ownership + colors onto the regions source. This makes it immune
|
||
// to load-order races: if `ensureLayers` runs before the geo fetch
|
||
// resolves, the data is applied the moment the fetch lands; if the geo
|
||
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
|
||
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
|
||
// regions effect re-paints whenever ownership changes.
|
||
const ensureSources = () => {
|
||
if (!map.getSource(REGIONS_SOURCE)) {
|
||
map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||
}
|
||
if (!map.getSource(HOVER_SOURCE)) {
|
||
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
|
||
}
|
||
if (!map.getSource(FLASH_SOURCE)) {
|
||
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
||
}
|
||
if (!map.getSource(MARKERS_SOURCE)) {
|
||
map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||
}
|
||
};
|
||
|
||
// 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 || !idKey || !src) return;
|
||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
||
};
|
||
|
||
// Stamp the current markers onto the marker source (v0.27, #44).
|
||
const paintMarkers = () => {
|
||
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||
if (!src) return;
|
||
src.setData(buildMarkersFC(markersRef.current, nationsRef.current));
|
||
};
|
||
|
||
// Register the eight canvas-generated marker-type icon images (v0.27, #44).
|
||
// Images are cleared on a style (re)load, so this is (re)run from
|
||
// `ensureLayers` (which fires on every `styledata`). Self-contained — no
|
||
// external font/glyph endpoint — so markers render offline and
|
||
// deterministically.
|
||
const ensureMarkerImages = async () => {
|
||
for (const type of MARKER_TYPES) {
|
||
const id = `marker-${type}`;
|
||
if (map.getImage(id)) continue;
|
||
await map.addImage(id, drawMarkerIcon(type));
|
||
}
|
||
};
|
||
|
||
const ensureLayers = async () => {
|
||
if (cancelled) return;
|
||
ensureSources();
|
||
if (!baseGeoRef.current) {
|
||
try {
|
||
await loadGeo();
|
||
} catch (err) {
|
||
console.error('failed to load regions geojson', err);
|
||
}
|
||
}
|
||
if (!idKeyRef.current) {
|
||
try {
|
||
await loadIdKey();
|
||
} catch (err) {
|
||
console.error('failed to load region keys', err);
|
||
}
|
||
}
|
||
if (cancelled) return;
|
||
paintRegions();
|
||
paintMarkers();
|
||
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.
|
||
// 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',
|
||
source: REGIONS_SOURCE,
|
||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
|
||
});
|
||
}
|
||
if (!map.getLayer(LINE_LAYER)) {
|
||
// Thin internal borders: every sub-region outline at a light weight so
|
||
// the subdivision reads within each country's uniform fill. The per-
|
||
// feature `stroke` alpha keeps owned borders a touch brighter than
|
||
// unowned ones (the v0.10 treatment, thinned for the finer grid).
|
||
map.addLayer({
|
||
id: LINE_LAYER,
|
||
type: 'line',
|
||
source: REGIONS_SOURCE,
|
||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.75, 'line-opacity': 0.95 },
|
||
});
|
||
}
|
||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||
// stack is: fill, base borders, player border, territory-change flash,
|
||
// hover brighten, hover outline, and (top-most) the LLM-placed map
|
||
// markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it
|
||
// throws if that layer does not exist yet on a fresh style load.
|
||
if (!map.getLayer(PLAYER_LINE)) {
|
||
map.addLayer({
|
||
id: PLAYER_LINE,
|
||
type: 'line',
|
||
source: REGIONS_SOURCE,
|
||
filter: ['==', ['get', 'isPlayer'], 1],
|
||
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
|
||
});
|
||
}
|
||
if (!map.getLayer(FLASH_LINE)) {
|
||
map.addLayer({
|
||
id: FLASH_LINE,
|
||
type: 'line',
|
||
source: FLASH_SOURCE,
|
||
paint: { 'line-color': '#fbbf24', 'line-width': 2.6, 'line-opacity': 0 },
|
||
});
|
||
}
|
||
if (!map.getLayer(HOVER_FILL)) {
|
||
map.addLayer({
|
||
id: HOVER_FILL,
|
||
type: 'fill',
|
||
source: HOVER_SOURCE,
|
||
paint: { 'fill-color': '#ffffff', 'fill-opacity': 0.16 },
|
||
});
|
||
}
|
||
if (!map.getLayer(HOVER_LINE)) {
|
||
map.addLayer({
|
||
id: HOVER_LINE,
|
||
type: 'line',
|
||
source: HOVER_SOURCE,
|
||
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
|
||
});
|
||
}
|
||
// (v0.27, #44) The LLM-placed map markers: a top-most symbol layer.
|
||
// Each marker renders its type's canvas-generated icon (a distinct shape,
|
||
// per-type color from the shared MARKER_TYPE_INFO contract) with a dark
|
||
// outline for legibility on any choropleth fill. The icon images are
|
||
// self-contained (no external font/glyph endpoint), so markers render
|
||
// offline and deterministically. `icon-image` is `marker-<type>`, the
|
||
// named canvas images registered by `ensureMarkerImages` above.
|
||
await ensureMarkerImages();
|
||
if (!map.getLayer(MARKER_SYMBOL)) {
|
||
map.addLayer({
|
||
id: MARKER_SYMBOL,
|
||
type: 'symbol',
|
||
source: MARKERS_SOURCE,
|
||
layout: {
|
||
'icon-image': ['concat', 'marker-', ['get', 'type']],
|
||
'icon-size': 0.8,
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
});
|
||
}
|
||
// Marker interactions: the markers sit above the choropleth, but they
|
||
// deliberately share the region (FILL_LAYER) handlers registered below.
|
||
// MapLibre layer-scoped events are independent per-layer delegates (no
|
||
// propagation, no stopPropagation), so a marker-on-region would fire
|
||
// both a marker and a region handler. Instead the region handlers detect
|
||
// a marker under the cursor (see onHover/onClick) and act on the
|
||
// marker's data; clicking a marker focuses its owning nation via the
|
||
// same path as clicking its region (the marker's owner == its region's
|
||
// owner).
|
||
map.off('mousemove', FILL_LAYER, onHover);
|
||
map.off('mouseout', FILL_LAYER, onHoverOut);
|
||
map.off('click', FILL_LAYER, onClick);
|
||
map.on('mousemove', FILL_LAYER, onHover);
|
||
map.on('mouseout', FILL_LAYER, onHoverOut);
|
||
map.on('click', FILL_LAYER, onClick);
|
||
};
|
||
|
||
if (map.isStyleLoaded()) void ensureLayers();
|
||
else map.on('styledata', () => void ensureLayers());
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
cancelAnimationFrame(flashRaf);
|
||
flashApiRef.current = null;
|
||
map.remove();
|
||
mapRef.current = null;
|
||
if (containerRef.current) {
|
||
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = undefined;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 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. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
|
||
// release/re-claim repaints the grey even if the RegionMap diff alone
|
||
// wouldn't catch it.
|
||
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;
|
||
const unownedSet = new Set(unowned ?? []);
|
||
|
||
if (map && geo && idKey) {
|
||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
|
||
}
|
||
|
||
if (wasPrimed && prev && idKey) {
|
||
const changed = new Set<string>();
|
||
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
|
||
}
|
||
// A release (owned → unowned) and a re-claim (unowned → owned) both show
|
||
// up as ownerId changes above, so they flash like any other transfer.
|
||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||
}
|
||
}, [regions, playerNationId, unowned]);
|
||
|
||
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
|
||
// changes (a turn placed/removed markers) or the nations record changes
|
||
// (a confirmed rename/merge changes an owner's display name). No-op until
|
||
// the map + source exist (the mount effect's ensureLayers applies the
|
||
// current data once the layer structure is in place).
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||
if (!src) return;
|
||
src.setData(buildMarkersFC(markers ?? [], nations));
|
||
}, [markers, nations]);
|
||
|
||
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||
// `position: relative`, which would otherwise win over a Tailwind class.
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
data-testid="world-map"
|
||
className={cn('h-full w-full', className)}
|
||
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
|
||
/>
|
||
);
|
||
}
|