# 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.