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 type { RegionMap, RegionOwner } from '@shared/protocol'; import type { Nation } from '@shared/types'; import { cn } from '@/lib/utils'; // Minimal GeoJSON types (avoids a hard @types/geojson dependency). 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 country 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.5, 'raster-brightness-max': 0.8, 'raster-fade-duration': 200, }, }, ], }; const COUNTRIES_SOURCE = 'countries'; const HOVER_SOURCE = 'hover'; const FLASH_SOURCE = 'flash'; const FILL_LAYER = 'countries-fill'; const LINE_LAYER = 'countries-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 base geo. Each region * carries its owner's stable fill color, a subtle stroke, and a flag for the * player's own regions (rendered as a brighter, thicker border by a dedicated * layer). Unknown owners fall back to a faint neutral fill — never an error. */ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); for (const f of out.features) { f.properties = f.properties ?? {}; const r = f.id ? regions[f.id as string] : undefined; if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; f.properties.stroke = 'rgba(255,255,255,0.10)'; 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.06)'; f.properties.owner = ''; f.properties.isPlayer = 0; } } return out; } /** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */ function featuresByCodes(geo: FeatureCollection, codes: Iterable): FeatureCollection { const set = new Set(codes); const features = geo.features.filter((f) => f.id != null && set.has(String(f.id))); 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: country, its owner (color-chipped), and 1–2 key stats. */ function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string { if (!region || !nation) { return `
${esc(countryName || 'Unclaimed')}
No owner
`; } const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join(''); return `
${esc(countryName || region.owner)}
${esc(region.owner)}
${stats}
`; } /** Click popup: the full owning-nation profile with all four stats. */ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string { if (!region || !nation) { return `
${esc(countryName || 'Unclaimed')}
No owner
`; } const stats = [ statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy), statRow('Stab', nation.stats.stability), statRow('Pop', nation.stats.population), ].join(''); const regions = nation.territory.length; return `
${esc(countryName || region.owner)}
${esc(region.owner)}
${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier
${stats}
`; } interface Props { regions: RegionMap; playerNationId: string; /** All nations, for hover/click stats and color chips. */ nations: Record; /** The active scenario id, for a sensible initial camera. */ presetId: string; className?: string; /** Called when a region is clicked, so the dossier can focus its owner. */ onFocusNation?: (id: string) => void; } /** * World basemap (MapLibre GL) + a country choropleth colored by the owning * nation. 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 (the choropleth is the star), thin borders, a brighter/thicker * border for the player, hover + click popups with * owner + stats, a transient flash on territory changes, and a * per-scenario initial camera. 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); // 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 }); const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }); let lastHoverId: string | null = null; const loadGeo = (): Promise => baseGeoRef.current ? Promise.resolve(baseGeoRef.current) : fetch('/countries.geo.json') .then((r) => { if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`); return r.json(); }) .then((g: FeatureCollection) => { baseGeoRef.current = g; return g; }); 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. function animateFlash(codes: string[]) { const geo = baseGeoRef.current; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; if (!geo || !src || cancelled) return; src.setData(featuresByCodes(geo, codes)); 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; const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const id = f.id == null ? null : String(f.id); if (id && id !== lastHoverId) { lastHoverId = id; const geo = baseGeoRef.current; const feat = geo?.features.find((x) => x.id != null && String(x.id) === id); (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData( feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(), ); } const p = f.properties as { name?: string }; const region = id ? regionsRef.current[id] : undefined; const nation = region ? nationsRef.current[region.ownerId] : undefined; hoverPopup .setLngLat(e.lngLat) .setHTML(hoverHtml(p?.name ?? id ?? '', 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 = ''; }; const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const id = f.id == null ? null : String(f.id); const p = f.properties as { name?: string }; const region = id ? regionsRef.current[id] : undefined; const nation = region ? nationsRef.current[region.ownerId] : undefined; clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map); if (region?.ownerId) onFocusNationRef.current?.(region.ownerId); }; // 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; `paintCountries` then // stamps ownership + colors onto the countries 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(COUNTRIES_SOURCE)) { map.addSource(COUNTRIES_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 countries source (no-op until geo loads). const paintCountries = () => { const geo = baseGeoRef.current; const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined; if (!geo || !src) return; src.setData(applyRegions(geo, regionsRef.current, playerRef.current)); }; const ensureLayers = async () => { if (cancelled) return; ensureSources(); if (baseGeoRef.current) { paintCountries(); } else { try { await loadGeo(); } catch (err) { console.error('failed to load countries geojson', err); } if (cancelled) return; paintCountries(); } 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. map.addLayer({ id: FILL_LAYER, type: 'fill', source: COUNTRIES_SOURCE, paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 }, }); } if (!map.getLayer(LINE_LAYER)) { map.addLayer({ id: LINE_LAYER, type: 'line', source: COUNTRIES_SOURCE, paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, '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: COUNTRIES_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 any regions // whose owner changed since the last update (transient, self-cleaning). useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.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) { const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined; if (src) void src.setData(applyRegions(geo, cur, playerNationId)); } if (wasPrimed && prev) { const changed = new Set(); for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); } 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 (
); }