7c8b5aeb23
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>
315 lines
13 KiB
Markdown
315 lines
13 KiB
Markdown
# Map renderer
|
||
|
||
This document specifies the map renderer in `ui/frontend/src/map/`.
|
||
It is the source of truth for the rendering data model, the
|
||
hit-test algorithm, the torus-wrap and bounded-plane (no-wrap)
|
||
camera semantics, and the choice of dependencies. Any disagreement
|
||
between this document and the code is a bug in one of them.
|
||
|
||
> **`galaxy/client` is deprecated.** The Go module under
|
||
> `galaxy/client/` — including `client/world/` — is no longer the
|
||
> reference implementation for any new code. The TypeScript
|
||
> renderer described here is independent: it does not import
|
||
> `client/world` at runtime, and it is not bound by the older
|
||
> module's algorithmic details (fixed-point integers, expanded
|
||
> canvas, incremental pan reuse, grid spatial index). The Go code
|
||
> remains as historical context only.
|
||
|
||
## Goals
|
||
|
||
The renderer is the bottom of the rendering stack the rest of the
|
||
UI sits on top of. It must:
|
||
|
||
1. Render thousands of vector primitives (points, circles, lines)
|
||
onto a Pixi v8 canvas at 60 fps on a mid-range laptop.
|
||
2. Support pan and zoom over a toroidal world (`'torus'` mode) and
|
||
over a bounded plane (`'no-wrap'` mode), both first-class.
|
||
3. Run the same algorithm on web, Wails, Capacitor, and PWA
|
||
targets — only the browser is supported in Phase 9, but no API
|
||
in this module assumes the platform.
|
||
4. Provide deterministic hit-test for cursor-to-primitive mapping,
|
||
with results that are unit-testable independently of Pixi.
|
||
|
||
## Coordinate model
|
||
|
||
World coordinates are TypeScript `number` (IEEE 754 float64). The
|
||
world is a rectangle `[0, W) × [0, H)` for some positive `W`,
|
||
`H`. Primitive geometry, the camera centre, and the no-wrap clamp
|
||
arithmetic all live in world coordinates.
|
||
|
||
Pixi's transform pipeline owns the world→screen mapping. We do
|
||
not maintain a manual fixed-point representation: the deprecated
|
||
Go renderer's fixed-point ints existed because it composited into
|
||
a pixel buffer, which we do not.
|
||
|
||
The camera is `{ centerX, centerY, scale }` with `scale` in pixels
|
||
per world unit. The viewport is `{ widthPx, heightPx }` in CSS
|
||
pixels (Pixi's `autoDensity` handles device pixel ratio
|
||
internally).
|
||
|
||
## Primitives
|
||
|
||
```ts
|
||
type Primitive = PointPrim | CirclePrim | LinePrim;
|
||
|
||
interface PrimitiveBase {
|
||
id: PrimitiveID;
|
||
priority: number;
|
||
style: Style;
|
||
hitSlopPx: number; // 0 = use kind default
|
||
}
|
||
|
||
interface PointPrim extends PrimitiveBase { kind: 'point'; x: number; y: number; }
|
||
interface CirclePrim extends PrimitiveBase { kind: 'circle'; x: number; y: number; radius: number; }
|
||
interface LinePrim extends PrimitiveBase { kind: 'line';
|
||
x1: number; y1: number; x2: number; y2: number; }
|
||
```
|
||
|
||
`radius` is in world units. `style.strokeWidthPx` and
|
||
`style.pointRadiusPx` are in screen pixels and stay constant under
|
||
zoom (Pixi's stroke width is in pixel space when the parent
|
||
container is scaled).
|
||
|
||
Default hit slop in screen pixels: point=8, circle=6, line=6.
|
||
These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0`
|
||
overrides them.
|
||
|
||
## Theme
|
||
|
||
A single dark theme ships in Phase 9. The theme is a record of
|
||
default colours; primitives whose `style` omits a colour fall back
|
||
to the theme. Runtime theme switching is not implemented — Phase
|
||
35 introduces light/dark and the materialise-on-theme-change
|
||
cycle.
|
||
|
||
## Hit-test
|
||
|
||
Algorithm in `src/map/hit-test.ts`:
|
||
|
||
```text
|
||
hitTest(world, camera, viewport, cursorPx, mode):
|
||
cursorWorld = screenToWorld(cursorPx, camera, viewport)
|
||
candidates = []
|
||
for p in world.primitives:
|
||
slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT[kind]
|
||
slopWorld = slopPx / camera.scale
|
||
delta =
|
||
mode == 'torus'
|
||
? torusShortestDelta(p, cursorWorld, world)
|
||
: euclideanDelta(p, cursorWorld)
|
||
distSq = match(delta, p.kind, p.geometry, slopWorld) // or null
|
||
if distSq != null: candidates.push({ p, distSq })
|
||
candidates.sort(by [-priority, distSq, kindOrder, id])
|
||
return candidates[0] ?? null
|
||
```
|
||
|
||
`torusShortestDelta` normalises a delta to the half-open interval
|
||
`(-size/2, size/2]` per axis, picking the shorter wrap direction.
|
||
At exactly `size/2` it returns `+size/2` (positive direction);
|
||
the lower bound is exclusive so `-size/2` is normalised to
|
||
`+size/2`.
|
||
|
||
`kindOrder` is `point=0, line=1, circle=2`. Point wins ties over
|
||
overlapping line/circle; this matches typical UX expectations
|
||
where a point object on top of a route should be the preferred
|
||
target.
|
||
|
||
Per-primitive distance:
|
||
|
||
- **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`.
|
||
- **Stroke-only circle**: `|dist - radius| ≤ slopWorld`. The
|
||
squared "distance" reported is the squared ring gap, so the
|
||
ordering rule prefers the closest-to-ring candidate among
|
||
multiple ring-only circles.
|
||
- **Line**: perpendicular distance to the segment, with `t`
|
||
clamped to `[0, 1]` (foot beyond endpoints uses the endpoint).
|
||
In torus mode the segment is taken in its torus-shortest
|
||
representation: from `(x1, y1)` to `(x1 + dx, y1 + dy)` where
|
||
`(dx, dy)` is the torus-shortest delta from end-1 to end-2.
|
||
|
||
The brute-force `O(N)` walk is fine for the Phase 9 target of
|
||
~1000 primitives on every pointer event. Spatial indexing is
|
||
deferred until profiling proves it necessary; PixiJS' culling and
|
||
batching handle the draw side without help.
|
||
|
||
## Torus rendering
|
||
|
||
The renderer creates nine container copies of the primitive scene
|
||
at offsets `(dx, dy) ∈ {-W, 0, W} × {-H, 0, H}`. In torus mode
|
||
all nine copies are visible; PixiJS culls the off-viewport copies
|
||
itself. In no-wrap mode only the origin copy `(0, 0)` is visible.
|
||
|
||
Lines that cross a torus boundary are not split at render time:
|
||
each copy renders the full line at its offset, and PixiJS' culling
|
||
naturally drops the parts outside its container's reachable area.
|
||
|
||
The nine-copy upper bound assumes the visible viewport never
|
||
exceeds three tile-widths or three tile-heights of the world. To
|
||
hold this assumption in both modes, the renderer enforces
|
||
`clampZoom({ minScale })` with `minScale = max(viewport.W/world.W,
|
||
viewport.H/world.H)` regardless of wrap mode. Without this, in
|
||
torus mode the user could zoom out far enough to see the 3×3 grid
|
||
of wrap copies at once — the copies are there to fill partial slack
|
||
near a panned edge, not to be visible simultaneously. The clamp is
|
||
re-evaluated on every viewport resize so a window resize does not
|
||
strand the camera below the new minimum.
|
||
|
||
## No-wrap camera
|
||
|
||
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
|
||
keeps the camera inside the world rectangle by default. We layer
|
||
the project-specific centring rule on top, implemented via the
|
||
`'moved'` event: when the visible viewport is larger than the world
|
||
along an axis, the camera is **centred** on that axis.
|
||
`pixi-viewport`'s default would pin the world to the top-left of
|
||
the screen, which is jarring at low zoom. The shared
|
||
`clampZoom({ minScale })` (described above) prevents this case in
|
||
practice, but the centring rule stays as a defensive layer for
|
||
windowed-resize transients.
|
||
|
||
`pivotZoom` keeps the world point under the cursor stable during
|
||
zoom. The math is symmetric and tested in
|
||
`tests/map-no-wrap.test.ts`.
|
||
|
||
## Dependencies
|
||
|
||
- **`pixi.js@^8`** — vector renderer with WebGPU/WebGL backend.
|
||
Async init via `app.init({ preference, ... })`. The
|
||
`preference` option may be a string or an array; the renderer
|
||
cascades through the array and falls back to whichever backend
|
||
initialises successfully.
|
||
- **`pixi-viewport@^6`** — pan/zoom/pinch plugin layer over a
|
||
Pixi `Container`. Provides drag inertia, mobile gestures, and
|
||
the `clamp`/`clampZoom` plugins out of the box. We disable the
|
||
plugins we do not need (`bounce`, `snap`, `follow`,
|
||
`mouse-edges`).
|
||
|
||
No additional dependencies are necessary. The deprecated
|
||
`pixi.js`-v7 era `pixi-viewport` v5 contracts have been replaced
|
||
in v6 (notably `events: renderer.events` is now mandatory in the
|
||
constructor).
|
||
|
||
## Renderer preference selection
|
||
|
||
The playground page reads `?renderer=webgpu|webgl` from the URL
|
||
and passes it to `Application.init`. Without the parameter the
|
||
preference defaults to `['webgpu', 'webgl']`. Playwright projects
|
||
use the URL parameter to force a specific backend per browser:
|
||
|
||
- `chromium-desktop` → `?renderer=webgpu`
|
||
- `webkit-desktop` → `?renderer=webgl` (WebKit does not implement
|
||
WebGPU yet)
|
||
- mobile projects → no parameter, accept whichever Pixi picks
|
||
|
||
The selected backend is exposed via `[data-backend]` on the
|
||
playground page header so the e2e spec can assert it without
|
||
poking Pixi internals.
|
||
|
||
## Performance acceptance
|
||
|
||
The "60 fps with 1000 primitives" criterion is documented but
|
||
manually verified, not asserted in CI. CI runners vary too much
|
||
in CPU/GPU to make wall-clock fps reliable. Manual gate: open
|
||
`/__debug/map`, drag continuously for 5 seconds, observe Pixi's
|
||
ticker FPS in DevTools (Pixi exposes `app.ticker.FPS`).
|
||
|
||
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`,
|
||
`distSqPointToSegment`, `screenToWorld`/`worldToScreen`.
|
||
- `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`,
|
||
`minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant
|
||
verified within float64 precision).
|
||
- `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.
|
||
|
||
The unit tests run in jsdom and never touch Pixi or
|
||
`pixi-viewport`, so a refactor of the renderer cannot silently
|
||
break them.
|