4d729c1f50
* 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>
1013 lines
34 KiB
Svelte
1013 lines
34 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 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>
|