8e552f556d
Polish pass after the first F8-10 walkthrough:
- table-planets: moved the `foreign` chip to the end of the row and
hid the race dropdown until `foreign` is on (it never made sense
to pick a race while the bucket itself was off).
- persistent per-table filter / sort state — extracted to
`table-{planets,ship-groups,fleets}-state.svelte.ts` singletons so
a row click → map → back to the table restores the prior chip /
dropdown / sort state. Held in memory only; an F5 still resets.
- table-ship-groups: the planet and class dropdowns now narrow to
the slice surviving the owner checkboxes, so toggling `foreign`
off removes planets / classes touched only by foreign rows.
- map.svelte: camera (centre + zoom) is captured on every dispose
path into a new `GameStateStore.lastCamera` and consumed on the
next mount, so leaving the map for any other active view and
coming back restores the prior pan / zoom. A pending focus from
the tables still wins for the centre point.
- table-ship-classes: `:disabled` now reads as disabled (muted
colour, no hover ring, not-allowed cursor) — the click was already
a no-op, only the visual was lying.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
900 lines
30 KiB
Svelte
900 lines
30 KiB
Svelte
<!--
|
|
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 RendererHandle,
|
|
} from "../../map/index";
|
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
|
import { computeReachCircles } from "../../map/reach-circles";
|
|
import { computeSelectionRing } from "../../map/selection-ring";
|
|
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);
|
|
|
|
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.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 selectedPlanetId =
|
|
selection?.selected?.kind === "planet" ? selection.selected.id : null;
|
|
const extrasFingerprint =
|
|
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
|
`reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` +
|
|
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,
|
|
)
|
|
: [];
|
|
const selectedPlanetId =
|
|
selection?.selected?.kind === "planet" ? selection.selected.id : null;
|
|
const selectionRing = computeSelectionRing(
|
|
report.planets,
|
|
selectedPlanetId,
|
|
palette,
|
|
);
|
|
return [
|
|
...cargo,
|
|
...pending,
|
|
...reach,
|
|
...(selectionRing === null ? [] : [selectionRing]),
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
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.
|
|
// scrollToBombingRow waits for the report's bombing row for the
|
|
// given planet to mount, then scrolls it into view. The map context
|
|
// menu switches to the report view through a store mutation, so the
|
|
// section renders on a later frame; a short bounded poll bridges
|
|
// that gap without coupling the map to the report's render timing.
|
|
function scrollToBombingRow(planet: number): void {
|
|
if (typeof document === "undefined") return;
|
|
let attempts = 60;
|
|
const tick = (): void => {
|
|
const row = document.querySelector(
|
|
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
|
|
);
|
|
if (row instanceof HTMLElement) {
|
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
return;
|
|
}
|
|
attempts -= 1;
|
|
if (attempts <= 0) return;
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
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;
|
|
}
|
|
case "bombing": {
|
|
activeView.select("report");
|
|
// The report sections render reactively after the view
|
|
// switches above, so there is no navigation promise to
|
|
// await; poll a bounded number of animation frames for
|
|
// the bombing row, then scroll it into view.
|
|
scrollToBombingRow(target.planet);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
mounted = true;
|
|
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;
|
|
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}
|
|
</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);
|
|
}
|
|
</style>
|