// 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,
	Rectangle,
	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,
	displayLineEndpoints,
	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;
	/**
	 * refreshCameraDerivedDraws repaints every primitive / outline /
	 * label whose geometry depends on the current camera scale. The
	 * map view calls this right after a manual `viewport.moveCenter` /
	 * `viewport.setZoom` pair (e.g. on a theme-flip remount), where
	 * `pixi-viewport`'s `'zoomed'` listener can race the next Ticker
	 * tick and leave the scene drawn at the boot scale until the
	 * player nudges the wheel.
	 */
	refreshCameraDerivedDraws(): 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). One layer per torus
	// copy so the textual legend / selection frame / bombing outline
	// stay glued to whichever wrap tile the player is panned over.
	// Performance is reasonable because `setPlanetLabels` /
	// `setPlanetOutlines` skip the rebuild on no-data-change via the
	// fingerprint guard, and the per-zoom transform update is just an
	// `x/y/scale` assignment per entry.
	const outlineLayers: Container[] = copies.map((c) => {
		const layer = new Container();
		c.addChild(layer);
		return layer;
	});
	const labelLayers: Container[] = copies.map((c) => {
		const layer = new Container();
		c.addChild(layer);
		return layer;
	});

	// 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, currentScaleRef);
		}
	};
	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 per torus copy, anchored at the planet's `(x, y)` in
	// each tile. `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",
				// F8-12 / #5 fix: Pixi's Text rasteriser sometimes clips
				// the last glyph by a subpixel when the texture bounds
				// are computed from the un-padded measurement. A few
				// pixels of `padding` give the rasteriser enough room
				// to render the right edge cleanly without changing
				// the text's anchor or position.
				padding: 2,
			},
		});
		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 list of planetLabelInstances.values()) {
			for (const entry of list) 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;
		// Refresh the click hit area to match the label's bounding box
		// plus the padding from the selection frame (so the player can
		// click anywhere inside the visible legend, not just the glyphs).
		const widestText = Math.max(
			entry.nameText?.width ?? 0,
			entry.numberText.width,
		);
		const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
		const hitHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2;
		entry.container.hitArea = new Rectangle(
			-hitWidth / 2,
			-LABEL_FRAME_PADDING_PX,
			hitWidth,
			hitHeight,
		);
		entry.frame.clear();
		if (!isSelected) {
			entry.frame.visible = false;
			return;
		}
		const frameWidth = hitWidth;
		const frameHeight = hitHeight;
		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 list = planetLabelInstances.get(data.planetNumber);
			if (list === undefined) continue;
			const planetPrim = pointPrimitivesById.get(data.planetNumber);
			const visibleRadius =
				planetPrim === undefined
					? 0
					: displayPointRadiusWorld(
							planetPrim.style,
							cameraScale,
							currentScaleRef,
						);
			const anchorY = data.y + visibleRadius + gapWorld;
			for (const entry of list) {
				entry.container.x = data.x;
				entry.container.y = anchorY;
				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()) {
			for (const g of entry.graphics) {
				g.parent?.removeChild(g);
				g.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);
		if (planetPrim === undefined) {
			for (const g of entry.graphics) g.clear();
			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;
		for (const g of entry.graphics) {
			g.clear();
			g.circle(planetPrim.x, planetPrim.y, outlineRadius);
			g.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 list: Graphics[] = [];
			for (const layer of outlineLayers) {
				const g = new Graphics();
				layer.addChild(g);
				list.push(g);
			}
			const entry: PlanetOutlineGfx = { graphics: list, 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 list = planetLabelInstances.get(data.planetNumber);
				if (list === undefined) continue;
				for (const entry of list) {
					paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
				}
			}
			currentLabelsSelectedId = selectedPlanetId;
			updateLabelTransforms();
			requestRender();
			return;
		}
		clearAllLabels();
		currentLabels = labels.slice();
		currentLabelsFingerprint = fp;
		currentLabelsSelectedId = selectedPlanetId;
		for (const data of labels) {
			const targetPlanet = data.planetNumber;
			const list: LabelGfx[] = [];
			for (const layer of labelLayers) {
				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);
				layer.addChild(container);
				const entry: LabelGfx = {
					container,
					frame,
					nameText,
					numberText,
				};
				paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
				// F8-12 / #3 follow-up: a click on the label routes
				// through the same hit-test path as a click on the
				// disc so selection and pick-mode both fire the right
				// callback.
				container.eventMode = "static";
				container.cursor = "pointer";
				container.on("pointertap", () =>
					simulatePlanetClick(targetPlanet),
				);
				list.push(entry);
			}
			planetLabelInstances.set(targetPlanet, list);
		}
		updateLabelTransforms();
		requestRender();
	};

	const simulatePlanetClick = (planetNumber: number): void => {
		const prim = pointPrimitivesById.get(planetNumber);
		if (prim === undefined) return;
		const cameraScale = viewport.scaled;
		if (cameraScale <= 0) return;
		const screen = {
			x:
				viewport.screenWidth / 2 +
				(prim.x - viewport.center.x) * cameraScale,
			y:
				viewport.screenHeight / 2 +
				(prim.y - viewport.center.y) * cameraScale,
		};
		handleViewportClicked({ screen });
	};

	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,
		);
		const cameraScale = viewport.scaled > 0 ? viewport.scaled : 1;
		const lineWidthWorld = PICK_OVERLAY_STYLE.line.widthPx / cameraScale;
		const hoverWidthWorld = PICK_OVERLAY_STYLE.hover.widthPx / cameraScale;
		for (const g of pickOverlays) {
			g.clear();
			// F8-12 / #5: anchor ring around the source is retired —
			// the source planet's own outline already signals selection.
			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: lineWidthWorld,
				});
			}
			if (spec.hoverOutline !== null) {
				// F8-12 / #5: hover ring matches the regular planet
				// outline geometry (visible disc + ~1 px padding) but
				// paints in `pickHighlight` so the player sees the
				// destination candidate just like a selection — only
				// in the warm picker accent.
				g.circle(
					spec.hoverOutline.x,
					spec.hoverOutline.y,
					spec.hoverOutline.radius,
				);
				g.stroke({
					color: theme.pickHighlight,
					alpha: PICK_OVERLAY_STYLE.hover.alpha,
					width: hoverWidthWorld,
				});
			}
		}
		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. A rapid wheel / pinch
	// burst fires many `zoomed` events per frame; we coalesce them
	// onto the next Ticker tick so the expensive `clear() + redraw`
	// pass runs at most once per painted frame even on a 500-planet
	// map.
	let zoomedRedrawPending = false;
	const runCameraDerivedRedraws = (): void => {
		redrawAllPrimitives();
		updateOutlineTransforms();
		updateLabelTransforms();
		redrawPickOverlay();
		requestRender();
	};
	const handleZoomed = (): void => {
		if (zoomedRedrawPending) return;
		zoomedRedrawPending = true;
		Ticker.shared.addOnce(() => {
			zoomedRedrawPending = false;
			runCameraDerivedRedraws();
		});
	};
	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,
		refreshCameraDerivedDraws: runCameraDerivedRedraws,
		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,
	scaleRef: number,
): void {
	const color = p.style.strokeColor ?? theme.lineStroke;
	const alpha = p.style.strokeAlpha ?? 1;
	const width = displayStrokeWidthWorld(p.style, cameraScale);
	const ends = displayLineEndpoints(
		p.style,
		p.x1,
		p.y1,
		p.x2,
		p.y2,
		cameraScale,
		scaleRef,
	);
	const dash = p.style.strokeDashPx;
	if (dash === undefined || dash <= 0) {
		g.moveTo(ends.x1, ends.y1);
		g.lineTo(ends.x2, ends.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 = ends.x2 - ends.x1;
	const dy = ends.y2 - ends.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(ends.x1 + ux * t, ends.y1 + uy * t);
		g.lineTo(ends.x1 + ux * segEnd, ends.y1 + uy * segEnd);
	}
	g.stroke({ color, alpha, width });
}
