ui/phase-13: planet inspector — read-only

Plumbs the map → inspector pathway: a click on a planet selects it
through the new SelectionStore, the sidebar Inspector tab swaps
its empty-state copy for a per-kind read-only field set, and a
mobile-only bottom-sheet mirrors the same content over the map.
Field projection in api/game-state.ts now surfaces every documented
planet field.
This commit is contained in:
Ilia Denisov
2026-05-09 08:29:03 +02:00
parent a3fdcfe9c5
commit 6364bba6fd
19 changed files with 1440 additions and 75 deletions
+44 -4
View File
@@ -8,10 +8,16 @@ the existing renderer instance alive). Empty-planet reports render
the empty world without errors — the regression test in
`tests/e2e/game-shell-map.spec.ts` covers this.
Phase 9 owns the renderer's hit-test and pan/zoom semantics; Phase 13
will plug map clicks into the inspector. Phase 29 wires the wrap-mode
toggle on top of the per-game `wrapMode` preference the store
already manages.
Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13
plugs map clicks into the inspector by translating the renderer's
`clicked` event into a hit-test, looking the planet up by id in the
report, and calling `SelectionStore.selectPlanet`. The selection
store, set in the layout, drives both the desktop sidebar inspector
tab and the mobile bottom-sheet — the map view itself does not need
to know which surface is showing the result.
Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages.
-->
<script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte";
@@ -26,8 +32,13 @@ already manages.
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
@@ -37,6 +48,7 @@ already manages.
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let mounted = false;
$effect(() => {
@@ -70,6 +82,10 @@ already manages.
mode: "torus" | "no-wrap",
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
if (detachClick !== null) {
detachClick();
detachClick = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
@@ -92,6 +108,7 @@ already manages.
);
handle.viewport.setZoom(minScale * 1.05, true);
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
mountedTurn = report.turn;
mountedGameId = store?.gameId ?? "";
mountError = null;
@@ -100,6 +117,25 @@ already manages.
}
}
// handleMapClick translates a renderer click into a planet
// selection. A click that misses every primitive (empty space) is
// a deliberate no-op: the selection rule for Phase 13 is that
// only the explicit close button on the mobile sheet clears the
// current selection.
function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) {
return;
}
if (selection === undefined) return;
const hit = handle.hitAt(cursorPx);
if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const planetId = hit.primitive.id;
const planet = store.report.planets.find((p) => p.number === planetId);
if (planet === undefined) return;
selection.selectPlanet(planet.number);
}
onMount(() => {
mounted = true;
onResize = (): void => {
@@ -115,6 +151,10 @@ already manages.
window.removeEventListener("resize", onResize);
onResize = null;
}
if (detachClick !== null) {
detachClick();
detachClick = null;
}
if (handle !== null) {
handle.dispose();
handle = null;