Files
galaxy-game/ui/frontend/src/lib/active-view/map.svelte
T
Ilia Denisov 4d729c1f50
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
feat(ui): F8-12 — smooth planet discs + ?debug=1 overlay (#55)
* Planet discs (and every other circle the renderer draws —
  outlines, picker hover ring, reach / bombing rings, etc.) trace
  a fixed 32-segment polygon instead of leaning on Pixi's adaptive
  bezier subdivision. PixiJS v8 picks the segment count from the
  world-space radius, which collapsed to 6-8 segments once the
  parent container's scale climbed — so the planet read as a
  visible polygon at high zoom. The custom path stays cheap (~64
  floats per disc) and gives a perceptually round silhouette at
  every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
  bottom-left panel shows the current `scale`, the
  "whole world fits" reference scale, the current zoom ratio
  (scale / scale_ref), and the world-units rectangle visible in
  the viewport — so the owner can decide what `maxScale` to clamp
  to on the next iteration without guessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:33:23 +02:00

1013 lines
34 KiB
Svelte
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.
<!--
Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by
`lib/game/game-shell.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps
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
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";
import { activeView } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
createRenderer,
minScaleNoWrap,
type PlanetOutlineSpec,
type RendererHandle,
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPlanetLabels } from "../../map/labels";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { computeReachCircles } from "../../map/reach-circles";
import { reachStore } from "$lib/calculator/reach.svelte";
import { theme as themeStore } from "$lib/theme/theme.svelte";
import {
reportToWorld,
type HitTarget,
type MapCategory,
} from "../../map/state-binding";
import {
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
} from "../../map/visibility";
import {
DARK_THEME,
LIGHT_THEME,
type PrimitiveID,
type Theme,
} from "../../map/world";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import type { OrderCommand } from "../../sync/order-types";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
type MapToggles,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type Selected,
type SelectionStore,
} from "$lib/selection.svelte";
import { computeInSpacePosition } from "../../map/ship-groups";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapFogProvider,
registerMapModeProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
registerMapRenderCountProvider,
type MapCameraSnapshot,
type MapFogSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
import MapTogglesControl from "./map-toggles.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `MapPickService` is owned by the in-game shell layout (set on
// the context tree the inspector subsections also descend from).
// Renderer changes attach / detach via `bindResolver` so a
// remount mid-pick does not orphan a pending promise. The map
// view is mounted only beneath the layout, so the service is
// always present in production; tests render the map in isolation
// and may omit it.
const pickService = getContext<MapPickService | undefined>(
MAP_PICK_CONTEXT_KEY,
);
// Pending Send commands turn into green dashed tracks on the
// map overlay; the draft is read alongside the report so the
// overlay stays in lock-step with the order tab.
const orderDraft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let mountError: string | null = $state(null);
// F8-12 follow-up: an opt-in technical overlay activated by adding
// `?debug=1` to the URL. Shows the current camera scale and the
// world-units rectangle currently visible inside the viewport — so
// the owner can decide what to clamp `maxScale` to once it lands.
const debugOverlayEnabled = (() => {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).get("debug") === "1";
})();
let debugInfo: {
scale: number;
scaleRef: number;
viewWorldWidth: number;
viewWorldHeight: number;
} | null = $state(null);
let debugFrame: number | null = null;
function startDebugLoop(): void {
if (!debugOverlayEnabled) return;
const tick = (): void => {
if (handle !== null) {
const camera = handle.getCamera();
const vp = handle.getViewport();
const safeScale = camera.scale > 0 ? camera.scale : 1;
const worldW = store?.report?.mapWidth ?? 1;
const worldH = store?.report?.mapHeight ?? 1;
debugInfo = {
scale: camera.scale,
scaleRef: Math.max(vp.widthPx / worldW, vp.heightPx / worldH),
viewWorldWidth: vp.widthPx / safeScale,
viewWorldHeight: vp.heightPx / safeScale,
};
} else {
debugInfo = null;
}
debugFrame = requestAnimationFrame(tick);
};
debugFrame = requestAnimationFrame(tick);
}
function stopDebugLoop(): void {
if (debugFrame !== null) {
cancelAnimationFrame(debugFrame);
debugFrame = null;
}
}
let handle: RendererHandle | null = null;
let hitLookup = new Map<PrimitiveID, HitTarget>();
// currentCategories / currentPlanetDependents are populated by
// `reportToWorld` inside `mountRenderer` and consumed by the
// Phase 29 hide-set computation on every effect re-run (mount or
// toggle change). Both maps cover the base world; extras
// (cargo-routes, pending-Send) are gated upstream via
// `skipPlanets`, so they never need a categories entry.
let currentCategories: ReadonlyMap<PrimitiveID, MapCategory> = new Map();
let currentPlanetDependents: ReadonlyMap<
number,
ReadonlySet<PrimitiveID>
> = new Map();
// currentFogCircles mirrors the latest `setVisibilityFog` input so
// the debug surface can report it to Playwright. The renderer
// keeps the Graphics, not the data; recomputing on every read
// would duplicate work.
let currentFogCircles: ReadonlyArray<{
x: number;
y: number;
radius: number;
}> = [];
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
// The palette the current renderer was mounted with (DARK_THEME or
// LIGHT_THEME). A theme flip changes the singleton reference, which
// the effect detects to drive a camera-preserving remount — Pixi
// bakes the background colour at init and every primitive bakes its
// colour at build, so a live re-tint is not possible on the same
// instance.
let mountedPalette: Theme | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
// `mounted` must be `$state` so the renderer-mount effect re-runs
// once `onMount` flips it true. On the first map navigation the
// effect's initial pass returns early (gameState is still hydrating
// → `report` is null), and the subsequent server-driven `report`
// transition re-fires the effect after `onMount` has already
// completed. On a second navigation back to /map the report is
// already loaded — without reactivity here the effect's first
// pass would gate on `mounted === false`, and there would be no
// later state change to wake it up. The visible symptom is a
// black canvas (renderer never re-mounted on the new DOM).
let mounted = $state(false);
// Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without
// the in-progress gate, parallel `createRenderer` awaits would
// leave both old and new viewport listeners on the canvas,
// double-firing every click. The gate is intentionally a plain
// `let` (not `$state`) so reads from the effect do not register
// as a reactive dependency.
let mountInProgress = false;
let pendingMountSignal = $state(0);
// Track the latest cargo-route fingerprint we pushed to the
// renderer so a no-op push (e.g. report refresh that yields the
// same overlay) doesn't churn Pixi graphics needlessly.
let lastExtrasFingerprint: string | null = null;
$effect(() => {
// Read the overlay-applied report so the map labels reflect
// pending renames immediately. Falls back to raw report when
// the rendered source is missing (e.g. component used outside
// the in-game shell layout).
const report = renderedReport?.report ?? store?.report;
const status = store?.status ?? "idle";
// Track the wrap mode so the renderer remounts when Phase 29's
// toggle UI flips it; the read here also subscribes the effect.
const mode = store?.wrapMode ?? "torus";
// Track the Phase 29 visibility toggles so the effect re-runs
// when the gear popover flips any flag. The hide set + fog +
// extras filter all derive from this rune.
const toggles = store?.mapToggles;
const gameId = store?.gameId ?? "";
// Track the resolved app theme so the canvas follows the user's
// light / dark choice. A flip re-keys the snapshot below and
// triggers a camera-preserving remount with the new palette.
const palette: Theme =
themeStore.resolved === "light" ? LIGHT_THEME : DARK_THEME;
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report || toggles === undefined) return;
// Explicit reads of every toggle key — Svelte 5's deep proxy
// tracks per-property access, and the actual consumers
// (computeHiddenIds, computeFogCircles, buildExtras) run
// inside `untrack` blocks or async continuations where the
// tracking would otherwise be lost. Touching every key here
// synchronously guarantees a flip triggers the effect.
void toggles.hyperspaceGroups;
void toggles.incomingGroups;
void toggles.unidentifiedGroups;
void toggles.foreignPlanets;
void toggles.uninhabitedPlanets;
void toggles.unidentifiedPlanets;
void toggles.unreachablePlanets;
void toggles.cargoRoutes;
void toggles.battleMarkers;
void toggles.bombingMarkers;
void toggles.planetNames;
void toggles.visibleHyperspace;
// Subscribe to the calculator's published reach so the rings
// redraw as the design or the selected planet changes.
void reachStore.origin;
void reachStore.speedPerTurn;
// Redraw the selected-planet ring when the selection changes.
void selection?.selected;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
// when a destination planet hides. The hide set + fog are
// applied after mount / on every toggle change without a
// remount.
const hiddenPlanetNumbers = computeHiddenPlanetNumbers(report, toggles);
const hiddenPlanetFingerprint =
fingerprintHiddenPlanets(hiddenPlanetNumbers);
// Cargo-route arrows and pending-Send tracks are pushed onto
// the live renderer via `setExtraPrimitives` so the overlay
// can change inside a single turn without disposing the Pixi
// `Application` — Pixi 8 does not reliably re-init on the
// same canvas. The fingerprint guard avoids redundant Pixi
// rebuilds when the overlay computation re-runs but the
// routes / pending-Send content is unchanged (e.g. status
// transitions valid → submitting → applied for the same
// command). The Phase 29 cascade + cargoRoutes toggle are
// folded into the fingerprint so a toggle flip that changes
// the visible set reliably triggers a push.
const draftCommands = orderDraft?.commands ?? [];
const draftStatuses = orderDraft?.statuses ?? {};
const reachOrigin = reachStore.origin;
const reachFingerprint =
reachOrigin === null
? ""
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
`reach=${reachFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
mountedPalette === palette &&
handle !== null;
if (sameSnapshot) {
// Apply wrap-mode flips in-place via the renderer's own
// `setMode` — a full re-mount is unnecessary (the world,
// primitives, and camera are unchanged) and Pixi 8 does
// not reliably re-init on the same canvas (the symptom is
// a crashed tab when the wrap-mode radio fires).
if (handle !== null && handle.getMode() !== mode) {
untrack(() => {
handle?.setMode(mode);
});
}
// Always re-apply hide set + fog on a same-snapshot pass:
// toggle flips bypass the extras fingerprint when they
// only change which baked-world primitives are hidden,
// and a no-op `setHiddenPrimitiveIds` is cheap.
untrack(() => {
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
});
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
palette,
),
);
});
lastExtrasFingerprint = extrasFingerprint;
}
return;
}
// Read the pending-mount signal so the effect re-runs after
// the in-flight mount completes (it bumps the signal in its
// finally block). Without this, a dep change observed while
// `mountInProgress` is true would be silently dropped.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void runSerializedMount(
report,
mode,
toggles,
hiddenPlanetNumbers,
extrasFingerprint,
draftCommands,
draftStatuses,
palette,
);
});
});
function buildExtras(
report: NonNullable<GameStateStore["report"]>,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
mode: "torus" | "no-wrap",
palette: Theme,
): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes
? buildCargoRouteLines(
report,
skip ? { skipPlanets: skip } : undefined,
palette,
)
: [];
const pending = buildPendingSendLines(
report,
draftCommands,
draftStatuses,
skip ? { skipPlanets: skip } : undefined,
palette,
);
// Reach circles published by the ship-class calculator. Empty
// when no own planet is selected or the design is invalid, so
// this is a no-op for the rest of the map.
const reachOrigin = reachStore.origin;
const reach =
reachOrigin !== null && reachStore.speedPerTurn > 0
? computeReachCircles(
reachOrigin,
reachStore.speedPerTurn,
report.mapWidth,
report.mapHeight,
mode,
palette,
)
: [];
return [...cargo, ...pending, ...reach];
}
function applyVisibilityState(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): void {
if (handle === null) return;
const hiddenIds = computeHiddenIds(
currentCategories,
currentPlanetDependents,
hiddenPlanetNumbers,
toggles,
);
handle.setHiddenPrimitiveIds(hiddenIds);
const fogCircles = computeFogCircles(report, toggles);
currentFogCircles = fogCircles;
handle.setVisibilityFog(fogCircles);
applyPlanetLabels(report, toggles);
}
function applyPlanetLabels(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
): void {
if (handle === null) return;
const labels = buildPlanetLabels(report, {
showNames: toggles.planetNames,
});
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
handle.setPlanetLabels(labels, selectedPlanetId);
applyPlanetOutlines(report, toggles, selectedPlanetId);
}
function applyPlanetOutlines(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
selectedPlanetId: number | null,
): void {
if (handle === null) return;
const palette = mountedPalette ?? DARK_THEME;
const outlines: PlanetOutlineSpec[] = [];
// Bombing outline (F8-12 / #30): every bombed planet gets the
// damaged / wiped accent painted around its disc. The
// `bombingMarkers` toggle hides the visual cue while leaving
// the data intact.
if (toggles.bombingMarkers) {
for (const bombing of report.bombings) {
if (bombing.planetNumber === selectedPlanetId) continue;
outlines.push({
planetNumber: bombing.planetNumber,
color: bombing.wiped
? palette.bombingWiped
: palette.bombingDamaged,
});
}
}
// Selection outline overrides bombing on the same planet so the
// player can always tell which one is currently focused.
if (selectedPlanetId !== null) {
outlines.push({
planetNumber: selectedPlanetId,
color: palette.selectionAccent,
});
}
handle.setPlanetOutlines(outlines);
}
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
extrasFingerprint: string,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
palette: Theme,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode, palette);
if (handle === null) return;
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
handle.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
mode,
palette,
),
);
lastExtrasFingerprint = extrasFingerprint;
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
// while the gate was up gets a fresh effect run with the
// current state.
pendingMountSignal += 1;
}
}
function computeRoutesFingerprint(
routes: NonNullable<GameStateStore["report"]>["routes"],
): string {
if (routes.length === 0) return "";
const parts = routes.map((route) => {
const entries = route.entries
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
.join(",");
return `${route.sourcePlanetNumber}:${entries}`;
});
return parts.join(";");
}
function computePendingSendFingerprint(
commands: readonly OrderCommand[],
statuses: Readonly<Record<string, string>>,
): string {
const parts: string[] = [];
for (const cmd of commands) {
if (cmd.kind !== "sendShipGroup") continue;
const status = statuses[cmd.id];
if (status === "rejected" || status === "invalid") continue;
parts.push(`${cmd.groupId}->${cmd.destinationPlanetNumber}`);
}
return parts.join(";");
}
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
palette: Theme,
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
// the same game (e.g. cargo-route overlay change) keeps the
// user's pan / zoom. On a cold mount with no live `handle` we
// fall back to the per-game `store.lastCamera` snapshot, so
// leaving the map for a table / report and coming back also
// restores the prior view. A new game / first mount has no
// prior camera in either source, so `previousCamera` stays
// null and the default centring path runs.
const previousGameId = mountedGameId;
const targetGameId = store?.gameId ?? "";
let previousCamera: ReturnType<RendererHandle["getCamera"]> | null = null;
if (handle !== null && previousGameId === targetGameId) {
previousCamera = handle.getCamera();
} else if (handle === null && store?.lastCamera) {
previousCamera = store.lastCamera;
}
if (handle !== null && store !== undefined) {
store.lastCamera = handle.getCamera();
}
if (detachClick !== null) {
detachClick();
detachClick = null;
}
// Detach the previous resolver before disposing — the
// renderer's `dispose` already calls `onPick(null)` on any
// open session, which `bindResolver(null)` would also do, so
// we route the cancel through one path only.
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
try {
const {
world,
hitLookup: nextHitLookup,
categories,
planetDependents,
} = reportToWorld(report, palette);
hitLookup = nextHitLookup;
currentCategories = categories;
currentPlanetDependents = planetDependents;
handle = await createRenderer({
canvas: canvasEl,
world,
mode,
preference: ["webgpu", "webgl"],
theme: palette,
});
const minScale = minScaleNoWrap(
{
widthPx: containerEl.clientWidth,
heightPx: containerEl.clientHeight,
},
world,
);
// Consume an F8-10 table click that asked the next map mount
// to centre on a particular target. The store self-clears on
// read, so any later remount inside the same session sees
// null and falls through to the default centring path. The
// coord-only `pendingCenter` is the fleet-row fallback: a
// fleet has no `Selected` variant, but its xy still feeds
// the camera. `pendingFocus` wins when both are queued.
const focusTarget = selection?.consumePendingFocus() ?? null;
const focusPoint =
resolveFocusPoint(focusTarget, report, world.width, world.height)
?? selection?.consumePendingCenter()
?? null;
if (focusPoint !== null) {
handle.viewport.moveCenter(focusPoint.x, focusPoint.y);
handle.viewport.setZoom(
previousCamera === null
? minScale * 1.05
: Math.max(previousCamera.scale, minScale),
true,
);
} else if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
// does not strand the user below the current floor.
handle.viewport.moveCenter(
previousCamera.centerX,
previousCamera.centerY,
);
handle.viewport.setZoom(
Math.max(previousCamera.scale, minScale),
true,
);
} else {
handle.viewport.moveCenter(world.width / 2, world.height / 2);
handle.viewport.setZoom(minScale * 1.05, true);
}
// `viewport.setZoom` emits `zoomed` through the next Ticker
// tick, but the handler can race the synchronous setExtras /
// label / outline calls that follow — and a theme-flip
// remount has been observed to leave primitives drawn at the
// boot scale until the user nudges the wheel. Force the
// camera-derived redraw explicitly here so the post-mount
// state always matches `viewport.scaled`.
handle.refreshCameraDerivedDraws();
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
if (handle === null) {
onResolve(null);
return null;
}
const planet = report.planets.find(
(p) => p.number === sourcePlanetNumber,
);
if (planet === undefined) {
onResolve(null);
return null;
}
return handle.setPickMode({
sourcePrimitiveId: sourcePlanetNumber,
sourceX: planet.x,
sourceY: planet.y,
reachableIds,
onPick: onResolve,
});
});
const detachPrim = registerMapPrimitivesProvider(() => {
const h = handle;
if (h === null) return [];
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
id: p.id,
kind: p.kind,
priority: p.priority,
alpha: h.getPrimitiveAlpha(p.id),
fillColor: p.style.fillColor ?? null,
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
visible: !h.isPrimitiveHidden(p.id),
}));
});
const detachPick = registerMapPickStateProvider(() => {
const h = handle;
if (h === null) {
return {
active: false,
sourcePlanetNumber: null,
reachableIds: [],
hoveredId: null,
} satisfies MapPickStateSnapshot;
}
const state = h.getPickState();
return {
active: state.active,
sourcePlanetNumber:
state.sourcePrimitiveId === null
? null
: Number(state.sourcePrimitiveId),
reachableIds:
state.reachableIds === null
? []
: Array.from(state.reachableIds).map((id) => Number(id)),
hoveredId:
state.hoveredId === null ? null : Number(state.hoveredId),
} satisfies MapPickStateSnapshot;
});
const detachCamera = registerMapCameraProvider(() => {
const h = handle;
if (h === null) return null;
const camera = h.getCamera();
const viewport = h.getViewport();
const rect = canvasEl?.getBoundingClientRect();
return {
camera,
viewport,
canvasOrigin: {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
},
} satisfies MapCameraSnapshot;
});
const detachFog = registerMapFogProvider(() => ({
circles: currentFogCircles.map((c) => ({ ...c })),
}) satisfies MapFogSnapshot);
const detachMode = registerMapModeProvider(() =>
handle === null ? null : handle.getMode(),
);
const detachRenderCount = registerMapRenderCountProvider(() =>
handle === null ? null : handle.getRenderCount(),
);
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
detachFog();
detachMode();
detachRenderCount();
};
mountedTurn = report.turn;
mountedGameId = targetGameId;
mountedPalette = palette;
// runSerializedMount immediately pushes the visibility
// state + extras after this resolves; clearing the
// fingerprint here is defensive in case the post-mount
// path is ever bypassed (e.g. mount-then-throw before the
// extras push). The hide set / fog are applied by the
// caller too, so we do not call them here.
lastExtrasFingerprint = null;
mountError = null;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
}
// resolveFocusPoint maps an F8-10 table click target to world (x, y)
// for camera centring. Planets resolve via the report; in-space
// ship groups via the shared interpolation helper; on-planet ship
// groups fall back to the destination planet's xy (so a click on a
// group stationed at #5 centres on #5). Returns null when the
// target cannot be resolved — a stale ref after a fresh report,
// or a planet that is no longer in the visible set; the caller
// then falls through to the default centring path.
function resolveFocusPoint(
target: Selected | null,
report: NonNullable<GameStateStore["report"]>,
worldWidth: number,
worldHeight: number,
): { x: number; y: number } | null {
if (target === null) return null;
if (target.kind === "planet") {
const planet = report.planets.find((p) => p.number === target.id);
return planet === undefined ? null : { x: planet.x, y: planet.y };
}
const ref = target.ref;
const group =
ref.variant === "local"
? report.localShipGroups.find((g) => g.id === ref.id)
: ref.variant === "other"
? report.otherShipGroups[ref.index]
: undefined;
if (group === undefined) return null;
const planetIndex = new Map(report.planets.map((p) => [p.number, p]));
const inSpace = computeInSpacePosition(
group,
planetIndex,
worldWidth,
worldHeight,
);
if (inSpace !== null) return inSpace;
const dest = planetIndex.get(group.destination);
return dest === undefined ? null : { x: dest.x, y: dest.y };
}
// handleMapClick translates a renderer click into a selection
// update. A click that misses every primitive (empty space) is a
// deliberate no-op: the selection rule from Phase 13 is that only
// the explicit close button on the mobile sheet clears the
// current selection. The Phase 19 ship-group surface dispatches
// through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a
// ship-group selection variant. F8-12 / #30 retired the separate
// bombing-ring click; bombing → report navigation now starts in
// the inspector via `scrollToBombingRow` (`lib/report-nav.ts`).
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;
const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return;
switch (target.kind) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number);
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const turn = store?.report?.turn ?? 0;
activeView.select("battle", {
battleId: target.battleId,
turn,
});
break;
}
}
}
onMount(() => {
mounted = true;
startDebugLoop();
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
// In DEV the in-game shell mounts on a fresh document load
// (`page.goto`), which discards anything the
// `/__debug/store` route may have installed earlier in the
// session. The renderer-side accessors are still useful for
// e2e specs driving the map, so we install them here too.
if (import.meta.env.DEV) {
detachDebugSurface = installRendererDebugSurface();
}
});
onDestroy(() => {
mounted = false;
stopDebugLoop();
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
}
if (detachClick !== null) {
detachClick();
detachClick = null;
}
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (detachDebugSurface !== null) {
detachDebugSurface();
detachDebugSurface = null;
}
if (handle !== null) {
// Persist the camera snapshot to the per-game store so the
// next mount (active-view switch back to map) restores it.
if (store !== undefined) store.lastCamera = handle.getCamera();
handle.dispose();
handle = null;
}
});
</script>
<section class="active-view" data-testid="active-view-map" data-status={store?.status ?? "idle"}>
{#if store?.status === "error"}
<p class="overlay error" role="alert" data-testid="map-error">
{store.error ?? "request failed"}
</p>
{:else if mountError !== null}
<p class="overlay error" role="alert" data-testid="map-mount-error">
{mountError}
</p>
{:else if store?.status !== "ready"}
<p class="overlay" data-testid="map-loading">{i18n.t("common.loading")}</p>
{/if}
<div
class="canvas-wrap"
data-testid="map-canvas-wrap"
data-planet-count={store?.report?.planets.length ?? 0}
bind:this={containerEl}
>
<canvas
bind:this={canvasEl}
aria-label={i18n.t("game.map.aria_label", {
count: String(store?.report?.planets.length ?? 0),
})}
></canvas>
{#if store !== undefined && store.status === "ready"}
<MapTogglesControl {store} />
{/if}
{#if debugOverlayEnabled && debugInfo !== null}
<div class="debug-overlay" data-testid="map-debug-overlay" aria-hidden="true">
<div class="debug-row">
<span class="debug-key">scale</span>
<span class="debug-val">{debugInfo.scale.toFixed(3)}</span>
</div>
<div class="debug-row">
<span class="debug-key">scale_ref</span>
<span class="debug-val">{debugInfo.scaleRef.toFixed(3)}</span>
</div>
<div class="debug-row">
<span class="debug-key">scale_ratio</span>
<span class="debug-val">
×{(debugInfo.scale / debugInfo.scaleRef).toFixed(2)}
</span>
</div>
<div class="debug-row">
<span class="debug-key">view W×H</span>
<span class="debug-val">
{debugInfo.viewWorldWidth.toFixed(1)} × {debugInfo.viewWorldHeight.toFixed(1)}
</span>
</div>
</div>
{/if}
</div>
</section>
<style>
.active-view {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.canvas-wrap {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
background: var(--color-bg);
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 0.9rem;
background: var(--color-surface-overlay);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
z-index: 10;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
margin: 0;
}
.overlay.error {
background: var(--color-danger-subtle);
border-color: var(--color-danger);
color: var(--color-danger);
}
.debug-overlay {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
min-width: 11rem;
padding: 0.35rem 0.55rem;
background: rgba(0, 0, 0, 0.55);
color: #f3f5fb;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.72rem;
line-height: 1.25;
pointer-events: none;
user-select: none;
z-index: 5;
}
.debug-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.debug-key {
color: rgba(243, 245, 251, 0.65);
}
.debug-val {
font-variant-numeric: tabular-nums;
}
</style>