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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user