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, type RegionMap, type RegionOwner } from '@shared/protocol'; import type { Nation } from '@shared/types'; import { cn } from '@/lib/utils'; import { idToKey } from '@/lib/regions'; // Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth // source is the sub-national `regions.geo.json`: each feature is a sub-region with // 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 no glyphs endpoint is needed — we don't add // label layers; the choropleth provides the borders.) 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'; /** 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 brighter than unowned); * - `isPlayer` – 1 when the owning nation is the player (drives the amber * player-highlight border). * * Crucially, the owner lookup keys the RegionMap by the stamped * `properties.regionKey`, **never** by the sub-region id or the repacked * top-level `id` — the RegionMap has no `FRA-03` entries, only region keys. */ function applyRegions( geo: FeatureCollection, regions: RegionMap, playerNationId: string, idKey: Record, ): 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) { f.properties.fill = r.color; f.properties.owner = r.owner; f.properties.ownerId = r.ownerId; f.properties.stroke = 'rgba(255,255,255,0.30)'; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; } else { f.properties.fill = 'rgba(148,163,184,0.05)'; f.properties.stroke = 'rgba(255,255,255,0.14)'; 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)) }; } 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 "No owner" card is shown instead. 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): string { if (!region || !nation) { return `
${esc(subRegionName || 'Unclaimed')}
No owner
`; } const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].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; /** * 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; } /** * 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. * * 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, onFocusNation, }: 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 onFocusNationRef = useRef(onFocusNation); regionsRef.current = regions; nationsRef.current = nations; playerRef.current = playerNationId; presetRef.current = presetId; onFocusNationRef.current = onFocusNation; // 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; 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; hoverPopup .setLngLat(e.lngLat) .setHTML(hoverHtml(p?.name ?? subId, region, nation)) .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; 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() }); } }; // 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)); }; 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(); 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. 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 }, }); } 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. useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; const idKey = idKeyRef.current; const cur = regions; const prev = prevRegionsRef.current; prevRegionsRef.current = cur; const wasPrimed = primedRef.current; if (Object.keys(cur).length > 0) primedRef.current = true; if (map && geo && idKey) { const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey)); } 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); } if (changed.size > 0) flashApiRef.current?.([...changed]); } }, [regions, playerNationId]); // 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 (
); }