- 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.
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Badge } from './ui/badge';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||
import StatBar from './StatBar';
|
||
import { nationColor } from '@shared/protocol';
|
||
import { RELATION_COLORS } from '@/lib/colors';
|
||
import { keyToName } from '@/lib/regions';
|
||
import type { Nation, RelationStatus } from '@shared/types';
|
||
|
||
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
||
const RELATION_LABEL: Record<RelationStatus, string> = {
|
||
ally: 'Ally',
|
||
neutral: 'Neutral',
|
||
rival: 'Rival',
|
||
hostile: 'Hostile',
|
||
war: 'At war',
|
||
};
|
||
|
||
interface Props {
|
||
nation: Nation;
|
||
nations: Record<string, Nation>;
|
||
/** True when this is the nation the player is leading. */
|
||
isPlayer: boolean;
|
||
/**
|
||
* Render without the outer card chrome (the surrounding floating window
|
||
* provides the border, backdrop, and title bar). Same content, same testids.
|
||
*/
|
||
inWindow?: boolean;
|
||
}
|
||
|
||
/**
|
||
* A single nation's dossier: identity + color chip, the four 0–100 stat bars
|
||
* (colored by value: low = red → high = green, animated on change), territory as
|
||
* color-coded region chips, and diplomacy as a list with a colored indicator per
|
||
* relation (ally = green, rival = amber, war = red, neutral = slate). Used for
|
||
* the player (highlighted) or any inspected nation.
|
||
*/
|
||
export default function NationPanel({ nation, nations, isPlayer, inWindow = false }: Props) {
|
||
const color = nationColor(nation.id);
|
||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||
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 = (
|
||
<div className="flex items-center gap-3">
|
||
<span
|
||
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
|
||
style={{ background: color }}
|
||
data-testid="nation-color-chip"
|
||
/>
|
||
<div className="min-w-0 flex-1 space-y-0.5">
|
||
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
|
||
<CardDescription>
|
||
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
|
||
</CardDescription>
|
||
</div>
|
||
{isPlayer && (
|
||
<Badge variant="default" className="shrink-0">
|
||
Your nation
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const sections = (
|
||
<div className="space-y-5">
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
Status
|
||
</h3>
|
||
<div className="space-y-2">
|
||
<StatBar
|
||
label="Military"
|
||
value={nation.stats.military}
|
||
testId={isPlayer ? 'stat-military' : undefined}
|
||
/>
|
||
<StatBar
|
||
label="Economy"
|
||
value={nation.stats.economy}
|
||
testId={isPlayer ? 'stat-economy' : undefined}
|
||
/>
|
||
<StatBar
|
||
label="Stability"
|
||
value={nation.stats.stability}
|
||
testId={isPlayer ? 'stat-stability' : undefined}
|
||
/>
|
||
<StatBar
|
||
label="Population"
|
||
value={nation.stats.population}
|
||
testId={isPlayer ? 'stat-population' : undefined}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
Territory
|
||
</h3>
|
||
{regions.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{regions.map((key) => {
|
||
const label = keyNames[key] ?? key;
|
||
return (
|
||
<span
|
||
key={key}
|
||
className="rounded border px-1.5 py-0.5 text-xs"
|
||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||
title={key}
|
||
data-region-key={key}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No territory.</p>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
Diplomacy
|
||
</h3>
|
||
{relations.length > 0 ? (
|
||
<ul className="space-y-1.5" data-testid="relations-list">
|
||
{relations.map(([id, status]) => {
|
||
const other = nations[id];
|
||
const c = RELATION_COLORS[status] ?? '#94a3b8';
|
||
return (
|
||
<li
|
||
key={id}
|
||
className="flex items-center gap-2 text-sm"
|
||
data-testid="relation-row"
|
||
data-status={status}
|
||
>
|
||
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
|
||
<span className="min-w-0 flex-1 truncate text-foreground/85">
|
||
{other?.name ?? id}
|
||
</span>
|
||
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
|
||
{RELATION_LABEL[status] ?? status}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
if (inWindow) {
|
||
return (
|
||
<div data-testid="nation-panel" className="space-y-4 p-4">
|
||
{identity}
|
||
{sections}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
|
||
<CardHeader className="space-y-0">{identity}</CardHeader>
|
||
<CardContent>{sections}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|