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:
Ilia Denisov
2026-05-09 20:01:34 +02:00
parent 5fd67ed958
commit 7c8b5aeb23
43 changed files with 4559 additions and 98 deletions
+408 -11
View File
@@ -21,18 +21,27 @@ import { Application, Container, Graphics, type Renderer, type RendererType } fr
import { Viewport as PixiViewport } from "pixi-viewport";
import { hitTest, type Hit } from "./hit-test";
import { screenToWorld } from "./math";
import { minScaleNoWrap } from "./no-wrap";
import {
computePickOverlay,
PICK_OVERLAY_STYLE,
type PickModeHandle,
type PickModeOptions,
} from "./pick-mode";
import { wrapCameraTorus } from "./torus";
import {
DARK_THEME,
DEFAULT_POINT_RADIUS_PX,
World,
type Camera,
type CirclePrim,
type LinePrim,
type PointPrim,
type Primitive,
type PrimitiveID,
type Theme,
type Viewport,
type World,
type WrapMode,
} from "./world";
@@ -58,6 +67,26 @@ export interface RendererHandle {
getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas";
hitAt(cursorPx: { x: number; y: number }): Hit | null;
/**
* setExtraPrimitives replaces the current overlay primitive layer
* with `prims`. The base world (passed to `createRenderer`) is
* preserved; only the extras layer changes. Used by the in-game
* shell to project order-overlay-driven artefacts (Phase 16
* cargo-route arrows) onto the live renderer without disposing
* and recreating the Pixi `Application` — which Pixi 8 does not
* reliably support on the same canvas.
*
* Hit-test, `getPrimitives`, and pick mode all see the union of
* base + extras after the call returns. Repeated calls
* remount-replace the extras atomically.
*/
setExtraPrimitives(prims: readonly Primitive[]): void;
/**
* getPrimitives returns the live union of base + extras. The
* order is base-first, extras-last (mirroring the draw order).
* Reads stay in sync with `setExtraPrimitives`.
*/
getPrimitives(): readonly Primitive[];
/**
* onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor
@@ -70,6 +99,62 @@ export interface RendererHandle {
* click here will not race a pan gesture.
*/
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onPointerMove subscribes `cb` to every pointer-move event on
* the canvas. The callback receives the cursor in canvas-local
* pixel coordinates so callers can hand it straight to `hitAt`.
* Touch drags also emit pointer-move while a finger is pressed.
* The returned function detaches the listener; idempotent.
*/
onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onHoverChange subscribes `cb` to changes in the primitive
* currently under the cursor. The callback fires only when the
* id transitions (deduped) and is invoked with `null` when the
* cursor moves into empty space. Driven by the same pointer-move
* stream as `onPointerMove`, so subscribing to both does not
* double-cost the pointer event.
*/
onHoverChange(cb: (id: PrimitiveID | null) => void): () => void;
/**
* setPickMode opens (or, with `null`, closes) a map-driven
* destination pick. While a session is active the renderer dims
* primitives outside `reachableIds`, mounts an overlay drawing
* the source-anchor ring, the cursor line, and the
* hover-highlight ring, suppresses regular `onClick` consumers,
* and listens for Escape on `document`. The session resolves via
* `opts.onPick(id)` on a click hitting a reachable planet, or
* `opts.onPick(null)` on Escape / handle.cancel().
*
* Returns the imperative cancel handle when a session was opened
* (i.e. `opts !== null`), otherwise `null`. Calling the function
* again with `null` closes any active session and is idempotent.
*/
setPickMode(opts: PickModeOptions | null): PickModeHandle | null;
/**
* isPickModeActive reports whether a `setPickMode` session is
* currently open. The standard `onClick` path is suppressed
* while this returns `true`.
*/
isPickModeActive(): boolean;
/**
* getPickState returns a defensive snapshot of the pick-mode
* session for debugging surfaces. `sourcePrimitiveId` and
* `reachableIds` are `null` while no session is open.
*/
getPickState(): {
active: boolean;
sourcePrimitiveId: PrimitiveID | null;
reachableIds: ReadonlySet<PrimitiveID> | null;
hoveredId: PrimitiveID | null;
};
/**
* getPrimitiveAlpha returns the current rendered alpha of the
* primitive `id` (in the central tile). Used by the debug
* surface to report dimmed-state for e2e assertions. Returns 1
* for unknown ids.
*/
getPrimitiveAlpha(id: PrimitiveID): number;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
@@ -132,10 +217,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c;
});
for (const c of copies) {
for (const p of opts.world.primitives) {
c.addChild(buildGraphics(p, theme));
// Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup
// indexes the full set of `Graphics` instances per primitive id.
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
let currentWorld: World = opts.world;
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = buildGraphics(prim, theme);
c.addChild(g);
let list = primitiveGraphics.get(prim.id);
if (list === undefined) {
list = [];
primitiveGraphics.set(prim.id, list);
}
list.push(g);
}
allPrimitiveIds.push(prim.id);
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
if (isExtra) extraPrimitiveIds.add(prim.id);
};
for (const p of opts.world.primitives) {
populatePrimitives(p, false);
}
let mode: WrapMode = opts.mode;
@@ -217,6 +323,208 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
applyMode(mode);
// Pointer-move + hover plumbing. Listening on the underlying
// canvas keeps the renderer agnostic of pixi-viewport's plugin
// chain (drag/pinch can swallow Pixi-level pointer events while
// a gesture is in progress; the DOM event still fires).
const pointerMoveCallbacks = new Set<
(cursorPx: { x: number; y: number }) => void
>();
const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>();
let lastHoveredId: PrimitiveID | null = null;
let lastCursorPx: { x: number; y: number } | null = null;
const handlePointerMove = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
const cursorPx = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
lastCursorPx = cursorPx;
for (const cb of pointerMoveCallbacks) cb(cursorPx);
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hoveredId = hit?.primitive.id ?? null;
if (hoveredId === lastHoveredId) return;
lastHoveredId = hoveredId;
for (const cb of hoverChangeCallbacks) cb(hoveredId);
};
const handlePointerLeave = (): void => {
lastCursorPx = null;
if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return;
lastHoveredId = null;
for (const cb of hoverChangeCallbacks) cb(null);
};
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
// Click dispatch. The renderer owns one `viewport.clicked`
// listener and fans the event out to either the pick-mode
// resolver (when a session is open) or the standard `onClick`
// subscribers — never both. Routing through one listener makes
// the gating race-proof: a pick-mode resolution + teardown runs
// in the same tick as the click, and the standard subscribers
// do not see the post-teardown state.
const clickSubscribers = new Set<
(cursorPx: { x: number; y: number }) => void
>();
// Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in
// `pick-mode.ts`.
let pickModeActive = false;
let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null;
const dimmedAlphaBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = [];
const handleViewportClicked = (e: {
screen: { x: number; y: number };
}): void => {
const cursorPx = { x: e.screen.x, y: e.screen.y };
if (pickModeActive) {
const session = pickOptions;
if (session === null) return;
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hitId = hit?.primitive.id ?? null;
if (hitId === null) return;
if (hitId === session.sourcePrimitiveId) return;
if (!session.reachableIds.has(hitId)) return;
const cb = session.onPick;
teardownPickMode();
cb(hitId);
return;
}
for (const cb of clickSubscribers) cb(cursorPx);
};
viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return;
const cursorWorld =
lastCursorPx === null
? null
: screenToWorld(
lastCursorPx,
handle.getCamera(),
handle.getViewport(),
);
const spec = computePickOverlay(
pickOptions,
cursorWorld,
lastHoveredId,
pointPrimitivesById,
allPrimitiveIds,
);
const g = pickOverlay;
g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({
color: PICK_OVERLAY_STYLE.anchor.color,
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
width: PICK_OVERLAY_STYLE.anchor.width,
});
if (spec.line !== null) {
g.moveTo(spec.line.x1, spec.line.y1);
g.lineTo(spec.line.x2, spec.line.y2);
g.stroke({
color: PICK_OVERLAY_STYLE.line.color,
alpha: PICK_OVERLAY_STYLE.line.alpha,
width: PICK_OVERLAY_STYLE.line.width,
});
}
if (spec.hoverOutline !== null) {
g.circle(
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
);
g.stroke({
color: PICK_OVERLAY_STYLE.hover.color,
alpha: PICK_OVERLAY_STYLE.hover.alpha,
width: PICK_OVERLAY_STYLE.hover.width,
});
}
};
const teardownPickMode = (): void => {
if (!pickModeActive) return;
pickModeActive = false;
for (const detach of detachPickListeners) detach();
detachPickListeners.length = 0;
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
dimmedAlphaBackup.clear();
if (pickOverlay !== null) {
pickOverlay.destroy();
pickOverlay = null;
}
pickOptions = null;
};
const openPickMode = (options: PickModeOptions): PickModeHandle => {
// An existing session is cancelled first so the previous
// `onPick(null)` is delivered before the new one starts.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
pickOptions = options;
pickModeActive = true;
// Dim every primitive that's not the source and not reachable.
for (const [id, list] of primitiveGraphics) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
for (const g of list) {
dimmedAlphaBackup.set(g, g.alpha);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
}
}
// Overlay graphic. Lives in the origin copy so the central
// tile owns it; the camera always wraps back into this tile
// (`wrapTorusCamera`), so the user sees the overlay
// regardless of how far they have panned.
pickOverlay = new Graphics();
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing
// callback registries.
detachPickListeners.push(handle.onPointerMove(redrawPickOverlay));
detachPickListeners.push(handle.onHoverChange(redrawPickOverlay));
// Click resolution is handled by the shared
// `handleViewportClicked` dispatcher above; pick mode does
// not subscribe its own `clicked` listener — see the
// rationale in the dispatcher's comment.
const keyHandler = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
if (pickOptions === null) return;
event.preventDefault();
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
};
document.addEventListener("keydown", keyHandler);
detachPickListeners.push(() =>
document.removeEventListener("keydown", keyHandler),
);
return {
cancel: (): void => {
if (pickOptions === null) return;
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
},
};
};
const handle: RendererHandle = {
app,
viewport,
@@ -233,16 +541,89 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}),
getBackend: () => rendererBackendName(app.renderer),
hitAt: (cursorPx) =>
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
),
setExtraPrimitives: (prims) => {
// Drop the previous extras layer.
for (const id of extraPrimitiveIds) {
const list = primitiveGraphics.get(id);
if (list !== undefined) {
for (const g of list) {
g.parent?.removeChild(g);
g.destroy();
}
primitiveGraphics.delete(id);
}
pointPrimitivesById.delete(id);
const idx = allPrimitiveIds.indexOf(id);
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
}
extraPrimitiveIds.clear();
// Add the new extras.
for (const p of prims) {
populatePrimitives(p, true);
}
// Rebuild the snapshot World hit-test reads from. The
// renderer keeps `currentWorld` mutable so the live
// extras participate in click/hover tests on the same
// frame they're drawn.
currentWorld = new World(opts.world.width, opts.world.height, [
...opts.world.primitives,
...prims,
]);
},
getPrimitives: () => currentWorld.primitives,
onClick: (cb) => {
const handler = (e: { screen: { x: number; y: number } }): void => {
cb({ x: e.screen.x, y: e.screen.y });
};
viewport.on("clicked", handler);
clickSubscribers.add(cb);
return () => {
viewport.off("clicked", handler);
clickSubscribers.delete(cb);
};
},
onPointerMove: (cb) => {
pointerMoveCallbacks.add(cb);
return () => {
pointerMoveCallbacks.delete(cb);
};
},
onHoverChange: (cb) => {
hoverChangeCallbacks.add(cb);
// Fire the current state once so subscribers do not have to
// wait for the next pointer movement to learn what's under
// the cursor.
cb(lastHoveredId);
return () => {
hoverChangeCallbacks.delete(cb);
};
},
setPickMode: (options) => {
if (options === null) {
if (!pickModeActive) return null;
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
return null;
}
return openPickMode(options);
},
isPickModeActive: () => pickModeActive,
getPickState: () => ({
active: pickModeActive,
sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null,
reachableIds: pickOptions?.reachableIds ?? null,
hoveredId: lastHoveredId,
}),
getPrimitiveAlpha: (id) => {
const list = primitiveGraphics.get(id);
if (list === undefined || list.length === 0) return 1;
// All copies share the same alpha (dim is applied to every
// torus tile), so the central-tile entry is representative.
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -255,8 +636,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}
},
dispose: () => {
// Tear down any open pick session before destroying the
// app — the resolution callback might reference Svelte
// stores that disappear next tick on dispose, but
// `onPick(null)` here is a synchronous notification the
// caller is responsible for handling.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
pointerMoveCallbacks.clear();
hoverChangeCallbacks.clear();
clickSubscribers.clear();
app.destroy({ removeView: false }, { children: true });
},
};
@@ -283,7 +680,7 @@ function buildGraphics(p: Primitive, theme: Theme): Graphics {
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radiusPx = p.style.pointRadiusPx ?? 3;
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
g.circle(p.x, p.y, radiusPx);
g.fill({ color, alpha });
}