37580b7699
Tests · UI / test (push) Waiting to run
The Phase 29 fog overlay rendered as a handful of random arc segments instead of a clean union of holes around LOCAL planets — Pixi v8's `Graphics.cut()` does not reliably subtract multiple overlapping circles from a base path. Replaced the cut-based approach with a layered overpaint: a fog-tinted rectangle fills the world, then opaque background- coloured circles are painted on top for every visibility circle. The natural rendering order unions overlapping circles for free — no geometry, no `cut()` quirks, one extra fill per circle. Renamed the toggle from `visibilityFog` to `visibleHyperspace` across the store, i18n strings, popover, tests, and docs. The overlay still implements the visual "fog" effect at the renderer level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is named after the player-facing concept it controls — the portion of the map that is visible (intelligence/scan coverage) — rather than the obscured part. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
374 lines
16 KiB
Markdown
374 lines
16 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.
|
||
|
||
## 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.
|
||
|
||
## Visible-hyperspace overlay (the "fog")
|
||
|
||
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
|
||
Phase 29 fog overlay used to highlight the player's visible
|
||
hyperspace. 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 draws a world-sized rectangle filled with `FOG_COLOR` (two
|
||
shades lighter than the dark theme background), then paints an
|
||
opaque background-coloured circle on top for every visibility
|
||
circle. The overpaint order naturally unions overlapping circles
|
||
— earlier iterations used Pixi v8's `Graphics.cut()` to subtract
|
||
holes, but `cut()` produces incorrect unions for multiple
|
||
overlapping holes; layered repainting trades one extra fill per
|
||
circle for a predictable, geometry-free union.
|
||
- 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
|
||
`visibleHyperspace` 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 fog input
|
||
(the list of circles last passed to `setVisibilityFog`).
|
||
Empty when the `visibleHyperspace` 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.
|