cc004f935d
The renderer's torus mode laid out the world in a 3×3 grid of wrap
copies (TORUS_OFFSETS) so the user could pan past an edge without
seeing a void. Below `minScale = max(viewport/world)` the world
shrinks below the viewport along at least one axis and the wrap
copies become visible side-by-side — the user reported a 9-tile
mosaic that pans and zooms as one rigid unit. The doc explicitly
deferred the fix ("if profiling ever reveals that users do this");
real usage is the trigger.
Apply `clampZoom({ minScale })` in both modes; torus still keeps
free pan (no `clamp({ direction: "all" })`) so the wrap copies
fill the cross-edge slack as designed. Resize re-evaluates the
clamp so a window resize does not strand the camera below the new
floor. Documentation in `ui/docs/renderer.md` updated to describe
the new shared invariant.
Regression test in `tests/e2e/playground-map.spec.ts` wheels out
aggressively in torus mode and asserts `camera.scale >= minScale`
across all four Playwright projects.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
242 lines
9.8 KiB
Markdown
242 lines
9.8 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 ≤ 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. 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.
|
||
|
||
## 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.
|