# 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 ≤ (pointRadiusPx + slopWorld)²`. The visible
  disc is part of the click target — a click on any pixel of the
  rendered planet registers as a hit, with `slopWorld` adding a
  small ergonomic margin on top. `pointRadiusPx` defaults to
  `DEFAULT_POINT_RADIUS_PX = 3` when unset.
- **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.

## Pick mode

Phase 16 introduced a generic *map-driven destination pick* the
inspector uses for cargo routes and that ship-group dispatch
(Phase 19/20) will reuse. The renderer owns the visual lifecycle;
the Svelte side wraps it in a promise-shaped service.

Lifecycle (`RendererHandle.setPickMode(opts)`):

1. **Open** (`opts !== null`): renderer marks `pickModeActive`,
   sets `alpha = 0.3` on every primitive whose id is neither the
   source nor in `reachableIds`, mounts an overlay `Graphics` in
   the origin tile, and subscribes to pointer-move + hover-change
   + viewport `clicked` + document `keydown`.
2. **Tick** (every pointer-move and hover transition): the
   renderer asks `computePickOverlay(opts, cursorWorld,
   hoveredId, points, allIds)` (`src/map/pick-mode.ts`) for a
   draw spec — anchor ring + cursor line + optional hover
   outline + dim set — and re-paints the overlay.
3. **Resolve**: a click on a primitive whose id is in
   `reachableIds` calls `opts.onPick(id)` and tears down. A click
   on empty space or a non-reachable primitive is a no-op
   (forgiving for accidental taps mid-pan). Escape (or the
   imperative `cancel()` on the returned handle) calls
   `opts.onPick(null)`.
4. **Tear down**: alpha overrides are restored, the overlay
   `Graphics` is destroyed, every listener is detached, and
   `pickModeActive` returns to `false`. Existing `onClick`
   subscriptions are gated on `pickModeActive`, so the standard
   planet-selection path does not fire on the destination click.

The pure overlay-spec helper lives in `src/map/pick-mode.ts` and
is covered by `tests/map-pick-mode.test.ts` without booting Pixi.
The Pixi side (alpha mutation, `Graphics` overlay, listener
hookup) is exercised in the in-browser e2e specs.

The Svelte adapter `MapPickService` (`src/lib/map-pick.svelte.ts`)
turns the callback contract into `pick(request) → Promise<id |
null>`. The map active view (`lib/active-view/map.svelte`)
constructs the service, sets `MAP_PICK_CONTEXT_KEY`, and binds a
resolver that translates `sourcePlanetNumber` to the underlying
`PickModeOptions` (looking up the source coordinates from the
current report). Inspector subsections call `service.pick(...)`
and react to the resolved id.

## Hidden primitives

`RendererHandle.setHiddenPrimitiveIds(ids)` replaces the current
hide-by-id set. Every primitive whose id sits in `ids` has its
per-copy `Graphics.visible` flipped to `false` and is skipped by
`hitAt`, so a click on its former area falls through to the next
visible primitive. An empty set restores everything. Repeated
calls are diff-free idempotent — `g.visible` assignments are
cheap.

The hide set is propagated to `hitTest` through a new optional
`hiddenIds` parameter so internal hit-test sites (pointer-move,
clicked dispatcher) stay in lock-step with the visible scene.
After `setExtraPrimitives` the hide set is re-applied so a
freshly-pushed extras layer (cargo-route overlay, pending-Send
tracks) does not silently un-hide a primitive whose id is in the
current set.

The Phase 29 map view (`src/lib/active-view/map.svelte`) computes
the set from the per-game `MapToggles` rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount.

## Visible-hyperspace overlay (the "fog")

`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
Phase 29 fog overlay used to highlight the player's visible
hyperspace. Each entry describes a circle around a LOCAL planet
where the player has scanner / visibility coverage:

- An empty list destroys the existing fog Graphics.
- A non-empty list creates one fog `Graphics` per torus copy.
  Each draws a world-sized rectangle filled with `FOG_COLOR` (two
  shades lighter than the dark theme background), then paints an
  opaque background-coloured circle on top for every visibility
  circle. The overpaint order naturally unions overlapping circles
  — earlier iterations used Pixi v8's `Graphics.cut()` to subtract
  holes, but `cut()` produces incorrect unions for multiple
  overlapping holes; layered repainting trades one extra fill per
  circle for a predictable, geometry-free union.
- The fog is inserted at the bottom of each copy's z-order so
  primitives paint on top.
- The fog never participates in hit-test. Planet glyphs sit on
  top of fog, so clicks on visible planets work unchanged.
- Wrap mode is honoured for free — `applyMode` hides every
  non-origin copy in `no-wrap`, so the fog inherits the same
  behaviour because the fog Graphics is a child of each copy.

The map view recomputes the fog input only when the report or the
`visibleHyperspace` toggle changes — per-frame cost stays at zero.

## Debug surface

The DEV-only `__galaxyDebug` object (defined in
`routes/__debug/store/+page.svelte`) exposes
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`, and
`getMapFog()` so e2e specs can assert the renderer's current
state without scraping pixels:

- `getMapPrimitives()` returns a snapshot of every primitive in
  the active world: id, kind, priority, current alpha
  (post-overlay), the explicit fill / stroke colour from its
  `Style` (no theme fallback), and the Phase 29 `visible` flag
  mirroring the renderer's hide set.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
  reachableIds, hoveredId }` — the renderer's view of the
  current pick session.
- `getMapCamera()` returns the current camera + viewport +
  canvas-origin snapshot, used by Phase 29 e2e specs to assert
  camera preservation across wrap-mode flips.
- `getMapFog()` returns the most recent fog input
  (the list of circles last passed to `setVisibilityFog`).
  Empty when the `visibleHyperspace` toggle is off.

The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
/ `registerMapCameraProvider` / `registerMapFogProvider` in
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
the surface invokes them lazily on every read.

## 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` — hand-built cases covering every
  rule from the algorithm above: hit/miss with default and
  custom slop (now including `pointRadiusPx`), torus wrap
  copies, filled vs stroked circles, line endpoint clamping,
  priority/kind/id ordering, scale effect on slop.
- `tests/map-pick-mode.test.ts` — pure-state coverage for
  `computePickOverlay`: anchor / line / hover-outline / dim-set
  shape against representative pick configurations.
- `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.
