Files
galaxy-game/ui/docs/renderer.md
T
Ilia Denisov 2bd1b54936
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
feat(ui): Phase 29 map visibility toggles
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:33:53 +02:00

369 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## Hidden primitives
`RendererHandle.setHiddenPrimitiveIds(ids)` replaces the current
hide-by-id set. Every primitive whose id sits in `ids` has its
per-copy `Graphics.visible` flipped to `false` and is skipped by
`hitAt`, so a click on its former area falls through to the next
visible primitive. An empty set restores everything. Repeated
calls are diff-free idempotent — `g.visible` assignments are
cheap.
The hide set is propagated to `hitTest` through a new optional
`hiddenIds` parameter so internal hit-test sites (pointer-move,
clicked dispatcher) stay in lock-step with the visible scene.
After `setExtraPrimitives` the hide set is re-applied so a
freshly-pushed extras layer (cargo-route overlay, pending-Send
tracks) does not silently un-hide a primitive whose id is in the
current set.
The Phase 29 map view (`src/lib/active-view/map.svelte`) computes
the set from the per-game `MapToggles` rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount.
## Visibility fog
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
Phase 29 fog overlay. Each entry describes a circle around a
LOCAL planet where the player has scanner / visibility coverage:
- An empty list destroys the existing fog Graphics.
- A non-empty list creates one fog `Graphics` per torus copy.
Each fills the world rectangle with `FOG_COLOR` (two shades
lighter than the dark theme background) and "cuts" every
circle out of it via Pixi v8's `Graphics.cut()` path operator,
so overlapping circles compose into a union hole (no
even-odd-fill quirks). The fog is inserted at the bottom of
each copy's z-order so primitives paint on top.
- The fog never participates in hit-test. Planet glyphs sit on
top of fog, so clicks on visible planets work unchanged.
- Wrap mode is honoured for free — `applyMode` hides every
non-origin copy in `no-wrap`, so the fog inherits the same
behaviour because the fog Graphics is a child of each copy.
The map view recomputes the fog input only when the report or the
fog toggle changes — per-frame cost stays at zero.
## Debug surface
The DEV-only `__galaxyDebug` object (defined in
`routes/__debug/store/+page.svelte`) exposes
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`, and
`getMapFog()` 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), the explicit fill / stroke colour from its
`Style` (no theme fallback), and the Phase 29 `visible` flag
mirroring the renderer's hide set.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
reachableIds, hoveredId }` — the renderer's view of the
current pick session.
- `getMapCamera()` returns the current camera + viewport +
canvas-origin snapshot, used by Phase 29 e2e specs to assert
camera preservation across wrap-mode flips.
- `getMapFog()` returns the most recent visibility-fog input
(the list of circles last passed to `setVisibilityFog`).
Empty when the fog toggle is off.
The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
/ `registerMapCameraProvider` / `registerMapFogProvider` 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.