Files
galaxy-game/ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte
T
Ilia Denisov 4ad96b0ef7
Tests · UI / test (push) Successful in 2m11s
Tests · UI / test (pull_request) Successful in 2m7s
feat(ui): migrate all view bodies to design tokens (F1b)
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.

Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.

Updates ui/docs/design-system.md (migration status + exceptions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:24:02 +02:00

339 lines
9.3 KiB
Svelte

<!--
Phase 16 cargo-routes subsection of the planet inspector. Shows a
fixed COL/CAP/MAT/EMP four-slot table for the active local planet,
each slot either empty (with a single Add button) or filled (with
the destination planet's name plus Edit and Remove buttons). Add
and Edit hand off to the renderer-driven `MapPickService`: the map
dims out-of-reach planets, draws the cursor-line anchor, and
resolves with either a chosen destination id or `null` (cancel).
The component is purposely deferential to the existing infrastructure:
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule,
so the optimistic overlay always matches what the server sees.
- `MapPickService.pick(...)` is a renderer-side abstraction; its
source/destination semantics live in `lib/active-view/map.svelte`.
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`)
is computed inline using `torusShortestDelta` to mirror the
engine's torus distance — see `pkg/util/map.go.deltas`.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportPlanet, ReportRoute } from "../../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { torusShortestDelta } from "../../../map/math";
import {
MAP_PICK_CONTEXT_KEY,
type MapPickService,
} from "$lib/map-pick.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import {
CARGO_LOAD_TYPE_VALUES,
type CargoLoadType,
} from "../../../sync/order-types";
type Props = {
planet: ReportPlanet;
routes: ReportRoute[];
planets: ReportPlanet[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
};
let {
planet,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
}: Props = $props();
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const pick = getContext<MapPickService | undefined>(MAP_PICK_CONTEXT_KEY);
const disabled = $derived(draft === undefined || pick === undefined);
let pendingSlot: CargoLoadType | null = $state(null);
$effect(() => {
// Reset the in-flight slot whenever the inspector switches to a
// different planet so a stale "pick in progress" prompt does
// not leak across the selection boundary.
void planet.number;
pendingSlot = null;
});
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
COL: "game.inspector.planet.cargo.slot.col",
CAP: "game.inspector.planet.cargo.slot.cap",
MAT: "game.inspector.planet.cargo.slot.mat",
EMP: "game.inspector.planet.cargo.slot.emp",
};
const currentEntries = $derived(
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
);
// Per-slot derived map keeps the template's {#each} block free of
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks
// when the source array is freshly cloned by `applyOrderOverlay`.
const slotEntries = $derived.by(() => {
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
COL: null,
CAP: null,
MAT: null,
EMP: null,
};
for (const entry of currentEntries) {
map[entry.loadType] = entry;
}
return map;
});
function destinationName(planetNumber: number): string {
const target = planets.find((p) => p.number === planetNumber);
if (target === undefined) return `#${planetNumber}`;
if (target.kind === "unidentified") return `#${planetNumber}`;
return target.name === "" ? `#${planetNumber}` : target.name;
}
const reach = $derived(40 * localPlayerDrive);
function reachableSet(): Set<number> {
// The engine accepts a route from a player-owned planet to any
// planet inside the source's flight distance — own, foreign,
// uninhabited, and unidentified all qualify (`game/internal/
// controller/route.go.PlanetRouteSet` only checks ownership of
// the origin and `util.ShortDistance(...) <= FligthDistance`,
// see `pkg/calc/race.go`). The picker mirrors that contract;
// only the source itself is excluded so a self-route cannot be
// emitted.
const ids = new Set<number>();
if (reach <= 0) return ids;
for (const candidate of planets) {
if (candidate.number === planet.number) continue;
const dx = torusShortestDelta(planet.x, candidate.x, mapWidth);
const dy = torusShortestDelta(planet.y, candidate.y, mapHeight);
if (Math.hypot(dx, dy) <= reach) {
ids.add(candidate.number);
}
}
return ids;
}
async function startPick(loadType: CargoLoadType): Promise<void> {
if (draft === undefined || pick === undefined) return;
if (pendingSlot !== null) return;
const reachable = reachableSet();
if (reachable.size === 0) return;
pendingSlot = loadType;
try {
const destination = await pick.pick({
sourcePlanetNumber: planet.number,
reachableIds: reachable,
});
if (destination === null) return;
await draft.add({
kind: "setCargoRoute",
id: crypto.randomUUID(),
sourcePlanetNumber: planet.number,
destinationPlanetNumber: destination,
loadType,
});
} finally {
pendingSlot = null;
}
}
async function removeRoute(loadType: CargoLoadType): Promise<void> {
if (draft === undefined) return;
await draft.add({
kind: "removeCargoRoute",
id: crypto.randomUUID(),
sourcePlanetNumber: planet.number,
loadType,
});
}
function cancelPick(): void {
pick?.cancel();
}
</script>
<section class="cargo" data-testid="inspector-planet-cargo">
<h4 class="title">
{i18n.t("game.inspector.planet.cargo.title")}
</h4>
<dl class="slots">
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
{@const entry = slotEntries[loadType]}
{@const slug = loadType.toLowerCase()}
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}>
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
{i18n.t(SLOT_LABELS[loadType])}
</dt>
<dd class="slot-body">
{#if entry === null}
<span
class="empty"
data-testid={`inspector-planet-cargo-slot-${slug}-empty`}
>
{i18n.t("game.inspector.planet.cargo.empty")}
</span>
<button
type="button"
class="action add"
data-testid={`inspector-planet-cargo-slot-${slug}-add`}
disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)}
>
{i18n.t("game.inspector.planet.cargo.add")}
</button>
{:else}
<span
class="destination"
data-testid={`inspector-planet-cargo-slot-${slug}-destination`}
>
{destinationName(entry.destinationPlanetNumber)}
</span>
<button
type="button"
class="action edit"
data-testid={`inspector-planet-cargo-slot-${slug}-edit`}
disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)}
>
{i18n.t("game.inspector.planet.cargo.edit")}
</button>
<button
type="button"
class="action remove"
data-testid={`inspector-planet-cargo-slot-${slug}-remove`}
disabled={disabled || pendingSlot !== null}
onclick={() => void removeRoute(loadType)}
>
{i18n.t("game.inspector.planet.cargo.remove")}
</button>
{/if}
</dd>
</div>
{/each}
</dl>
{#if pendingSlot !== null}
<div
class="pick-prompt"
data-testid="inspector-planet-cargo-pick-prompt"
role="status"
>
<span class="pick-message">
{i18n.t("game.inspector.planet.cargo.pick.prompt")}
</span>
<button
type="button"
class="action cancel"
data-testid="inspector-planet-cargo-pick-cancel"
onclick={cancelPick}
>
{i18n.t("game.inspector.planet.cargo.pick.cancel")}
</button>
</div>
{:else if reach > 0 && reachableSet().size === 0}
<p
class="no-destinations"
data-testid="inspector-planet-cargo-no-destinations"
>
{i18n.t("game.inspector.planet.cargo.pick.no_destinations", {
reach: reach.toFixed(1),
})}
</p>
{/if}
</section>
<style>
.cargo {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.title {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
.slots {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.6rem;
}
.slot {
display: contents;
}
.slot-label {
color: var(--color-text-muted);
font-size: 0.85rem;
align-self: center;
}
.slot-body {
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
}
.empty {
color: var(--color-text-muted);
font-style: italic;
}
.destination {
color: var(--color-text);
}
.action {
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.action:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pick-prompt {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
padding: 0.3rem 0.5rem;
background: var(--color-warning-subtle);
border: 1px solid var(--color-warning);
border-radius: 4px;
}
.pick-message {
color: var(--color-warning);
font-size: 0.85rem;
flex: 1;
}
.no-destinations {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
</style>