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
+79 -6
View File
@@ -116,7 +116,11 @@ target.
Per-primitive distance:
- **Point**: `distSq ≤ slopWorld²`.
- **Point**: `distSq ≤ (pointRadiusPx + slopWorld)²`. The visible
disc is part of the click target — a click on any pixel of the
rendered planet registers as a hit, with `slopWorld` adding a
small ergonomic margin on top. `pointRadiusPx` defaults to
`DEFAULT_POINT_RADIUS_PX = 3` when unset.
- **Filled circle**: `distSq ≤ (radius + slopWorld)²` where
`radius` is in world units. The circle counts as filled when
`style.fillColor` is set and `style.fillAlpha > 0`.
@@ -220,6 +224,72 @@ If a future regression requires a programmatic perf gate, the
right place is a Tier 2 (release-line) Playwright trace measuring
average frame time over a scripted drag.
## Pick mode
Phase 16 introduced a generic *map-driven destination pick* the
inspector uses for cargo routes and that ship-group dispatch
(Phase 19/20) will reuse. The renderer owns the visual lifecycle;
the Svelte side wraps it in a promise-shaped service.
Lifecycle (`RendererHandle.setPickMode(opts)`):
1. **Open** (`opts !== null`): renderer marks `pickModeActive`,
sets `alpha = 0.3` on every primitive whose id is neither the
source nor in `reachableIds`, mounts an overlay `Graphics` in
the origin tile, and subscribes to pointer-move + hover-change
+ viewport `clicked` + document `keydown`.
2. **Tick** (every pointer-move and hover transition): the
renderer asks `computePickOverlay(opts, cursorWorld,
hoveredId, points, allIds)` (`src/map/pick-mode.ts`) for a
draw spec — anchor ring + cursor line + optional hover
outline + dim set — and re-paints the overlay.
3. **Resolve**: a click on a primitive whose id is in
`reachableIds` calls `opts.onPick(id)` and tears down. A click
on empty space or a non-reachable primitive is a no-op
(forgiving for accidental taps mid-pan). Escape (or the
imperative `cancel()` on the returned handle) calls
`opts.onPick(null)`.
4. **Tear down**: alpha overrides are restored, the overlay
`Graphics` is destroyed, every listener is detached, and
`pickModeActive` returns to `false`. Existing `onClick`
subscriptions are gated on `pickModeActive`, so the standard
planet-selection path does not fire on the destination click.
The pure overlay-spec helper lives in `src/map/pick-mode.ts` and
is covered by `tests/map-pick-mode.test.ts` without booting Pixi.
The Pixi side (alpha mutation, `Graphics` overlay, listener
hookup) is exercised in the in-browser e2e specs.
The Svelte adapter `MapPickService` (`src/lib/map-pick.svelte.ts`)
turns the callback contract into `pick(request) → Promise<id |
null>`. The map active view (`lib/active-view/map.svelte`)
constructs the service, sets `MAP_PICK_CONTEXT_KEY`, and binds a
resolver that translates `sourcePlanetNumber` to the underlying
`PickModeOptions` (looking up the source coordinates from the
current report). Inspector subsections call `service.pick(...)`
and react to the resolved id.
## Debug surface
The DEV-only `__galaxyDebug` object (defined in
`routes/__debug/store/+page.svelte`) exposes
`getMapPrimitives()` and `getMapPickState()` so e2e specs can
assert the renderer's current state without scraping pixels:
- `getMapPrimitives()` returns a snapshot of every primitive in
the active world: id, kind, priority, current alpha
(post-overlay), and the explicit fill / stroke colour from its
`Style` (no theme fallback). Tests use this to count cargo
arrows or to verify dim state during pick mode.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
reachableIds, hoveredId }` — the renderer's view of the
current pick session.
The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
in `src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
the surface invokes them lazily on every read.
## Tests
- `tests/map-math.test.ts` — `clamp`, `torusShortestDelta`,
@@ -227,11 +297,14 @@ average frame time over a scripted drag.
- `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`,
`minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant
verified within float64 precision).
- `tests/map-hit-test.test.ts` 22 hand-built cases covering
every rule from the algorithm above: hit/miss with default and
custom slop, torus wrap copies, filled vs stroked circles,
line endpoint clamping, priority/kind/id ordering, scale
effect on slop.
- `tests/map-hit-test.test.ts` — hand-built cases covering every
rule from the algorithm above: hit/miss with default and
custom slop (now including `pointRadiusPx`), torus wrap
copies, filled vs stroked circles, line endpoint clamping,
priority/kind/id ordering, scale effect on slop.
- `tests/map-pick-mode.test.ts` — pure-state coverage for
`computePickOverlay`: anchor / line / hover-outline / dim-set
shape against representative pick configurations.
- `tests/e2e/playground-map.spec.ts` — Pixi mount in real
browsers, mode toggle, wheel zoom, no-wrap clamp after drag,
hit-test plumbing.