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>
9.6 KiB
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/clientis deprecated. The Go module undergalaxy/client/— includingclient/world/— is no longer the reference implementation for any new code. The TypeScript renderer described here is independent: it does not importclient/worldat 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:
- Render thousands of vector primitives (points, circles, lines) onto a Pixi v8 canvas at 60 fps on a mid-range laptop.
- Support pan and zoom over a toroidal world (
'torus'mode) and over a bounded plane ('no-wrap'mode), both first-class. - 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.
- 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
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:
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)²whereradiusis in world units. The circle counts as filled whenstyle.fillColoris set andstyle.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
tclamped 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:
- 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. clampZoom({ minScale })enforcesminScale = 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 viaapp.init({ preference, ... }). Thepreferenceoption 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 PixiContainer. Provides drag inertia, mobile gestures, and theclamp/clampZoomplugins 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=webgpuwebkit-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.