map: click a region to switch the dossier to its owner; fix "no owner" (v0.7.0)
CI / build + unit + e2e (pull_request) Successful in 1m58s

This commit is contained in:
2026-10-11 19:22:33 +00:00
parent 67f48fb99c
commit eadae4dc4f
6 changed files with 162 additions and 44 deletions
+59 -41
View File
@@ -8,7 +8,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// 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 { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
@@ -101,21 +101,36 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* 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 ?? {};
const r = f.id ? regions[f.id as string] : undefined;
// 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.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.ownerId = '';
f.properties.isPlayer = 0;
}
}
@@ -150,35 +165,19 @@ function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation):
</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>`;
}
interface Props {
regions: RegionMap;
playerNationId: string;
/** All nations, for hover/click stats and color chips. */
/** 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 when a region is clicked, so the dossier can focus its owner. */
/**
* 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;
}
@@ -187,10 +186,18 @@ interface Props {
* 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.
* 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,
@@ -243,7 +250,6 @@ export default function WorldMap({
(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<FeatureCollection> =>
@@ -300,24 +306,33 @@ export default function WorldMap({
}
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 id = f.id == null ? null : String(f.id);
if (id && id !== lastHoverId) {
lastHoverId = id;
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;
const feat = geo?.features.find((x) => x.id != null && String(x.id) === id);
// 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 p = f.properties as { name?: string };
const region = id ? regionsRef.current[id] : undefined;
const region = regionOwner(regionsRef.current, code);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation))
.setHTML(hoverHtml(p?.name ?? code, region, nation))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -329,14 +344,17 @@ export default function WorldMap({
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 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);
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);
};