6996a79286
* 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>
1496 lines
52 KiB
TypeScript
1496 lines
52 KiB
TypeScript
// 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 });
|
||
}
|