Polish map & UI into a command-center war-room; rewire live LLM endpoint
Map (WorldMap): - Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that host) so the choropleth nation colors pop - Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter thicker amber border for the player nation so "this is me" is always obvious - Hover brightens a region and shows a color-chipped owner + key stats popup; click opens a full dossier popup and focuses the left panel on that nation - Transient, self-cleaning flash (pulsing amber outline) over regions whose ownership changed this turn - Bottom-left legend auto-generated from the region map; per-scenario initial camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis) Command-center UI: - New header bar: scenario title, prominent turn + date, the player nation (name + color chip), and a live Idle / "Simulating…" status indicator - 3-column operations-console grid: nation dossier (left) / dominant map (center) / command deck + event feed (right); stacks map-first on mobile - EventFeed: one card per event with a category icon, a colored outcome badge (success/failure/partial/neutral), a nation-colored accent edge, and the turn + date; new events slide in and flash at the top - NationPanel: value-colored animated stat bars (red → green), territory as color-coded region chips, and diplomacy as a list with a colored indicator per relation (ally/rival/war/neutral) - ActionPanel: a "Your move" composer with one prominent Send button and a spinner + "turns can take a few seconds" hint while a turn resolves - sonner error toasts: a 502 / model-down surfaces as "AI temporarily unavailable — try again" Live LLM rewire (server, previously uncommitted): - Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its chain-of-thought before emitting content; read only `content`, ignore `reasoning`. docker-compose + AGENTS.md updated to match. Tests: the deterministic Playwright e2e gains assertions for the dossier stat bars, the map legend (all nations), and the event-card type icon + outcome badge; it stays fully deterministic under LLM_STUB=1.
This commit is contained in:
+305
-39
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { RegionMap } from '@shared/protocol';
|
||||
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 {
|
||||
@@ -14,18 +16,47 @@ interface FeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: GeoFeature[];
|
||||
}
|
||||
type GeoSource = maplibregl.GeoJSONSource;
|
||||
|
||||
// Remote OpenFreeMap dark basemap ("command center" look).
|
||||
// Remote OpenFreeMap "dark" basemap — a near-black (rgb 12,12,12) canvas so the
|
||||
// choropleth nation colors pop. (Verified HTTP 200; the suggested dark-matter /
|
||||
// dark-outdoors / liberty-dark variants do not exist on that host.)
|
||||
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
|
||||
|
||||
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';
|
||||
|
||||
function applyRegions(
|
||||
geo: FeatureCollection,
|
||||
regions: RegionMap,
|
||||
playerNationId: string,
|
||||
): FeatureCollection {
|
||||
/** 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 ?? {};
|
||||
@@ -33,12 +64,11 @@ function applyRegions(
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
// Highlight the player's regions with a bright stroke.
|
||||
f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)';
|
||||
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.06)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.08)';
|
||||
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;
|
||||
}
|
||||
@@ -46,42 +76,144 @@ function applyRegions(
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;
|
||||
}
|
||||
|
||||
/** Click popup: the full owning-nation profile with all four stats. */
|
||||
function clickHtml(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),
|
||||
statRow('Stab', nation.stats.stability),
|
||||
statRow('Pop', nation.stats.population),
|
||||
].join('');
|
||||
const regions = nation.territory.length;
|
||||
return `<div class="hm-popup">
|
||||
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
|
||||
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
|
||||
<div class="hm-stats">${stats}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Small bottom-left legend mapping each nation on the map to its stable color. */
|
||||
function MapLegend({ regions }: { regions: RegionMap }) {
|
||||
const owners = new Map<string, { color: string; owner: string }>();
|
||||
for (const r of Object.values(regions)) {
|
||||
if (r.ownerId && !owners.has(r.ownerId)) owners.set(r.ownerId, { color: r.color, owner: r.owner });
|
||||
}
|
||||
const list = [...owners.entries()].sort((a, b) => a[1].owner.localeCompare(b[1].owner));
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="map-legend"
|
||||
className="pointer-events-none absolute bottom-3 left-3 z-10 max-h-[62%] overflow-hidden rounded-lg border bg-card/85 p-2.5 backdrop-blur-sm"
|
||||
>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Nations
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{list.map(([id, o]) => (
|
||||
<li key={id} data-testid="map-legend-item" className="flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: o.color }} />
|
||||
<span className="truncate">{o.owner}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
regions: RegionMap;
|
||||
playerNationId: string;
|
||||
/** All nations, for hover/click stats and color chips. */
|
||||
nations: Record<string, Nation>;
|
||||
/** 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. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers`
|
||||
* re-establish on `styledata`), stripped of all flight-specific data/logic.
|
||||
* nation. The choropleth is the star: a near-black basemap, per-nation stable
|
||||
* fills, subtle borders, a brighter/thicker border for the player, hover + click
|
||||
* popups with owner + stats, a transient flash on territory changes, a
|
||||
* bottom-left legend, and a per-scenario initial camera.
|
||||
*/
|
||||
export default function WorldMap({ regions, playerNationId, className }: Props) {
|
||||
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: STYLE_URL,
|
||||
center: [15, 50],
|
||||
zoom: 2.5,
|
||||
center: cam.center,
|
||||
zoom: cam.zoom,
|
||||
minZoom: 1.25,
|
||||
maxZoom: 8,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = 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<FeatureCollection> =>
|
||||
baseGeoRef.current
|
||||
? Promise.resolve(baseGeoRef.current)
|
||||
@@ -95,19 +227,89 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
|
||||
return g;
|
||||
});
|
||||
|
||||
const onCountryClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
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 p = f.properties as { owner?: string; name?: string; isPlayer?: number };
|
||||
const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed';
|
||||
new maplibregl.Popup({ offset: 8 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setText(label)
|
||||
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';
|
||||
};
|
||||
|
||||
// Idempotent: re-adds the countries source + layers after any setStyle, and on
|
||||
// first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
|
||||
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)adds the sources + layers after any setStyle and on first
|
||||
// load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
|
||||
const ensureLayers = async () => {
|
||||
let geo: FeatureCollection;
|
||||
try {
|
||||
@@ -123,15 +325,18 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
|
||||
data: applyRegions(geo, regionsRef.current, playerRef.current),
|
||||
});
|
||||
}
|
||||
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.getLayer(FILL_LAYER)) {
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'fill-color': ['get', 'fill'],
|
||||
'fill-opacity': 0.55,
|
||||
},
|
||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.8 },
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(LINE_LAYER)) {
|
||||
@@ -139,14 +344,52 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'line-color': ['get', 'stroke'],
|
||||
'line-width': 0.75,
|
||||
},
|
||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.6, 'line-opacity': 0.9 },
|
||||
});
|
||||
}
|
||||
map.off('click', FILL_LAYER, onCountryClick);
|
||||
map.on('click', FILL_LAYER, onCountryClick);
|
||||
// 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();
|
||||
@@ -154,19 +397,42 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(flashRaf);
|
||||
flashApiRef.current = null;
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-color the choropleth whenever the region ownership changes (each turn).
|
||||
// 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;
|
||||
if (!map || !geo) return;
|
||||
const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined;
|
||||
if (src) void src.setData(applyRegions(geo, regions, playerNationId));
|
||||
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]);
|
||||
|
||||
return <div ref={containerRef} data-testid="world-map" className={className} />;
|
||||
return (
|
||||
<div className={cn('relative h-full w-full', className)}>
|
||||
<div ref={containerRef} data-testid="world-map" className="h-full w-full" />
|
||||
<MapLegend regions={regions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user