- src/lib/regions.ts (new): cached, turf-free same-origin fetches of /region-keys.json (feature id -> key) and /regions.geo.json (id -> name) so WorldMap and NationPanel share one fetch each. - WorldMap.tsx: stamps each feature's properties.regionKey (from the manifest, by its stable id) and resolves owner / flash / hover / click from that key via the shared mapFeatureOwner; the per-region flash lights the exact sub-region whose owner changed; click carries only the sub-region name. - NationPanel.tsx: territory chips show the region's human name (falling back to the raw key) with the key in the title / data-attr. - App.tsx: the selected-region dossier label drops the now-unused parent code.
608 lines
25 KiB
TypeScript
608 lines
25 KiB
TypeScript
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 `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
|
||
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
|
||
interface GeoFeature {
|
||
type: 'Feature';
|
||
id?: string | number;
|
||
properties?: Record<string, unknown>;
|
||
geometry: unknown;
|
||
}
|
||
interface FeatureCollection {
|
||
type: 'FeatureCollection';
|
||
features: GeoFeature[];
|
||
}
|
||
type GeoSource = maplibregl.GeoJSONSource;
|
||
|
||
// Inline basemap style — a visible dark-navy background plus a subtle
|
||
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
|
||
// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn
|
||
// on top and is the real star.
|
||
//
|
||
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
|
||
// are empty at world zoom, so only its very dark raster rendered and the map
|
||
// looked black, and `styledata` was gated on fetching a remote document. An
|
||
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
||
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
||
// raster has no vector tiles, so 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 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<string, string>,
|
||
): FeatureCollection {
|
||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||
for (const f of out.features) {
|
||
f.properties = f.properties ?? {};
|
||
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||
// survives into the rendered features (the top-level `id` gets repacked).
|
||
const subId = f.id == null ? '' : String(f.id);
|
||
f.properties.regionId = subId;
|
||
// Resolve this feature's **region key** from the manifest (id -> key) and
|
||
// key the RegionMap by it — never the sub-region id or the repacked id.
|
||
const key = idKey[subId] ?? '';
|
||
f.properties.regionKey = key;
|
||
const r = key ? regions[key] : undefined;
|
||
if (r) {
|
||
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<string>,
|
||
idKey: Record<string, string>,
|
||
): FeatureCollection {
|
||
const set = new Set(keys);
|
||
const features = geo.features.filter((f) => {
|
||
const id = f.id == null ? '' : String(f.id);
|
||
const key = idKey[id] ?? '';
|
||
return key !== '' && set.has(key);
|
||
});
|
||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||
}
|
||
|
||
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 `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">${Math.round(value)}</span></div>`;
|
||
}
|
||
|
||
/**
|
||
* Compact hover tooltip: the sub-region's name, its **current owner**
|
||
* (color-chipped), and 1–2 key stats. When the region's country has no owning
|
||
* nation, a minimal "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 `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || '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(subRegionName || region.owner)}</div>
|
||
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||
<div class="hm-stats">${stats}</div>
|
||
</div>`;
|
||
}
|
||
|
||
interface Props {
|
||
regions: RegionMap;
|
||
playerNationId: string;
|
||
/** All nations, for the hover tooltip's owner stats and color chips. */
|
||
nations: Record<string, Nation>;
|
||
/** The active scenario id, for a sensible initial camera. */
|
||
presetId: string;
|
||
className?: string;
|
||
/**
|
||
* 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<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||
// The region-key manifest (feature id -> region key), loaded once; used to
|
||
// stamp each feature's region key and to expand the flash to its sub-region.
|
||
const idKeyRef = useRef<Record<string, string> | null>(null);
|
||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||
const regionsRef = useRef(regions);
|
||
const nationsRef = useRef(nations);
|
||
const playerRef = useRef(playerNationId);
|
||
const presetRef = useRef(presetId);
|
||
const 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('/regions.geo.json')
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`);
|
||
return r.json();
|
||
})
|
||
.then((g: FeatureCollection) => {
|
||
baseGeoRef.current = g;
|
||
return g;
|
||
});
|
||
|
||
// The region-key manifest (feature id -> region key), fetched once and
|
||
// cached in a ref so both the paint path and the flash can resolve keys.
|
||
const loadIdKey = (): Promise<Record<string, string>> =>
|
||
idKeyRef.current
|
||
? Promise.resolve(idKeyRef.current)
|
||
: idToKey().then((k) => {
|
||
idKeyRef.current = k;
|
||
return k;
|
||
});
|
||
|
||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||
|
||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
||
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
|
||
// region keys (a turn can change individual sub-regions, v0.19 #16 P1); each
|
||
// lights up exactly the sub-region that owns that key.
|
||
function animateFlash(codes: string[]) {
|
||
const geo = baseGeoRef.current;
|
||
const idKey = idKeyRef.current;
|
||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||
if (!geo || !idKey || !src || cancelled) return;
|
||
src.setData(featuresByRegionKeys(geo, codes, idKey));
|
||
if (!map.getLayer(FLASH_LINE)) {
|
||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||
// hover highlight when that layer exists; otherwise just append.
|
||
map.addLayer(
|
||
{
|
||
id: FLASH_LINE,
|
||
type: 'line',
|
||
source: FLASH_SOURCE,
|
||
paint: { 'line-color': '#fbbf24', 'line-width': 2.6, 'line-opacity': 0 },
|
||
},
|
||
map.getLayer(HOVER_FILL) ? HOVER_FILL : undefined,
|
||
);
|
||
}
|
||
cancelAnimationFrame(flashRaf);
|
||
const start = performance.now();
|
||
const DURATION = 2600;
|
||
const tick = (now: number) => {
|
||
if (cancelled) return;
|
||
const t = Math.min(1, (now - start) / DURATION);
|
||
const pulse = 0.55 + 0.45 * Math.sin(t * Math.PI * 3);
|
||
const opacity = t >= 1 ? 0 : 0.95 * (1 - t) * pulse;
|
||
try {
|
||
map.setPaintProperty(FLASH_LINE, 'line-opacity', opacity);
|
||
} catch {
|
||
/* map removed mid-animation */
|
||
}
|
||
if (t < 1) flashRaf = requestAnimationFrame(tick);
|
||
};
|
||
flashRaf = requestAnimationFrame(tick);
|
||
}
|
||
flashApiRef.current = animateFlash;
|
||
|
||
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||
// and its **owner** is resolved from the authoritative RegionMap via the
|
||
// feature's stamped `properties.regionKey` through
|
||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||
// index, #6) and never from the raw sub-region id (not a RegionMap key).
|
||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||
const f = e.features?.[0];
|
||
if (!f) return;
|
||
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<string>();
|
||
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 (
|
||
<div
|
||
ref={containerRef}
|
||
data-testid="world-map"
|
||
className={cn('h-full w-full', className)}
|
||
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
|
||
/>
|
||
);
|
||
}
|