Files
History/src/components/WorldMap.tsx
T
kamilkrzywda 7d50cba061
CI / build + unit + e2e (pull_request) Successful in 2m13s
Fix map coastal-water bleed-through and border lines (v0.10.0)
Fixes #2
2026-10-11 21:26:25 +00:00

522 lines
21 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 { regionOwner, type RegionMap, type 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<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 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: '© <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 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.
*
* Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
* stores it, so a later click/hover event exposes that number on
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
* interaction time (the root cause of #6). `properties`, however, survive into the
* rendered features, so the interaction handlers resolve the owning nation from
* `properties.regionId` via the RegionMap (see `regionOwner`).
*/
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 ?? {};
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
// survives into the features MapLibre hands back on click/hover.
const code = f.id == null ? '' : String(f.id);
f.properties.regionId = code;
const r = code ? regions[code] : 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 features whose id is in `codes` (cloned). */
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): 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) => ({ '&': '&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: country, its owner (color-chipped), and 1–2 key stats. */
function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || 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;
/**
* Called with a region's owning-nation id when that region is clicked, so the
* dossier switches to that nation. Not called for unowned regions (the dossier
* keeps its current target). See the component doc for the full behavior.
*/
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, a hover tooltip (owner + key stats), a **click that
* switches the dossier to the region's owning nation** (no popup — see #7), a
* transient flash on territory changes, and a per-scenario initial camera.
*
* Ownership for a hovered/clicked region is resolved from the authoritative
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
* Unowned regions (no RegionMap entry) 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<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const baseGeoRef = useRef<FeatureCollection | 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<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('/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;
// The region a hovered/clicked feature belongs to is identified by its stable
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
// source's `id` into a numeric index when it stores the source, so the value
// exposed on `e.features[i].id` at interaction time is a number, never the
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const p = f.properties as { name?: string; regionId?: string };
const code = typeof p?.regionId === 'string' ? p.regionId : '';
if (code && code !== lastHoverId) {
lastHoverId = code;
const geo = baseGeoRef.current;
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
// that to recover the feature for the hover-highlight outline (the
// feature MapLibre hands us has a repacked numeric `id` we can't use).
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
);
}
const region = regionOwner(regionsRef.current, code);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? code, 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. The owner is resolved from the stable ISO_A3 in
// `properties.regionId` via the RegionMap (never the repacked top-level
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
// leaves the current dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const p = f.properties as { regionId?: string };
const code = typeof p?.regionId === 'string' ? p.regionId : '';
const region = regionOwner(regionsRef.current, code);
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': 1.0 },
});
}
if (!map.getLayer(LINE_LAYER)) {
map.addLayer({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, '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<string>();
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 (
<div
ref={containerRef}
data-testid="world-map"
className={cn('h-full w-full', className)}
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
/>
);
}