Flip the client to region-key resolution + region names in the dossier (v0.19 P1 of #16)
- 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.
This commit is contained in:
+4
-5
@@ -591,12 +591,11 @@ function GameScreen({
|
|||||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||||
// When the dossier target was set by a map click, remember which sub-region
|
// When the dossier target was set by a map click, remember which sub-region
|
||||||
// was clicked, for a small label in the dossier. The label shows only the
|
// was clicked, for a small label in the dossier. The label shows only the
|
||||||
// region's name — the static parent-country code (which never updates on a
|
// region's name. Cleared when the user picks a nation via the manual Inspect
|
||||||
// transfer) is no longer displayed, though the info object still carries it.
|
// select.
|
||||||
// Cleared when the user picks a nation via the manual Inspect select.
|
const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null);
|
||||||
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
|
|
||||||
const focusNation = useCallback(
|
const focusNation = useCallback(
|
||||||
(id: string, info?: { parent?: string; name?: string }) => {
|
(id: string, info?: { name?: string }) => {
|
||||||
setViewingId(id);
|
setViewingId(id);
|
||||||
setSelectedRegion(info ?? null);
|
setSelectedRegion(info ?? null);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
import StatBar from './StatBar';
|
import StatBar from './StatBar';
|
||||||
import { nationColor } from '@shared/protocol';
|
import { nationColor } from '@shared/protocol';
|
||||||
import { RELATION_COLORS } from '@/lib/colors';
|
import { RELATION_COLORS } from '@/lib/colors';
|
||||||
|
import { keyToName } from '@/lib/regions';
|
||||||
import type { Nation, RelationStatus } from '@shared/types';
|
import type { Nation, RelationStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
||||||
@@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||||
const regions = nation.territory;
|
const regions = nation.territory;
|
||||||
|
|
||||||
|
// Human region names for the territory chips (region key -> name), loaded once
|
||||||
|
// (cached in @/lib/regions). A key with no known name falls back to the raw key.
|
||||||
|
const [keyNames, setKeyNames] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
keyToName()
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setKeyNames(m);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* names are cosmetic — a failed fetch just leaves the raw keys */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const identity = (
|
const identity = (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -95,15 +114,20 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
</h3>
|
</h3>
|
||||||
{regions.length > 0 ? (
|
{regions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{regions.map((code) => (
|
{regions.map((key) => {
|
||||||
<span
|
const label = keyNames[key] ?? key;
|
||||||
key={code}
|
return (
|
||||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
<span
|
||||||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
key={key}
|
||||||
>
|
className="rounded border px-1.5 py-0.5 text-xs"
|
||||||
{code}
|
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||||||
</span>
|
title={key}
|
||||||
))}
|
data-region-key={key}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No territory.</p>
|
<p className="text-sm text-muted-foreground">No territory.</p>
|
||||||
|
|||||||
+111
-82
@@ -11,6 +11,7 @@ maplibregl.setWorkerUrl(workerUrl);
|
|||||||
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||||
import type { Nation } from '@shared/types';
|
import type { Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { idToKey } from '@/lib/regions';
|
||||||
|
|
||||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
|
// 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
|
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
|
||||||
@@ -101,26 +102,32 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||||||
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
|
* Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has
|
||||||
* owns whole countries, so every sub-region of a country shares that country's
|
* its own region key (from `public/region-keys.json`, looked up by the feature's
|
||||||
* owning nation's color — the map looks like the old country choropleth, but
|
* stable id) and the RegionMap is keyed by that region key, so a single
|
||||||
* subdivided into finer cells. Each sub-region therefore carries:
|
* 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
|
* - `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
|
* survives into the features MapLibre hands back on click/hover
|
||||||
* (the top-level `id` is repacked into a numeric index, #6);
|
* (the top-level `id` is repacked into a numeric index, #6);
|
||||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
|
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
|
||||||
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
|
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
|
||||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
|
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
|
||||||
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
||||||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||||||
* player-highlight border).
|
* player-highlight border).
|
||||||
*
|
*
|
||||||
* Crucially, the owner lookup keys the RegionMap by `properties.parent` (the
|
* Crucially, the owner lookup keys the RegionMap by the stamped
|
||||||
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
|
* `properties.regionKey`, **never** by the sub-region id or the repacked
|
||||||
* `FRA-03` entries, only `FRA`.
|
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
|
||||||
*/
|
*/
|
||||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
function applyRegions(
|
||||||
|
geo: FeatureCollection,
|
||||||
|
regions: RegionMap,
|
||||||
|
playerNationId: string,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
f.properties = f.properties ?? {};
|
f.properties = f.properties ?? {};
|
||||||
@@ -128,11 +135,11 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
// survives into the rendered features (the top-level `id` gets repacked).
|
// survives into the rendered features (the top-level `id` gets repacked).
|
||||||
const subId = f.id == null ? '' : String(f.id);
|
const subId = f.id == null ? '' : String(f.id);
|
||||||
f.properties.regionId = subId;
|
f.properties.regionId = subId;
|
||||||
// Ownership lives at the parent country: resolve `properties.parent`
|
// Resolve this feature's **region key** from the manifest (id -> key) and
|
||||||
// (the ISO_A3) against the RegionMap. Never the sub-region id.
|
// key the RegionMap by it — never the sub-region id or the repacked id.
|
||||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
const key = idKey[subId] ?? '';
|
||||||
f.properties.parent = parent;
|
f.properties.regionKey = key;
|
||||||
const r = parent ? regions[parent] : undefined;
|
const r = key ? regions[key] : undefined;
|
||||||
if (r) {
|
if (r) {
|
||||||
f.properties.fill = r.color;
|
f.properties.fill = r.color;
|
||||||
f.properties.owner = r.owner;
|
f.properties.owner = r.owner;
|
||||||
@@ -151,16 +158,21 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a FeatureCollection of just the sub-regions whose **parent** country is
|
* Build a FeatureCollection of just the sub-regions whose **region key** is in
|
||||||
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
|
* `keys` (cloned). Used by the territory-change flash: a turn can change
|
||||||
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
|
* individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each
|
||||||
* must light up **all** of its sub-regions, not a single feature.
|
* changed region key lights up exactly the sub-region that owns it.
|
||||||
*/
|
*/
|
||||||
function featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
|
function featuresByRegionKeys(
|
||||||
const set = new Set(parents);
|
geo: FeatureCollection,
|
||||||
|
keys: Iterable<string>,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
): FeatureCollection {
|
||||||
|
const set = new Set(keys);
|
||||||
const features = geo.features.filter((f) => {
|
const features = geo.features.filter((f) => {
|
||||||
const parent = f.properties?.parent;
|
const id = f.id == null ? '' : String(f.id);
|
||||||
return typeof parent === 'string' && set.has(parent);
|
const key = idKey[id] ?? '';
|
||||||
|
return key !== '' && set.has(key);
|
||||||
});
|
});
|
||||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||||
}
|
}
|
||||||
@@ -203,37 +215,36 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||||||
* dossier switches to that nation. For a sub-region, the owner is the parent
|
* dossier switches to that nation. The optional `info` carries the clicked
|
||||||
* country's owning nation. The optional `info` carries the clicked sub-region's
|
* sub-region's name, for a small "which region" label in the dossier. Not
|
||||||
* own id/name and its parent country's ISO_A3, for a small "which region" label
|
* called for unowned regions (the dossier keeps its current target). See the
|
||||||
* in the dossier. Not called for unowned regions (the dossier keeps its
|
* component doc for the full behavior.
|
||||||
* current target). See the component doc for the full behavior.
|
|
||||||
*/
|
*/
|
||||||
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
|
onFocusNation?: (id: string, info?: { name?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
||||||
* the finer `regions.geo.json` dataset: each country is subdivided into
|
* 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
|
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
|
||||||
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
|
* lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each
|
||||||
* nation owns whole countries, so all of a country's sub-regions share that
|
* sub-region has its own region key (from `public/region-keys.json`, looked up
|
||||||
* country's owning-nation color. Rendered as a fixed, full-viewport background
|
* by the feature's stable id) and the RegionMap is keyed by that key, so a
|
||||||
* layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle
|
* single sub-region can change owner independently of its neighbours. Rendered
|
||||||
* terrain, per-nation stable fills (uniform per country, the choropleth is the
|
* as a fixed, full-viewport background layer (z-0) behind the floating HUD
|
||||||
* star), thin sub-region borders, a brighter/thicker amber border for the
|
* windows: a dark-navy basemap with subtle terrain, per-nation stable fills,
|
||||||
* player's countries, a hover tooltip (sub-region name + current owner
|
* thin sub-region borders, a brighter/thicker amber border for the player's
|
||||||
* + key stats), a **click that switches the dossier to the sub-region's parent
|
* regions, a hover tooltip (sub-region name + current owner + key stats), a
|
||||||
* country's owning nation** (no popup — see #7), a transient flash when a
|
* **click that switches the dossier to the sub-region's owning nation** (no
|
||||||
* country's owner changes (lighting up all its sub-regions), and a per-scenario
|
* popup — see #7), a transient flash when a sub-region's owner changes, and a
|
||||||
* initial camera.
|
* per-scenario initial camera.
|
||||||
*
|
*
|
||||||
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||||
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
|
* RegionMap by its stamped **region key** (`properties.regionKey`, via
|
||||||
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
||||||
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
||||||
* never from the sub-region id (not a RegionMap key). Unowned countries (no
|
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
|
||||||
* RegionMap entry for the parent) simply do not change the dossier.
|
* (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)
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
* — no floating control and no external API.
|
* — no floating control and no external API.
|
||||||
@@ -249,6 +260,9 @@ export default function WorldMap({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const baseGeoRef = useRef<FeatureCollection | 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.
|
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||||||
const regionsRef = useRef(regions);
|
const regionsRef = useRef(regions);
|
||||||
const nationsRef = useRef(nations);
|
const nationsRef = useRef(nations);
|
||||||
@@ -304,17 +318,28 @@ export default function WorldMap({
|
|||||||
return 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: [] });
|
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
// 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
|
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
|
||||||
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
|
// region keys (a turn can change individual sub-regions, v0.19 #16 P1); each
|
||||||
// up all of that country's sub-regions.
|
// lights up exactly the sub-region that owns that key.
|
||||||
function animateFlash(codes: string[]) {
|
function animateFlash(codes: string[]) {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src || cancelled) return;
|
if (!geo || !idKey || !src || cancelled) return;
|
||||||
src.setData(featuresByParents(geo, codes));
|
src.setData(featuresByRegionKeys(geo, codes, idKey));
|
||||||
if (!map.getLayer(FLASH_LINE)) {
|
if (!map.getLayer(FLASH_LINE)) {
|
||||||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||||||
// hover highlight when that layer exists; otherwise just append.
|
// hover highlight when that layer exists; otherwise just append.
|
||||||
@@ -350,9 +375,9 @@ export default function WorldMap({
|
|||||||
// The sub-region a hovered/clicked feature belongs to is identified by its
|
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||||||
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||||||
// and its **owner** is resolved from the authoritative RegionMap via the
|
// and its **owner** is resolved from the authoritative RegionMap via the
|
||||||
// feature's `properties.parent` (the parent country's ISO_A3) through
|
// feature's stamped `properties.regionKey` through
|
||||||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||||
// index, #6) and never from the sub-region id (not a RegionMap key).
|
// index, #6) and never from the raw sub-region id (not a RegionMap key).
|
||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
@@ -386,23 +411,19 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||||
// owning nation. For a sub-region, the owner is the **parent country's**
|
// owning nation, resolved from the feature's stamped `properties.regionKey`
|
||||||
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
|
// via the RegionMap (never the repacked top-level `f.id`, #6, and never 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
|
||||||
// sub-region id). A sub-region whose parent country has no owner leaves the
|
// dossier target unchanged — never a broken dossier.
|
||||||
// current dossier target unchanged — never a broken dossier.
|
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const region = mapFeatureOwner(regionsRef.current, f);
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
if (!region?.ownerId) return;
|
if (!region?.ownerId) return;
|
||||||
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
|
// Carry the clicked sub-region's name so the dossier can show which region
|
||||||
// the dossier can show which region was selected.
|
// was selected.
|
||||||
const p = f.properties as { regionId?: string; parent?: string; name?: string };
|
const p = f.properties as { name?: string };
|
||||||
const info = {
|
const info = { name: typeof p?.name === 'string' ? p.name : undefined };
|
||||||
parent: typeof p?.parent === 'string' ? p.parent : undefined,
|
|
||||||
name: typeof p?.name === 'string' ? p.name : undefined,
|
|
||||||
};
|
|
||||||
onFocusNationRef.current?.(region.ownerId, info);
|
onFocusNationRef.current?.(region.ownerId, info);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,34 +451,40 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stamp ownership + colors onto the regions source (no-op until geo loads).
|
// Stamp ownership + colors onto the regions source (no-op until both the
|
||||||
|
// base geo and the region-key manifest are loaded).
|
||||||
const paintRegions = () => {
|
const paintRegions = () => {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src) return;
|
if (!geo || !idKey || !src) return;
|
||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureLayers = async () => {
|
const ensureLayers = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
ensureSources();
|
ensureSources();
|
||||||
if (baseGeoRef.current) {
|
if (!baseGeoRef.current) {
|
||||||
paintRegions();
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
await loadGeo();
|
await loadGeo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load regions geojson', err);
|
console.error('failed to load regions geojson', err);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
|
||||||
paintRegions();
|
|
||||||
}
|
}
|
||||||
|
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)) {
|
if (!map.getLayer(FILL_LAYER)) {
|
||||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||||
// colors read clearly even on the dark background / over the terrain.
|
// colors read clearly even on the dark background / over the terrain.
|
||||||
// Every sub-region carries its parent country's owner color, so a whole
|
// Each sub-region carries its own owning nation's color (v0.19 #16 P1),
|
||||||
// country reads as one uniform fill — same as the old country map, but
|
// so the fill traces region-key ownership, not country borders.
|
||||||
// subdivided.
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
@@ -537,28 +564,30 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-color the choropleth whenever ownership changes, and flash any country
|
// Re-color the choropleth whenever ownership changes, and flash each
|
||||||
// (all of its sub-regions) whose owner changed since the last update (a
|
// sub-region whose owner changed since the last update (a transient,
|
||||||
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
|
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
|
||||||
// `animateFlash` expands each changed country to every one of its sub-regions.
|
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
|
||||||
|
// key.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const cur = regions;
|
const cur = regions;
|
||||||
const prev = prevRegionsRef.current;
|
const prev = prevRegionsRef.current;
|
||||||
prevRegionsRef.current = cur;
|
prevRegionsRef.current = cur;
|
||||||
const wasPrimed = primedRef.current;
|
const wasPrimed = primedRef.current;
|
||||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||||
|
|
||||||
if (map && geo) {
|
if (map && geo && idKey) {
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
|
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPrimed && prev) {
|
if (wasPrimed && prev && idKey) {
|
||||||
const changed = new Set<string>();
|
const changed = new Set<string>();
|
||||||
for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||||
if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code);
|
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
|
||||||
}
|
}
|
||||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Client-side region-key data access. Fetches the two static JSON files served
|
||||||
|
// from public/ and builds the lookups the region-level UI needs:
|
||||||
|
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
|
||||||
|
// - keyToName(): region key -> human name (to show region names in the dossier)
|
||||||
|
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
|
||||||
|
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
|
||||||
|
// hardcoded origin.
|
||||||
|
type GeoLike = {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let idKeyPromise: Promise<Record<string, string>> | null = null;
|
||||||
|
let keyNamePromise: Promise<Record<string, string>> | null = null;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
|
||||||
|
export function idToKey(): Promise<Record<string, string>> {
|
||||||
|
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
|
||||||
|
return idKeyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* region key -> human region name. Built by joining region-keys.json (id -> key)
|
||||||
|
* with regions.geo.json (id -> properties.name). A key with no known name is
|
||||||
|
* simply absent (callers fall back to the raw key).
|
||||||
|
*/
|
||||||
|
export function keyToName(): Promise<Record<string, string>> {
|
||||||
|
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
|
||||||
|
([keys, geo]) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = keys[id];
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (key && typeof name === 'string') out[key] = name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return keyNamePromise;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user