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:
@@ -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.
|
||||
Reference in New Issue
Block a user