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
+160
View File
@@ -0,0 +1,160 @@
// Map pick-mode contract: a generic "pick a destination on the map"
// interaction the inspector triggers and the renderer drives. Phase
// 16 adds the cargo-route picker on top of this; later phases
// (19/20) drive ship-group dispatch through the same surface.
//
// The renderer-facing API lives on `RendererHandle.setPickMode`
// (see `render.ts`); this module owns the option / handle types and
// the pure overlay-draw helper that translates the pick state into a
// drawing spec the renderer can lift straight onto a Pixi `Graphics`.
// Keeping the math here means the lifecycle (dim / cursor line /
// hover outline / click+Escape resolution) can be tested without
// booting a Pixi `Application`.
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
/**
* PickModeOptions configures a pick-mode session. The caller is
* responsible for computing `reachableIds` from the current report
* (e.g. cargo routes apply the `40 * driveTech` rule before opening
* the picker). The renderer never validates reach itself — it only
* dims primitives whose id is missing from this set.
*/
export interface PickModeOptions {
/** Numeric id of the source planet primitive. Stays full-alpha
* during the session and anchors the cursor line. */
readonly sourcePrimitiveId: PrimitiveID;
/** World coordinates of the source. Pre-computed so the renderer
* can draw the anchor ring and the line endpoint without
* crawling the primitive list. */
readonly sourceX: number;
readonly sourceY: number;
/** Ids whose primitives stay full-alpha and accept clicks. */
readonly reachableIds: ReadonlySet<PrimitiveID>;
/** Resolution callback. Fires with the chosen primitive id on a
* successful pick, or `null` when the player cancels via Escape
* or the imperative `cancel()` handle. */
readonly onPick: (id: PrimitiveID | null) => void;
}
export interface PickModeHandle {
/**
* cancel terminates the session immediately and resolves
* `onPick(null)`. Idempotent — repeated calls are no-ops.
*/
cancel(): void;
}
/**
* PickOverlaySpec is the pure description the renderer paints onto
* its overlay graphic each frame. Keeps the lifecycle logic
* Pixi-free so it can be exercised by Vitest.
*/
export interface PickOverlaySpec {
/** Highlight ring around the source planet (slightly outside the
* visible disc). */
readonly anchor: {
readonly x: number;
readonly y: number;
readonly radius: number;
};
/** Line from source to current cursor; `null` while the cursor
* is off-canvas. */
readonly line: {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
} | null;
/** Outline circle around the hovered reachable planet; `null`
* when the hover is empty or aimed at a non-reachable primitive. */
readonly hoverOutline: {
readonly x: number;
readonly y: number;
readonly radius: number;
} | null;
/** Ids to dim (alpha 0.3). Everything not in `reachableIds` and
* not the source. */
readonly dimmedIds: ReadonlySet<PrimitiveID>;
}
/** Anchor / hover outline padding in world units (the rings sit
* outside the visible disc so the planet stays clearly visible). */
export const ANCHOR_PADDING_WORLD = 6;
export const HOVER_PADDING_WORLD = 4;
/**
* computePickOverlay produces a `PickOverlaySpec` for the current
* pick state. Pure: no DOM access, no Pixi calls. Callers prepare
* `pointPrimitivesById` from the active world before invoking.
*/
export function computePickOverlay(
options: PickModeOptions,
cursorWorld: { x: number; y: number } | null,
hoveredId: PrimitiveID | null,
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
allPrimitiveIds: Iterable<PrimitiveID>,
): PickOverlaySpec {
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
const sourceRadius =
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
ANCHOR_PADDING_WORLD;
const dimmed = new Set<PrimitiveID>();
for (const id of allPrimitiveIds) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
dimmed.add(id);
}
const line =
cursorWorld === null
? null
: {
x1: options.sourceX,
y1: options.sourceY,
x2: cursorWorld.x,
y2: cursorWorld.y,
};
let hoverOutline: PickOverlaySpec["hoverOutline"] = null;
if (
hoveredId !== null &&
hoveredId !== options.sourcePrimitiveId &&
options.reachableIds.has(hoveredId)
) {
const target = pointPrimitivesById.get(hoveredId);
if (target !== undefined) {
hoverOutline = {
x: target.x,
y: target.y,
radius:
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
HOVER_PADDING_WORLD,
};
}
}
return {
anchor: {
x: options.sourceX,
y: options.sourceY,
radius: sourceRadius,
},
line,
hoverOutline,
dimmedIds: dimmed,
};
}
/**
* PICK_OVERLAY_STYLE captures the colours / widths the renderer
* applies to each spec channel. Exported so tests and future themes
* can read the same values.
*/
export const PICK_OVERLAY_STYLE = {
anchor: { color: 0xffe082, alpha: 0.9, width: 2 },
line: { color: 0xffe082, alpha: 0.5, width: 1 },
hover: { color: 0xffe082, alpha: 1, width: 2 },
dimAlpha: 0.3,
} as const;