Files
galaxy-game/ui/frontend/src/map/render.ts
T
Ilia Denisov 6996a79286
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m20s
perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
* Planet size formula moves to pixel-space:
  `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
  on-screen disc now reads ~4-7 px at the reference zoom regardless
  of how large the world rectangle is — the previous `world-units`
  formulation blew up on small maps and made Source-class planets
  swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
  across torus copies was the dominant cost on a 100+ planet map
  (Pixi.Text creation + Graphics rebuilds on every zoom step); the
  origin-copy layout is what the camera-wrap listener guarantees
  the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
  rebuilds when the input fingerprint is unchanged — toggle flips
  and selection changes now keep the existing Text / Graphics
  instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:39:19 +02:00

1496 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<PrimitiveID> | 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<PrimitiveID>): 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<PlanetLabelData>,
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<PlanetOutlineSpec>): void;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
const TORUS_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[-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<PrimitiveID> = 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<readonly [number, number]> =
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<readonly [number, number]> = [[0, 0]];
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
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<PrimitiveID, Graphics[]>();
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
// 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<PrimitiveID, Primitive>();
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
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<PrimitiveID> = 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<number, LabelGfx>();
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
let currentLabelsFingerprint: string | null = null;
let currentLabelsSelectedId: number | null = null;
const fingerprintPlanetLabels = (
labels: ReadonlyArray<PlanetLabelData>,
): 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<number, PlanetOutlineGfx>();
let currentOutlinesFingerprint: string | null = null;
const fingerprintPlanetOutlines = (
outlines: ReadonlyArray<PlanetOutlineSpec>,
): 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<PlanetOutlineSpec>,
): 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<PlanetLabelData>,
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<Graphics, number>();
const dimmedTintBackup = new Map<Graphics, number>();
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 });
}