ui/phase-9: PixiJS map renderer with torus and no-wrap modes

Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 14:06:23 +02:00
parent 9d2504c42d
commit db415f8aa4
17 changed files with 2064 additions and 41 deletions
+242
View File
@@ -0,0 +1,242 @@
// 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. 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.
//
// 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 { minScaleNoWrap } from "./no-wrap";
import {
DARK_THEME,
type Camera,
type CirclePrim,
type LinePrim,
type PointPrim,
type Primitive,
type Theme,
type Viewport,
type World,
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;
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;
});
for (const c of copies) {
for (const p of opts.world.primitives) {
c.addChild(buildGraphics(p, theme));
}
}
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,
);
};
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 for no-wrap.
viewport.plugins.remove("clamp");
viewport.plugins.remove("clamp-zoom");
viewport.off("moved", enforceCentreWhenLarger);
if (newMode === "no-wrap") {
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world,
);
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
viewport.clamp({ direction: "all" });
viewport.on("moved", enforceCentreWhenLarger);
enforceCentreWhenLarger();
} else {
// Torus mode: drop tight bounds, allow free pan.
viewport.moveCenter(viewport.center.x, viewport.center.y);
}
};
applyMode(mode);
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(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
if (mode === "no-wrap") {
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);
enforceCentreWhenLarger();
}
},
dispose: () => {
viewport.off("moved", enforceCentreWhenLarger);
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 ?? 3;
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 {
g.moveTo(p.x1, p.y1);
g.lineTo(p.x2, p.y2);
const color = p.style.strokeColor ?? theme.lineStroke;
const alpha = p.style.strokeAlpha ?? 1;
const width = p.style.strokeWidthPx ?? 1;
g.stroke({ color, alpha, width });
}