Files
History/src/components/WorldMap.tsx
T
kamilkrzywda cdabf16052
CI / build + unit + e2e (pull_request) Successful in 2m31s
feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
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)
2026-10-15 01:06:53 +00:00

1019 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 }}
/>
);
}