ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ preference the store already manages.
|
||||
minScaleNoWrap,
|
||||
type RendererHandle,
|
||||
} from "../../map/index";
|
||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||
import { reportToWorld } from "../../map/state-binding";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
@@ -40,12 +41,35 @@ preference the store already manages.
|
||||
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,
|
||||
registerMapPickStateProvider,
|
||||
registerMapPrimitivesProvider,
|
||||
type MapCameraSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
type MapPrimitiveSnapshot,
|
||||
} from "$lib/debug-surface.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,
|
||||
);
|
||||
|
||||
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
@@ -56,7 +80,23 @@ preference the store already manages.
|
||||
let mountedGameId: string | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
let detachClick: (() => void) | null = null;
|
||||
let detachDebugProviders: (() => void) | null = null;
|
||||
let detachDebugSurface: (() => void) | null = null;
|
||||
let mounted = 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
|
||||
@@ -72,31 +112,102 @@ preference the store already manages.
|
||||
if (!mounted || canvasEl === null || containerEl === null) return;
|
||||
if (status !== "ready" || !report) return;
|
||||
|
||||
// Skip a re-mount when the same turn is reloaded for the same
|
||||
// game and the wrap mode did not change. The store's `refresh`
|
||||
// path lands here on tab focus; an unchanged snapshot must not
|
||||
// flicker the canvas.
|
||||
// Cargo-route arrows 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 content is
|
||||
// unchanged (e.g. status transitions valid → submitting →
|
||||
// applied for the same command).
|
||||
const extrasFingerprint = computeRoutesFingerprint(report.routes);
|
||||
|
||||
const sameSnapshot =
|
||||
mountedTurn === report.turn &&
|
||||
mountedGameId === gameId &&
|
||||
handle !== null &&
|
||||
handle.getMode() === mode;
|
||||
if (sameSnapshot) return;
|
||||
if (sameSnapshot) {
|
||||
if (lastExtrasFingerprint !== extrasFingerprint) {
|
||||
untrack(() => {
|
||||
handle?.setExtraPrimitives(buildCargoRouteLines(report));
|
||||
});
|
||||
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 mountRenderer(report, mode);
|
||||
void runSerializedMount(report, mode, extrasFingerprint);
|
||||
});
|
||||
});
|
||||
|
||||
async function runSerializedMount(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
): Promise<void> {
|
||||
mountInProgress = true;
|
||||
try {
|
||||
await mountRenderer(report, mode, routesFingerprint);
|
||||
} 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(";");
|
||||
}
|
||||
|
||||
async function mountRenderer(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
): 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. A new game / first mount has no prior
|
||||
// camera, so `previousCamera` stays null and the default
|
||||
// centring path runs.
|
||||
const previousGameId = mountedGameId;
|
||||
const targetGameId = store?.gameId ?? "";
|
||||
const previousCamera =
|
||||
handle !== null && previousGameId === targetGameId
|
||||
? handle.getCamera()
|
||||
: null;
|
||||
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;
|
||||
@@ -109,7 +220,6 @@ preference the store already manages.
|
||||
mode,
|
||||
preference: ["webgpu", "webgl"],
|
||||
});
|
||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||
const minScale = minScaleNoWrap(
|
||||
{
|
||||
widthPx: containerEl.clientWidth,
|
||||
@@ -117,12 +227,113 @@ preference the store already manages.
|
||||
},
|
||||
world,
|
||||
);
|
||||
handle.viewport.setZoom(minScale * 1.05, true);
|
||||
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,
|
||||
}));
|
||||
});
|
||||
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;
|
||||
});
|
||||
detachDebugProviders = (): void => {
|
||||
detachPrim();
|
||||
detachPick();
|
||||
detachCamera();
|
||||
};
|
||||
mountedTurn = report.turn;
|
||||
mountedGameId = store?.gameId ?? "";
|
||||
mountedGameId = targetGameId;
|
||||
// Initial mount carries no extras yet; the post-mount
|
||||
// effect run pushes the current cargo-route lines via
|
||||
// `setExtraPrimitives` once `lastExtrasFingerprint`
|
||||
// disagrees with the freshly computed fingerprint.
|
||||
lastExtrasFingerprint = null;
|
||||
mountError = null;
|
||||
void routesFingerprint;
|
||||
} catch (err) {
|
||||
mountError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -154,6 +365,14 @@ preference the store already manages.
|
||||
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(() => {
|
||||
@@ -166,6 +385,15 @@ preference the store already manages.
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
}
|
||||
pickService?.bindResolver(null);
|
||||
if (detachDebugProviders !== null) {
|
||||
detachDebugProviders();
|
||||
detachDebugProviders = null;
|
||||
}
|
||||
if (detachDebugSurface !== null) {
|
||||
detachDebugSurface();
|
||||
detachDebugSurface = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
|
||||
Reference in New Issue
Block a user