// PixiJS map renderer with pan/zoom, torus and no-wrap modes. // // Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance // configured for the active wrap mode. Torus mode renders nine // container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the // user a seamless toroidal world while panning past the edge — and // keeps the camera centre snapped into the central tile via a // `moved` listener so the fixed 3×3 layout is sufficient for any // distance of pan. No-wrap mode hides eight of the nine copies and // pins the camera with `pixi-viewport`'s `clamp` plugin plus a // `moved` listener that recentres the camera when the visible // viewport exceeds the world along an axis. Both modes share a // `clampZoom({ minScale })` so the world (origin copy) always fills // at least the viewport — without it torus mode would expose all // nine copies at once. // // Hit-test is owned by ./hit-test.ts; this file only exposes the // current camera and viewport so callers can run hits. import { Application, Container, Graphics, Text, Ticker, UPDATE_PRIORITY, type Renderer, type RendererType, } from "pixi.js"; import { Viewport as PixiViewport } from "pixi-viewport"; import { hitTest, type Hit } from "./hit-test"; import type { PlanetLabelData } from "./labels"; import { screenToWorld } from "./math"; import { minScaleNoWrap } from "./no-wrap"; import { computePickOverlay, PICK_OVERLAY_STYLE, type PickModeHandle, type PickModeOptions, } from "./pick-mode"; import { wrapCameraTorus } from "./torus"; import { DARK_THEME, displayPointRadiusWorld, displayStrokeWidthWorld, World, type Camera, type CirclePrim, type LinePrim, type PointPrim, type Primitive, type PrimitiveID, type Theme, type Viewport, type WrapMode, } from "./world"; // RendererPreference matches Pixi's accepted values for backend // selection. The map renderer always restricts to webgpu/webgl. export type RendererPreference = "webgpu" | "webgl"; /** * PlanetOutlineSpec drives the F8-12 planet-outline overlay (#30): * a thin stroke painted around the planet disc that signals * selection / bombing without adding a separate ring marker. The * renderer hugs the outline to the visible disc on every zoom step, * so callers do not have to recompute it. */ export interface PlanetOutlineSpec { readonly planetNumber: number; readonly color: number; /** Stroke width in screen pixels. Defaults to 1.5 when omitted. */ readonly widthPx?: number; } export interface RendererOptions { canvas: HTMLCanvasElement; world: World; mode: WrapMode; preference?: RendererPreference | RendererPreference[]; theme?: Theme; resolution?: number; // device pixel ratio override; defaults to window.devicePixelRatio } export interface RendererHandle { app: Application; viewport: PixiViewport; getMode(): WrapMode; setMode(mode: WrapMode): void; getCamera(): Camera; getViewport(): Viewport; getBackend(): "webgl" | "webgpu" | "canvas"; /** * getRenderCount returns how many frames the renderer has actually * painted since creation. The renderer runs render-on-demand (the * Pixi auto-render loop is stopped), so this counter only advances * when the camera moved or a content mutation requested a repaint — * never on idle frames. Exposed for the debug surface so e2e specs * can assert that an idle map does not keep repainting. */ getRenderCount(): number; hitAt(cursorPx: { x: number; y: number }): Hit | null; /** * setExtraPrimitives replaces the current overlay primitive layer * with `prims`. The base world (passed to `createRenderer`) is * preserved; only the extras layer changes. Used by the in-game * shell to project order-overlay-driven artefacts (Phase 16 * cargo-route arrows) onto the live renderer without disposing * and recreating the Pixi `Application` — which Pixi 8 does not * reliably support on the same canvas. * * Hit-test, `getPrimitives`, and pick mode all see the union of * base + extras after the call returns. Repeated calls * remount-replace the extras atomically. */ setExtraPrimitives(prims: readonly Primitive[]): void; /** * getPrimitives returns the live union of base + extras. The * order is base-first, extras-last (mirroring the draw order). * Reads stay in sync with `setExtraPrimitives`. */ getPrimitives(): readonly Primitive[]; /** * onClick subscribes `cb` to a click on the map (a pointer-down / * pointer-up pair without enough drag to trigger pan). The cursor * is reported in canvas pixel coordinates so callers can hand it * straight to `hitAt`. Returns a function that detaches the * listener; the returned disposer is idempotent. * * Built on `pixi-viewport`'s `clicked` event, which already * applies the same drag threshold the pan plugin uses, so a * click here will not race a pan gesture. */ onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void; /** * onPointerMove subscribes `cb` to every pointer-move event on * the canvas. The callback receives the cursor in canvas-local * pixel coordinates so callers can hand it straight to `hitAt`. * Touch drags also emit pointer-move while a finger is pressed. * The returned function detaches the listener; idempotent. */ onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void; /** * onHoverChange subscribes `cb` to changes in the primitive * currently under the cursor. The callback fires only when the * id transitions (deduped) and is invoked with `null` when the * cursor moves into empty space. Driven by the same pointer-move * stream as `onPointerMove`, so subscribing to both does not * double-cost the pointer event. */ onHoverChange(cb: (id: PrimitiveID | null) => void): () => void; /** * setPickMode opens (or, with `null`, closes) a map-driven * destination pick. While a session is active the renderer dims * primitives outside `reachableIds`, mounts an overlay drawing * the source-anchor ring, the cursor line, and the * hover-highlight ring, suppresses regular `onClick` consumers, * and listens for Escape on `document`. The session resolves via * `opts.onPick(id)` on a click hitting a reachable planet, or * `opts.onPick(null)` on Escape / handle.cancel(). * * Returns the imperative cancel handle when a session was opened * (i.e. `opts !== null`), otherwise `null`. Calling the function * again with `null` closes any active session and is idempotent. */ setPickMode(opts: PickModeOptions | null): PickModeHandle | null; /** * isPickModeActive reports whether a `setPickMode` session is * currently open. The standard `onClick` path is suppressed * while this returns `true`. */ isPickModeActive(): boolean; /** * getPickState returns a defensive snapshot of the pick-mode * session for debugging surfaces. `sourcePrimitiveId` and * `reachableIds` are `null` while no session is open. */ getPickState(): { active: boolean; sourcePrimitiveId: PrimitiveID | null; reachableIds: ReadonlySet | null; hoveredId: PrimitiveID | null; }; /** * getPrimitiveAlpha returns the current rendered alpha of the * primitive `id` (in the central tile). Used by the debug * surface to report dimmed-state for e2e assertions. Returns 1 * for unknown ids. */ getPrimitiveAlpha(id: PrimitiveID): number; /** * setHiddenPrimitiveIds replaces the set of primitives the * renderer should hide. Hidden primitives have their per-copy * `Graphics.visible` flipped to `false` and are skipped by * `hitAt`, so a click on the area they used to cover falls * through to the next primitive. Empty input clears the hide * set. Called every effect run by the Phase 29 map view to * materialise the `MapToggles` flags + planet-cascade rule * without a Pixi remount. */ setHiddenPrimitiveIds(ids: ReadonlySet): void; /** * isPrimitiveHidden reports whether the supplied primitive id is * in the current hide set. Used by the debug surface so e2e * specs can assert toggle behaviour without poking at Pixi * internals. */ isPrimitiveHidden(id: PrimitiveID): boolean; /** * setVisibilityFog draws (or removes) the Phase 29 visibility * fog overlay. Each entry describes a circle around a LOCAL * planet that the player has scanner / visibility coverage on; * the overlay fills the world rectangle with a slightly lighter * fog colour and "punches" each circle out, leaving the * intelligence-covered area in the regular background. Empty * input destroys the existing fog Graphics. */ setVisibilityFog( circles: ReadonlyArray<{ x: number; y: number; radius: number }>, ): void; /** * setPlanetLabels replaces the on-map planet label dataset * (F8-12 / #29). Each entry is anchored to its planet's * `(x, y)` and the renderer keeps the labels just below the * disc, repositioning them on every zoom step so the gap stays * constant in screen pixels. `selectedPlanetId` (or `null`) * controls which label gets the inverse-fill selection frame * (F8-12 / #30); pass `null` when no planet is selected. */ setPlanetLabels( labels: ReadonlyArray, selectedPlanetId: number | null, ): void; /** * setPlanetOutlines replaces the planet-outline overlay set * (F8-12 / #30). Each entry paints a thin stroke around the * planet's visible disc — the radius follows the soft / pixel * sizing rules so the outline hugs the planet at any zoom. * Used by both selection (selection accent colour) and bombing * (damaged / wiped colour) signals. */ setPlanetOutlines(outlines: ReadonlyArray): void; resize(widthPx: number, heightPx: number): void; dispose(): void; } const TORUS_OFFSETS: ReadonlyArray = [ [-1, -1], [0, -1], [1, -1], [-1, 0], [0, 0], [1, 0], [-1, 1], [0, 1], [1, 1], ]; const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS // EMPTY_HIDDEN_IDS is the default state of the Phase 29 hide set // (no primitive is hidden). Shared by every renderer instance so a // frequent `setHiddenPrimitiveIds(EMPTY_HIDDEN_IDS)` call from the // debug surface stays allocation-free. const EMPTY_HIDDEN_IDS: ReadonlySet = new Set(); /** * FogPaintOp is one item in the ordered draw sequence produced by * `fogPaintOps`. The renderer dispatches each op directly onto a * Pixi `Graphics`; the indirection exists so the Phase 29 layered * overpaint (fog rect then background-coloured circles on top) can * be unit-tested without a Pixi context. */ export type FogPaintOp = | { readonly kind: "fillRect"; readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly color: number; readonly alpha: number; } | { readonly kind: "fillCircle"; readonly x: number; readonly y: number; readonly radius: number; readonly color: number; readonly alpha: number; }; /** * fogPaintOps returns the ordered sequence of paint operations that * draw the Phase 29 visible-hyperspace overlay. The renderer * dispatches each op onto its own Pixi `Graphics` inside a single * `fogLayer` that sits below every primitive copy, so the natural * rendering order paints fog underneath the world. * * Coordinates are in world space (the `fogLayer` has no transform), * which means the wrap offsets are baked directly into the * positions — there is no per-tile dispatch on the renderer side. * * `mode` controls the torus-wrap behaviour: * * - `"torus"`: every fog rect AND every visibility circle is * emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so * the fog covers all nine torus tiles and a planet near a seam * keeps a continuous visibility hole across it. * - `"no-wrap"`: only the central tile is emitted. The user can * never pan past the boundary in no-wrap mode, so the * additional wraps would just be wasted paint — worse, a * wrapped circle from a planet near an edge would leak into * the visible world rectangle as an unwanted hole. * * Empty `circles` returns an empty list — the caller skips fog * rendering entirely. Width/height ≤ 0 also returns empty so a * degenerate world cannot produce a non-empty op set. */ export function fogPaintOps( world: { width: number; height: number }, circles: ReadonlyArray<{ x: number; y: number; radius: number }>, fogColor: number, bgColor: number, mode: WrapMode, ): FogPaintOp[] { if (circles.length === 0) return []; if (world.width <= 0 || world.height <= 0) return []; const offsets: ReadonlyArray = mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; const ops: FogPaintOp[] = []; for (const [dx, dy] of offsets) { ops.push({ kind: "fillRect", x: dx * world.width, y: dy * world.height, width: world.width, height: world.height, color: fogColor, alpha: 1, }); } for (const c of circles) { for (const [dx, dy] of offsets) { ops.push({ kind: "fillCircle", x: c.x + dx * world.width, y: c.y + dy * world.height, radius: c.radius, color: bgColor, alpha: 1, }); } } return ops; } const ORIGIN_ONLY_OFFSET: ReadonlyArray = [[0, 0]]; export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; const preference = opts.preference ?? ["webgpu", "webgl"]; const resolution = opts.resolution ?? globalThis.devicePixelRatio ?? 1; const canvas = opts.canvas; const widthPx = canvas.clientWidth || canvas.width || 800; const heightPx = canvas.clientHeight || canvas.height || 600; const app = new Application(); await app.init({ canvas, width: widthPx, height: heightPx, preference, backgroundColor: theme.background, backgroundAlpha: 1, antialias: true, autoDensity: true, resolution, }); // Render-on-demand: stop Pixi's continuous auto-render loop. Frames // are painted explicitly by `renderFlush` below, only when the // camera moved or a content mutation requested a repaint. This is // what stops the heavy visibility-fog overlay from re-rasterising // every frame and freezing the whole UI on large reports. app.stop(); const viewport = new PixiViewport({ screenWidth: widthPx, screenHeight: heightPx, worldWidth: opts.world.width, worldHeight: opts.world.height, events: app.renderer.events, }); // No `.decelerate()`: panning stops the instant the drag is // released instead of coasting. Besides matching the requested feel, // it means the viewport stops mutating its transform as soon as the // pointer is up, so render-on-demand goes idle immediately after a // drag rather than repainting through an inertia tail. viewport.drag().wheel({ smooth: 5 }).pinch(); // Render-on-demand wiring. `viewport.dirty` is maintained by // pixi-viewport's own `Ticker.shared` update and flips true on any // camera move (drag / wheel / pinch / programmatic `moveCenter` / // the torus + no-wrap `moved` listeners). `contentDirty` is flipped // by `requestRender` from every scene-graph mutation that does not // move the camera (fog, hide-set, extras, wrap mode, resize, pick // overlay). The flush runs at LOW priority so it observes the // viewport's freshly updated `dirty` flag within the same shared // tick. Plain hover mutates no Graphics, so it never repaints. let contentDirty = true; // force the first paint after mount let renderCount = 0; const requestRender = (): void => { contentDirty = true; }; const renderFlush = (): void => { if (!viewport.dirty && !contentDirty) return; app.render(); viewport.dirty = false; contentDirty = false; renderCount++; }; Ticker.shared.add(renderFlush, undefined, UPDATE_PRIORITY.LOW); app.stage.addChild(viewport); // Phase 29 fog layer: a single Container sharing the viewport's // coordinate space, populated by `setVisibilityFog`. Added to // the viewport BEFORE the nine torus copies so the fog always // renders underneath every primitive. The layer holds the // fog-coloured world rectangle(s); the visibility holes are cut by // an inverse stencil mask (`fogMask`, the union of the visibility // circles) rather than by opaque overpaint — see `setVisibilityFog`. // An earlier per-copy approach with `copy.addChildAt(fog, 0)` ended // up with fog on top in practice — moving the fog to a sibling of // the copies avoids any reorder ambiguity. const fogLayer = new Container(); viewport.addChild(fogLayer); // Inverse mask for `fogLayer`, rebuilt by `setVisibilityFog`. Pixi // requires a mask to live in the display list, so it sits as a // sibling under the viewport; `null` whenever the fog is off. let fogMask: Graphics | null = null; // Create nine torus copies, each holding its own primitive // graphics. Origin copy is always visible; the other eight // follow the active wrap mode. const copies: Container[] = TORUS_OFFSETS.map(([dx, dy]) => { const c = new Container(); c.x = dx * opts.world.width; c.y = dy * opts.world.height; viewport.addChild(c); return c; }); // Outline + label layers (F8-12 / #29 + #30). Both live in the // origin copy only — replicating Pixi.Text / Graphics across all // nine torus copies is the dominant cost on a 100+ planet map, // and the player almost never sees a label "wrap" out of the // central tile because the camera-wrap listener snaps the centre // back into `[0, W) × [0, H)` whenever it walks past the seam. // Outlines sit between the primitive disc and the labels so the // stroke reads against the planet fill while staying below the // textual layer. const outlineLayer = new Container(); const labelLayer = new Container(); copies[ORIGIN_COPY_INDEX].addChild(outlineLayer); copies[ORIGIN_COPY_INDEX].addChild(labelLayer); // Per-id `Graphics` lookup. Each primitive lives in nine copies // (one per torus tile); pick-mode dims them by id, so the lookup // indexes the full set of `Graphics` instances per primitive id. const primitiveGraphics = new Map(); const pointPrimitivesById = new Map(); // primitiveById holds the original `Primitive` for every emitted // id so the F8-12 zoom rebuild can replay the geometry with the // current camera scale without having to re-derive it from a // fresh report. The map covers base + extras alike. const primitiveById = new Map(); const allPrimitiveIds: PrimitiveID[] = []; const extraPrimitiveIds = new Set(); let currentWorld: World = opts.world; // currentScaleRef mirrors the `minScaleNoWrap` value: the scale at // which the whole world fits the viewport. The non-linear planet // size formula softens growth relative to this reference (#31), and // it changes when the viewport resizes — recomputed in `applyMode` // and `resize`. let currentScaleRef = minScaleNoWrap( { widthPx: widthPx, heightPx: heightPx }, opts.world, ); // hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default; // every map-view effect run replaces it with the current // MapToggles-derived set via `setHiddenPrimitiveIds`. Both // renderer-internal hit-test sites (pointer-move, clicked) and the // external `handle.hitAt` thread it through `hitTest`. let hiddenIds: ReadonlySet = EMPTY_HIDDEN_IDS; // `fogLayer` (declared above) is repopulated every time // `setVisibilityFog` runs. We track the dispatched ops only // implicitly via the layer's children; on every flip we drop // the previous children and rebuild from the new op list. const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const visible = !hiddenIds.has(id); for (const g of list) g.visible = visible; }; const drawPrimitiveInto = (prim: Primitive, g: Graphics): void => { g.clear(); if (prim.kind === "point") { drawPoint(g, prim, theme, viewport.scaled, currentScaleRef); } else if (prim.kind === "circle") { drawCircle(g, prim, theme, viewport.scaled); } else { drawLine(g, prim, theme, viewport.scaled); } }; const populatePrimitives = (prim: Primitive, isExtra: boolean): void => { for (const c of copies) { const g = new Graphics(); drawPrimitiveInto(prim, g); c.addChild(g); let list = primitiveGraphics.get(prim.id); if (list === undefined) { list = []; primitiveGraphics.set(prim.id, list); } list.push(g); } allPrimitiveIds.push(prim.id); primitiveById.set(prim.id, prim); if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim); if (isExtra) extraPrimitiveIds.add(prim.id); // Fresh primitives honour the current hide set so cargo-route // or pending-Send extras pushed after `setHiddenPrimitiveIds` // inherit the right visibility. const list = primitiveGraphics.get(prim.id); if (list !== undefined) applyHiddenStateTo(prim.id, list); }; // redrawAllPrimitives replays every primitive's geometry into its // existing per-copy `Graphics` instances using the current camera // scale. The F8-12 zoom-invariant contract requires the renderer // to repaint pixel-space sizes (#28) and the softened planet // radius (#31) whenever the camera zoom changes — instead of // destroying and recreating the Graphics, we `clear()` and redraw // so dim/visibility state on each Graphics is preserved. const redrawAllPrimitives = (): void => { for (const [id, list] of primitiveGraphics) { const prim = primitiveById.get(id); if (prim === undefined) continue; for (const g of list) drawPrimitiveInto(prim, g); } }; for (const p of opts.world.primitives) { populatePrimitives(p, false); } // Planet label state (F8-12 / #29 + #30). One Container per // planet, anchored at the planet's `(x, y)` in the origin copy. // `currentLabels` mirrors the dataset last passed into // `setPlanetLabels` so a zoom-driven transform update does not // need a fresh report. interface LabelGfx { readonly container: Container; readonly frame: Graphics; readonly nameText: Text | null; readonly numberText: Text; } const planetLabelInstances = new Map(); let currentLabels: ReadonlyArray = []; let currentLabelsFingerprint: string | null = null; let currentLabelsSelectedId: number | null = null; const fingerprintPlanetLabels = ( labels: ReadonlyArray, ): string => { const parts: string[] = []; for (const l of labels) { parts.push(`${l.planetNumber};${l.name ?? ""};${l.numberLabel}`); } return parts.join("|"); }; const LABEL_FONT_SIZE_PX = 11; const LABEL_LINE_GAP_PX = 0; const LABEL_FRAME_PADDING_PX = 3; const LABEL_OFFSET_PX = 4; // gap between planet disc and the label const buildLabelText = ( content: string, fillColor: number, ): Text => { const t = new Text({ text: content, style: { fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", fontSize: LABEL_FONT_SIZE_PX, fill: fillColor, align: "center", }, }); t.anchor.set(0.5, 0); return t; }; const disposeLabelGfx = (entry: LabelGfx): void => { entry.nameText?.destroy(); entry.numberText.destroy(); entry.frame.destroy(); entry.container.parent?.removeChild(entry.container); entry.container.destroy(); }; const clearAllLabels = (): void => { for (const entry of planetLabelInstances.values()) { disposeLabelGfx(entry); } planetLabelInstances.clear(); currentLabelsFingerprint = null; currentLabelsSelectedId = null; }; const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => { // Text colours flip on selection so the legend reads on the // inverse-fill frame. const nameFill = isSelected ? theme.labelInverseText : theme.labelText; const numberFill = isSelected ? theme.labelInverseText : entry.nameText !== null ? theme.labelMuted : theme.labelText; if (entry.nameText !== null) { entry.nameText.style.fill = nameFill; } entry.numberText.style.fill = numberFill; const nameHeight = entry.nameText?.height ?? 0; const numberHeight = entry.numberText.height; const totalTextHeight = nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight; entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0; entry.frame.clear(); if (!isSelected) { entry.frame.visible = false; return; } const widestText = Math.max( entry.nameText?.width ?? 0, entry.numberText.width, ); const frameWidth = widestText + LABEL_FRAME_PADDING_PX * 2; const frameHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2; entry.frame.roundRect( -frameWidth / 2, -LABEL_FRAME_PADDING_PX, frameWidth, frameHeight, 3, ); entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 }); entry.frame.visible = true; }; const updateLabelTransforms = (): void => { const cameraScale = viewport.scaled; if (cameraScale <= 0) return; const labelScale = 1 / cameraScale; const gapWorld = LABEL_OFFSET_PX / cameraScale; for (const data of currentLabels) { const entry = planetLabelInstances.get(data.planetNumber); if (entry === undefined) continue; const planetPrim = pointPrimitivesById.get(data.planetNumber); const visibleRadius = planetPrim === undefined ? 0 : displayPointRadiusWorld( planetPrim.style, cameraScale, currentScaleRef, ); entry.container.x = data.x; entry.container.y = data.y + visibleRadius + gapWorld; entry.container.scale.set(labelScale); } }; // Planet outline state (F8-12 / #30). One Graphics per planet, // painted in the origin copy alongside the label container. Width // and colour come from `PlanetOutlineSpec`; the radius is // recomputed on every zoom step so the outline tracks the visible // disc — the planet itself may grow / shrink with zoom // (`pointRadiusBasePx` softening) or stay constant // (`pointRadiusPx` pixel-space). interface PlanetOutlineGfx { readonly graphics: Graphics; readonly spec: PlanetOutlineSpec; } const planetOutlineInstances = new Map(); let currentOutlinesFingerprint: string | null = null; const fingerprintPlanetOutlines = ( outlines: ReadonlyArray, ): string => { const parts: string[] = []; for (const o of outlines) { parts.push(`${o.planetNumber};${o.color};${o.widthPx ?? -1}`); } return parts.join("|"); }; const OUTLINE_DEFAULT_WIDTH_PX = 1.5; const OUTLINE_RADIUS_PADDING_PX = 1; // gap between disc edge and stroke const clearAllOutlines = (): void => { for (const entry of planetOutlineInstances.values()) { entry.graphics.parent?.removeChild(entry.graphics); entry.graphics.destroy(); } planetOutlineInstances.clear(); currentOutlinesFingerprint = null; }; const paintOutlineEntry = (entry: PlanetOutlineGfx): void => { const cameraScale = viewport.scaled; if (cameraScale <= 0) return; const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber); entry.graphics.clear(); if (planetPrim === undefined) return; const visibleRadius = displayPointRadiusWorld( planetPrim.style, cameraScale, currentScaleRef, ); const paddingWorld = OUTLINE_RADIUS_PADDING_PX / cameraScale; const widthWorld = (entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale; const outlineRadius = visibleRadius + paddingWorld; entry.graphics.circle(planetPrim.x, planetPrim.y, outlineRadius); entry.graphics.stroke({ color: entry.spec.color, alpha: 0.95, width: widthWorld, }); }; const updateOutlineTransforms = (): void => { for (const entry of planetOutlineInstances.values()) { paintOutlineEntry(entry); } }; const setPlanetOutlines = ( outlines: ReadonlyArray, ): void => { const fp = fingerprintPlanetOutlines(outlines); if (fp === currentOutlinesFingerprint) { // Same dataset — just refresh the geometry (the planet // position / size may have changed in the underlying // primitive). Keeps Graphics instances around. for (const entry of planetOutlineInstances.values()) { paintOutlineEntry(entry); } return; } clearAllOutlines(); currentOutlinesFingerprint = fp; for (const spec of outlines) { const g = new Graphics(); outlineLayer.addChild(g); const entry: PlanetOutlineGfx = { graphics: g, spec }; planetOutlineInstances.set(spec.planetNumber, entry); paintOutlineEntry(entry); } requestRender(); }; const setPlanetLabels = ( labels: ReadonlyArray, selectedPlanetId: number | null, ): void => { const fp = fingerprintPlanetLabels(labels); const sameContent = fp === currentLabelsFingerprint; const sameSelection = selectedPlanetId === currentLabelsSelectedId; if (sameContent && sameSelection) { // Position-only update (a zoom step may have moved planets // in the data) — keep Pixi.Text instances alive. currentLabels = labels.slice(); updateLabelTransforms(); return; } if (sameContent) { // Text + planet identity unchanged; only the selection frame // flips. Repaint the affected entries instead of rebuilding. currentLabels = labels.slice(); for (const data of labels) { const entry = planetLabelInstances.get(data.planetNumber); if (entry === undefined) continue; paintLabelEntry(entry, data.planetNumber === selectedPlanetId); } currentLabelsSelectedId = selectedPlanetId; updateLabelTransforms(); requestRender(); return; } clearAllLabels(); currentLabels = labels.slice(); currentLabelsFingerprint = fp; currentLabelsSelectedId = selectedPlanetId; for (const data of labels) { const container = new Container(); const frame = new Graphics(); frame.visible = false; container.addChild(frame); const nameText = data.name === null ? null : buildLabelText(data.name, theme.labelText); if (nameText !== null) container.addChild(nameText); const numberText = buildLabelText(data.numberLabel, theme.labelMuted); container.addChild(numberText); labelLayer.addChild(container); const entry: LabelGfx = { container, frame, nameText, numberText, }; paintLabelEntry(entry, data.planetNumber === selectedPlanetId); planetLabelInstances.set(data.planetNumber, entry); } updateLabelTransforms(); requestRender(); }; let mode: WrapMode = opts.mode; const enforceCentreWhenLarger = (): void => { const halfW = viewport.screenWidth / (2 * viewport.scaled); const halfH = viewport.screenHeight / (2 * viewport.scaled); const overX = halfW * 2 >= opts.world.width; const overY = halfH * 2 >= opts.world.height; if (!overX && !overY) return; viewport.moveCenter( overX ? opts.world.width / 2 : viewport.center.x, overY ? opts.world.height / 2 : viewport.center.y, ); }; // Reentry guard for the torus wrap handler: `viewport.moveCenter` // fires the same `'moved'` event that triggered the wrap, so a // naive callback would loop forever. let wrappingCamera = false; const wrapTorusCamera = (): void => { if (mode !== "torus" || wrappingCamera) return; const wrapped = wrapCameraTorus( { centerX: viewport.center.x, centerY: viewport.center.y, scale: viewport.scaled, }, opts.world, ); if (wrapped.centerX === viewport.center.x && wrapped.centerY === viewport.center.y) { return; } wrappingCamera = true; try { viewport.moveCenter(wrapped.centerX, wrapped.centerY); } finally { wrappingCamera = false; } }; const applyMode = (newMode: WrapMode): void => { mode = newMode; for (let i = 0; i < copies.length; i++) { copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX; } // Always reset clamp plugins; reattach per mode. viewport.plugins.remove("clamp"); viewport.plugins.remove("clamp-zoom"); viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", wrapTorusCamera); const minScale = minScaleNoWrap( { widthPx: viewport.screenWidth, heightPx: viewport.screenHeight }, opts.world, ); currentScaleRef = minScale; // Both modes enforce minScale on zoom-out: the world (origin // copy) always fills at least the viewport. Without this, in // torus mode the user would zoom out far enough to see the // 3×3 grid of wrap copies at once; the copies are there to // fill the partial slack near a panned edge, not to be // visible simultaneously. viewport.clampZoom({ minScale }); if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (newMode === "no-wrap") { viewport.clamp({ direction: "all" }); viewport.on("moved", enforceCentreWhenLarger); enforceCentreWhenLarger(); } else { // Torus mode keeps free pan: the user can drag in any // direction indefinitely. To keep the fixed 3×3 wrap // layout sufficient, snap the camera back into the // `[0, W) × [0, H)` central tile whenever it walks past // the edge — toroidal coordinates are equivalent modulo // world dimensions, so the user sees no jump. viewport.on("moved", wrapTorusCamera); wrapTorusCamera(); } // The F8-12 zoom-invariant contract requires pixel-space sizes // (#28) and the softened planet radius (#31) to track the // current scale. `applyMode` can change `viewport.scaled` (e.g. // the `setZoom(minScale, true)` clamp above), so redraw all // primitives before the next paint and re-place the labels / // outlines too. redrawAllPrimitives(); updateOutlineTransforms(); updateLabelTransforms(); // Toggling `copy.visible` does not move the camera, so request a // repaint explicitly; any camera change above also sets // `viewport.dirty`, which is harmless to request twice. requestRender(); }; applyMode(mode); // `handleZoomed` is defined inline here so it can close over both // `redrawAllPrimitives` and the still-to-be-declared // `redrawPickOverlay`; the `viewport.on("zoomed", …)` subscription // happens further below, after both helpers are in scope. F8-12 // (#28, #31): pixel-space sizes and the softened planet radius // depend on `viewport.scaled`, so a zoom step needs to repaint // every base primitive in the same frame. // Pointer-move + hover plumbing. Listening on the underlying // canvas keeps the renderer agnostic of pixi-viewport's plugin // chain (drag/pinch can swallow Pixi-level pointer events while // a gesture is in progress; the DOM event still fires). const pointerMoveCallbacks = new Set< (cursorPx: { x: number; y: number }) => void >(); const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>(); let lastHoveredId: PrimitiveID | null = null; let lastCursorPx: { x: number; y: number } | null = null; const handlePointerMove = (event: PointerEvent): void => { const rect = canvas.getBoundingClientRect(); const cursorPx = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; lastCursorPx = cursorPx; for (const cb of pointerMoveCallbacks) cb(cursorPx); const hit = hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, hiddenIds, ); const hoveredId = hit?.primitive.id ?? null; if (hoveredId === lastHoveredId) return; lastHoveredId = hoveredId; for (const cb of hoverChangeCallbacks) cb(hoveredId); }; const handlePointerLeave = (): void => { lastCursorPx = null; if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return; lastHoveredId = null; for (const cb of hoverChangeCallbacks) cb(null); }; canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerleave", handlePointerLeave); // Click dispatch. The renderer owns one `viewport.clicked` // listener and fans the event out to either the pick-mode // resolver (when a session is open) or the standard `onClick` // subscribers — never both. Routing through one listener makes // the gating race-proof: a pick-mode resolution + teardown runs // in the same tick as the click, and the standard subscribers // do not see the post-teardown state. const clickSubscribers = new Set< (cursorPx: { x: number; y: number }) => void >(); // Pick-mode state. Owned by the renderer so all callers funnel // through `setPickMode`; tests for the pure overlay math live in // `pick-mode.ts`. The overlay graphic is replicated across all nine // torus copies — identical world-coord ops drawn into each copy's // container, which already carries the wrap offset — so the // anchor / cursor line / hover outline always render in whatever // copy the user is panned over. In no-wrap mode the eight wrap // copies are `visible = false`, so their overlay children render // nothing for free; no special-case path is needed. let pickModeActive = false; let pickOptions: PickModeOptions | null = null; let pickOverlays: Graphics[] = []; const dimmedAlphaBackup = new Map(); const dimmedTintBackup = new Map(); const detachPickListeners: Array<() => void> = []; const handleViewportClicked = (e: { screen: { x: number; y: number }; }): void => { const cursorPx = { x: e.screen.x, y: e.screen.y }; if (pickModeActive) { const session = pickOptions; if (session === null) return; const hit = hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, ); const hitId = hit?.primitive.id ?? null; if (hitId === null) return; if (hitId === session.sourcePrimitiveId) return; if (!session.reachableIds.has(hitId)) return; const cb = session.onPick; teardownPickMode(); cb(hitId); return; } for (const cb of clickSubscribers) cb(cursorPx); }; viewport.on("clicked", handleViewportClicked); const redrawPickOverlay = (): void => { if (pickOverlays.length === 0 || pickOptions === null) return; const cursorWorld = lastCursorPx === null ? null : screenToWorld( lastCursorPx, handle.getCamera(), handle.getViewport(), ); // Pass the world dims only in torus mode: the function // switches to torus-shortest line geometry when world is // non-null, so the line endpoint goes the wrap-short way. const spec = computePickOverlay( pickOptions, cursorWorld, lastHoveredId, pointPrimitivesById, allPrimitiveIds, mode === "torus" ? { width: opts.world.width, height: opts.world.height } : null, viewport.scaled, currentScaleRef, ); for (const g of pickOverlays) { g.clear(); g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius); g.stroke({ color: theme.pickHighlight, alpha: PICK_OVERLAY_STYLE.anchor.alpha, width: PICK_OVERLAY_STYLE.anchor.width, }); if (spec.line !== null) { g.moveTo(spec.line.x1, spec.line.y1); g.lineTo(spec.line.x2, spec.line.y2); g.stroke({ color: theme.pickHighlight, alpha: PICK_OVERLAY_STYLE.line.alpha, width: PICK_OVERLAY_STYLE.line.width, }); } if (spec.hoverOutline !== null) { g.circle( spec.hoverOutline.x, spec.hoverOutline.y, spec.hoverOutline.radius, ); g.stroke({ color: theme.pickHighlight, alpha: PICK_OVERLAY_STYLE.hover.alpha, width: PICK_OVERLAY_STYLE.hover.width, }); } } requestRender(); }; const teardownPickMode = (): void => { if (!pickModeActive) return; pickModeActive = false; for (const detach of detachPickListeners) detach(); detachPickListeners.length = 0; for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha; dimmedAlphaBackup.clear(); for (const [g, tint] of dimmedTintBackup) g.tint = tint; dimmedTintBackup.clear(); for (const g of pickOverlays) g.destroy(); pickOverlays = []; pickOptions = null; // Un-dimming primitives and removing the overlay are scene // changes that do not move the camera. requestRender(); }; const openPickMode = (options: PickModeOptions): PickModeHandle => { // An existing session is cancelled first so the previous // `onPick(null)` is delivered before the new one starts. if (pickModeActive) { const previous = pickOptions; teardownPickMode(); previous?.onPick(null); } pickOptions = options; pickModeActive = true; // Dim every primitive that's not the source and not reachable. for (const [id, list] of primitiveGraphics) { if (id === options.sourcePrimitiveId) continue; if (options.reachableIds.has(id)) continue; for (const g of list) { dimmedAlphaBackup.set(g, g.alpha); dimmedTintBackup.set(g, g.tint as number); g.alpha = PICK_OVERLAY_STYLE.dimAlpha; g.tint = theme.pickDimTint; } } // Overlay graphics — one per torus copy. Every copy receives an // identical set of draw ops in world coords, and each copy's // container already applies its `(dx*W, dy*H)` transform, so // the anchor / cursor line / hover outline render in whatever // copy the user is currently panned over. In no-wrap mode the // non-origin copies are `visible = false`, so their overlay // children are invisible without extra wiring. pickOverlays = copies.map((c) => { const g = new Graphics(); c.addChild(g); return g; }); redrawPickOverlay(); // Pointer-move drives the cursor line; hover changes drive // the outline. Both go through the renderer's existing // callback registries. detachPickListeners.push(handle.onPointerMove(redrawPickOverlay)); detachPickListeners.push(handle.onHoverChange(redrawPickOverlay)); // Click resolution is handled by the shared // `handleViewportClicked` dispatcher above; pick mode does // not subscribe its own `clicked` listener — see the // rationale in the dispatcher's comment. const keyHandler = (event: KeyboardEvent): void => { if (event.key !== "Escape") return; if (pickOptions === null) return; event.preventDefault(); const cb = pickOptions.onPick; teardownPickMode(); cb(null); }; document.addEventListener("keydown", keyHandler); detachPickListeners.push(() => document.removeEventListener("keydown", keyHandler), ); return { cancel: (): void => { if (pickOptions === null) return; const cb = pickOptions.onPick; teardownPickMode(); cb(null); }, }; }; // Zoom-driven repaint. Both `redrawAllPrimitives` and // `redrawPickOverlay` are in scope now, so the subscription is // safe even on a synchronous zoomed event. const handleZoomed = (): void => { redrawAllPrimitives(); updateOutlineTransforms(); updateLabelTransforms(); redrawPickOverlay(); requestRender(); }; viewport.on("zoomed", handleZoomed); const handle: RendererHandle = { app, viewport, getMode: () => mode, setMode: applyMode, getCamera: () => ({ centerX: viewport.center.x, centerY: viewport.center.y, scale: viewport.scaled, }), getViewport: () => ({ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight, }), getBackend: () => rendererBackendName(app.renderer), getRenderCount: () => renderCount, hitAt: (cursorPx) => hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, hiddenIds, ), setExtraPrimitives: (prims) => { // Drop the previous extras layer. for (const id of extraPrimitiveIds) { const list = primitiveGraphics.get(id); if (list !== undefined) { for (const g of list) { g.parent?.removeChild(g); g.destroy(); } primitiveGraphics.delete(id); } pointPrimitivesById.delete(id); primitiveById.delete(id); const idx = allPrimitiveIds.indexOf(id); if (idx >= 0) allPrimitiveIds.splice(idx, 1); } extraPrimitiveIds.clear(); // Add the new extras. for (const p of prims) { populatePrimitives(p, true); } // Rebuild the snapshot World hit-test reads from. The // renderer keeps `currentWorld` mutable so the live // extras participate in click/hover tests on the same // frame they're drawn. currentWorld = new World(opts.world.width, opts.world.height, [ ...opts.world.primitives, ...prims, ]); requestRender(); }, getPrimitives: () => currentWorld.primitives, onClick: (cb) => { clickSubscribers.add(cb); return () => { clickSubscribers.delete(cb); }; }, onPointerMove: (cb) => { pointerMoveCallbacks.add(cb); return () => { pointerMoveCallbacks.delete(cb); }; }, onHoverChange: (cb) => { hoverChangeCallbacks.add(cb); // Fire the current state once so subscribers do not have to // wait for the next pointer movement to learn what's under // the cursor. cb(lastHoveredId); return () => { hoverChangeCallbacks.delete(cb); }; }, setPickMode: (options) => { if (options === null) { if (!pickModeActive) return null; const previous = pickOptions; teardownPickMode(); previous?.onPick(null); return null; } return openPickMode(options); }, isPickModeActive: () => pickModeActive, getPickState: () => ({ active: pickModeActive, sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null, reachableIds: pickOptions?.reachableIds ?? null, hoveredId: lastHoveredId, }), getPrimitiveAlpha: (id) => { const list = primitiveGraphics.get(id); if (list === undefined || list.length === 0) return 1; // All copies share the same alpha (dim is applied to every // torus tile), so the central-tile entry is representative. return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha; }, setHiddenPrimitiveIds: (ids) => { // Snapshot the input so a later mutation by the caller does // not silently un-hide primitives on the next hit-test. hiddenIds = new Set(ids); for (const [id, list] of primitiveGraphics) { applyHiddenStateTo(id, list); } requestRender(); }, isPrimitiveHidden: (id) => hiddenIds.has(id), setVisibilityFog: (circles) => { // Detach the old mask before destroying its Graphics, then // drop the previous fog rectangles. Every flip rebuilds from // scratch instead of mutating in place. fogLayer.mask = null; for (const old of fogLayer.removeChildren()) { old.destroy({ children: true }); } if (fogMask !== null) { fogMask.destroy(); fogMask = null; } // Repaint whether or not new fog is added: clearing the layer // (toggling the fog off) is itself a scene change. requestRender(); const ops = fogPaintOps( opts.world, circles, theme.fog, theme.background, mode, ); if (ops.length === 0) return; // The fog is the fog-coloured rectangle(s); the visibility // holes are cut by an INVERSE stencil mask built from the // union of the visibility circles. This replaces the earlier // opaque-circle overpaint, which on a large report painted // dozens of near-full-world opaque circles every frame — a // fill-rate cliff that froze panning under Safari's WebGPU // backend. A stencil mask rasterises the same circles far // cheaper (no blended colour writes) and stays fully vector, // so the fog edge is crisp at any zoom. The circle ops carry // `bgColor`, unused here — a mask only needs the shape. const mask = new Graphics(); let hasHole = false; for (const op of ops) { if (op.kind === "fillRect") { const g = new Graphics(); g.rect(op.x, op.y, op.width, op.height); g.fill({ color: op.color, alpha: op.alpha }); fogLayer.addChild(g); } else { // One fill per circle keeps each shape independent; // overlapping fills simply union in the stencil, which // is exactly the visibility coverage we want to cut. mask.circle(op.x, op.y, op.radius); mask.fill(0xffffff); hasHole = true; } } if (hasHole) { // The mask must be in the display list for its transform // to resolve; it shares the viewport's untransformed world // space with `fogLayer`, so the world-space op coordinates // line up. `inverse: true` shows the fog everywhere EXCEPT // inside the circle union. viewport.addChild(mask); fogLayer.setMask({ mask, inverse: true }); fogMask = mask; } else { mask.destroy(); } }, setPlanetLabels, setPlanetOutlines, resize: (w, h) => { app.renderer.resize(w, h); viewport.resize(w, h, opts.world.width, opts.world.height); const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); currentScaleRef = minScale; viewport.plugins.remove("clamp-zoom"); viewport.clampZoom({ minScale }); if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (mode === "no-wrap") { enforceCentreWhenLarger(); } // Resize changes the reference scale and may clamp the zoom; // in both cases the softened planet radius / pixel-space // strokes need to follow. redrawAllPrimitives(); updateOutlineTransforms(); updateLabelTransforms(); // The drawing buffer was resized; repaint at the new size. requestRender(); }, dispose: () => { // Detach the render-on-demand flush first so nothing tries // to paint a half-destroyed scene on the next shared tick. Ticker.shared.remove(renderFlush); // Tear down any open pick session before destroying the // app — the resolution callback might reference Svelte // stores that disappear next tick on dispose, but // `onPick(null)` here is a synchronous notification the // caller is responsible for handling. if (pickModeActive) { const previous = pickOptions; teardownPickMode(); previous?.onPick(null); } // `app.destroy({...children: true})` below recursively // destroys every container in the scene graph, fogLayer // included. The explicit teardown here drops the fog // rectangles + the inverse mask eagerly so a future caller // querying the renderer mid-dispose does not see stale fog // instances still parented under the layer. fogLayer.mask = null; for (const old of fogLayer.removeChildren()) { old.destroy({ children: true }); } if (fogMask !== null) { fogMask.destroy(); fogMask = null; } clearAllLabels(); clearAllOutlines(); viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", wrapTorusCamera); viewport.off("zoomed", handleZoomed); viewport.off("clicked", handleViewportClicked); canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerleave", handlePointerLeave); pointerMoveCallbacks.clear(); hoverChangeCallbacks.clear(); clickSubscribers.clear(); app.destroy({ removeView: false }, { children: true }); }, }; return handle; } function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" { const t = r.type as RendererType; // 1=WEBGL, 2=WEBGPU, 4=CANVAS per RendererType enum. if (t === 2) return "webgpu"; if (t === 4) return "canvas"; return "webgl"; } function drawPoint( g: Graphics, p: PointPrim, theme: Theme, cameraScale: number, scaleRef: number, ): void { const color = p.style.fillColor ?? theme.pointFill; const alpha = p.style.fillAlpha ?? 1; const radius = displayPointRadiusWorld(p.style, cameraScale, scaleRef); g.circle(p.x, p.y, radius); g.fill({ color, alpha }); if (p.style.strokeColor !== undefined && (p.style.strokeWidthPx ?? 0) > 0) { const strokeAlpha = p.style.strokeAlpha ?? 1; const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale); g.stroke({ color: p.style.strokeColor, alpha: strokeAlpha, width: strokeWidth, }); } } function drawCircle( g: Graphics, p: CirclePrim, theme: Theme, cameraScale: number, ): void { g.circle(p.x, p.y, p.radius); if (p.style.fillColor !== undefined) { g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 }); } const strokeColor = p.style.strokeColor ?? theme.circleStroke; const strokeAlpha = p.style.strokeAlpha ?? 1; const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale); g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth }); } function drawLine( g: Graphics, p: LinePrim, theme: Theme, cameraScale: number, ): void { const color = p.style.strokeColor ?? theme.lineStroke; const alpha = p.style.strokeAlpha ?? 1; const width = displayStrokeWidthWorld(p.style, cameraScale); const dash = p.style.strokeDashPx; if (dash === undefined || dash <= 0) { g.moveTo(p.x1, p.y1); g.lineTo(p.x2, p.y2); g.stroke({ color, alpha, width }); return; } // PixiJS v8 has no native dashed-line API; segment the path into // equal-length dashes (dash and gap both `dash` units). const dx = p.x2 - p.x1; const dy = p.y2 - p.y1; const length = Math.hypot(dx, dy); if (length === 0) return; const ux = dx / length; const uy = dy / length; const step = dash * 2; for (let t = 0; t < length; t += step) { const segEnd = Math.min(t + dash, length); g.moveTo(p.x1 + ux * t, p.y1 + uy * t); g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd); } g.stroke({ color, alpha, width }); }