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 `-` (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; 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: '© OpenFreeMap', }, }, 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, unownedSet: ReadonlySet, ): 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, idKey: Record, ): 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): 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 ? `
${esc(owner)}
` : '
No owner
'; return `
${esc(label)}: ${esc(name)}
${ownerLine}
`; } 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 `
${label}${Math.round(value)}
`; } /** * 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 { if (!region || !nation) { return `
${esc(subRegionName || 'Unclaimed')}
${unownedTooltipLabel(noMansLand)}
`; } // (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 ? `
${label}–
` : statRow(label, v); const stats = [ row('Mil', rs?.military), row('Eco', rs?.economy), row('Stab', rs?.stability), row('Pop', rs?.population), ].join(''); return `
${esc(subRegionName || region.owner)}
${esc(region.owner)}
${stats}
`; } interface Props { regions: RegionMap; playerNationId: string; /** All nations, for the hover tooltip's owner stats and color chips. */ nations: Record; /** 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; } /** * 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-`), 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(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); const playerRef = useRef(playerNationId); const presetRef = useRef(presetId); const unownedRef = useRef>(new Set(unowned ?? [])); const onFocusNationRef = useRef(onFocusNation); const markersRef = useRef(markers ?? []); // (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip. const regionStatsRef = useRef>(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(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 => 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> => 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-`, 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(); 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 (
); }