// 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, type Renderer, type RendererType } from "pixi.js";
import { Viewport as PixiViewport } from "pixi-viewport";

import { hitTest, type Hit } from "./hit-test";
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,
	DEFAULT_POINT_RADIUS_PX,
	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";

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";
	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;
	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

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,
	});

	const viewport = new PixiViewport({
		screenWidth: widthPx,
		screenHeight: heightPx,
		worldWidth: opts.world.width,
		worldHeight: opts.world.height,
		events: app.renderer.events,
	});
	viewport.drag().wheel({ smooth: 5 }).pinch().decelerate();

	app.stage.addChild(viewport);

	// 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;
	});

	// 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>();
	const allPrimitiveIds: PrimitiveID[] = [];
	const extraPrimitiveIds = new Set<PrimitiveID>();
	let currentWorld: World = opts.world;
	const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
		for (const c of copies) {
			const g = buildGraphics(prim, theme);
			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);
		if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
		if (isExtra) extraPrimitiveIds.add(prim.id);
	};
	for (const p of opts.world.primitives) {
		populatePrimitives(p, false);
	}

	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,
		);
		// 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();
		}
	};

	applyMode(mode);

	// 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,
		);
		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`.
	let pickModeActive = false;
	let pickOptions: PickModeOptions | null = null;
	let pickOverlay: Graphics | null = null;
	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 (pickOverlay === null || pickOptions === null) return;
		const cursorWorld =
			lastCursorPx === null
				? null
				: screenToWorld(
						lastCursorPx,
						handle.getCamera(),
						handle.getViewport(),
					);
		const spec = computePickOverlay(
			pickOptions,
			cursorWorld,
			lastHoveredId,
			pointPrimitivesById,
			allPrimitiveIds,
		);
		const g = pickOverlay;
		g.clear();
		g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
		g.stroke({
			color: PICK_OVERLAY_STYLE.anchor.color,
			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: PICK_OVERLAY_STYLE.line.color,
				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: PICK_OVERLAY_STYLE.hover.color,
				alpha: PICK_OVERLAY_STYLE.hover.alpha,
				width: PICK_OVERLAY_STYLE.hover.width,
			});
		}
	};
	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();
		if (pickOverlay !== null) {
			pickOverlay.destroy();
			pickOverlay = null;
		}
		pickOptions = null;
	};
	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 = PICK_OVERLAY_STYLE.dimTint;
			}
		}
		// Overlay graphic. Lives in the origin copy so the central
		// tile owns it; the camera always wraps back into this tile
		// (`wrapTorusCamera`), so the user sees the overlay
		// regardless of how far they have panned.
		pickOverlay = new Graphics();
		copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
		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);
			},
		};
	};

	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),
		hitAt: (cursorPx) =>
			hitTest(
				currentWorld,
				handle.getCamera(),
				handle.getViewport(),
				cursorPx,
				mode,
			),
		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);
				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,
			]);
		},
		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;
		},
		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);
			viewport.plugins.remove("clamp-zoom");
			viewport.clampZoom({ minScale });
			if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
			if (mode === "no-wrap") {
				enforceCentreWhenLarger();
			}
		},
		dispose: () => {
			// 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);
			}
			viewport.off("moved", enforceCentreWhenLarger);
			viewport.off("moved", wrapTorusCamera);
			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 buildGraphics(p: Primitive, theme: Theme): Graphics {
	const g = new Graphics();
	if (p.kind === "point") drawPoint(g, p, theme);
	else if (p.kind === "circle") drawCircle(g, p, theme);
	else drawLine(g, p, theme);
	return g;
}

function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
	const color = p.style.fillColor ?? theme.pointFill;
	const alpha = p.style.fillAlpha ?? 1;
	const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
	g.circle(p.x, p.y, radiusPx);
	g.fill({ color, alpha });
}

function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): 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 = p.style.strokeWidthPx ?? 1;
	g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth });
}

function drawLine(g: Graphics, p: LinePrim, theme: Theme): void {
	const color = p.style.strokeColor ?? theme.lineStroke;
	const alpha = p.style.strokeAlpha ?? 1;
	const width = p.style.strokeWidthPx ?? 1;
	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 });
}
