Files
History/src/components/NationPanel.tsx
T
kamilkrzywda 29927bae34 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.
2026-10-14 09:14:17 +00:00

187 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}