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

## Debug surface

The DEV-only `__galaxyDebug` object (defined in
`routes/__debug/store/+page.svelte`) exposes
`getMapPrimitives()` and `getMapPickState()` 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), and the explicit fill / stroke colour from its
  `Style` (no theme fallback). Tests use this to count cargo
  arrows or to verify dim state during pick mode.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
  reachableIds, hoveredId }` — the renderer's view of the
  current pick session.

The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
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.
