ui/phase-9: PixiJS map renderer with torus and no-wrap modes

Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 14:06:23 +02:00
parent 9d2504c42d
commit db415f8aa4
17 changed files with 2064 additions and 41 deletions
+241
View File
@@ -0,0 +1,241 @@
# 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 ≤ slopWorld²`.
- **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. In
no-wrap mode we enforce `clampZoom({ minScale })` directly. In
torus mode we do not enforce a minScale; the playground starts at
`minScale * 1.2` so a user has to zoom out aggressively before
seeing more than nine copies. If profiling ever reveals that
users do this, the renderer should switch to a generalised tile
loop.
## No-wrap camera
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
keeps the camera inside the world rectangle by default. We layer
two project-specific rules on top, both implemented via the
`'moved'` event:
1. 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.
2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W,
viewport.H/world.H)` so the user cannot zoom out below
"viewport fits world".
`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.
## 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` — 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/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.