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,100 @@
|
||||
// Fixture data for the map renderer playground and visual checks.
|
||||
//
|
||||
// sampleWorld() returns a 1000-primitive deterministic world built
|
||||
// with a small linear-congruential RNG so the layout is reproducible
|
||||
// across runs and across machines. The mix of primitive kinds
|
||||
// exercises all draw paths: many points (planets), several stroked
|
||||
// circles (orbits), several filled circles (zones), and a handful of
|
||||
// lines (routes).
|
||||
|
||||
import {
|
||||
type CirclePrim,
|
||||
type LinePrim,
|
||||
type PointPrim,
|
||||
type Primitive,
|
||||
World,
|
||||
} from "./world";
|
||||
|
||||
const WORLD_W = 4000;
|
||||
const WORLD_H = 4000;
|
||||
|
||||
// Tiny deterministic RNG so fixtures stay byte-identical regardless
|
||||
// of host platform. Seed values picked to give a visually pleasant
|
||||
// distribution; not cryptographically meaningful.
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
|
||||
// sampleWorld constructs the playground world. The result is stable
|
||||
// across calls — it allocates fresh arrays but the data is identical.
|
||||
export function sampleWorld(): World {
|
||||
const rand = lcg(0x5eed1234);
|
||||
const primitives: Primitive[] = [];
|
||||
let nextId = 0;
|
||||
|
||||
// 950 stars (points).
|
||||
for (let i = 0; i < 950; i++) {
|
||||
const star: PointPrim = {
|
||||
kind: "point",
|
||||
id: nextId++,
|
||||
x: rand() * WORLD_W,
|
||||
y: rand() * WORLD_H,
|
||||
priority: 1,
|
||||
style: { pointRadiusPx: 2 + Math.floor(rand() * 3) },
|
||||
hitSlopPx: 0,
|
||||
};
|
||||
primitives.push(star);
|
||||
}
|
||||
|
||||
// 30 stroked circles (orbits / influence rings).
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const orbit: CirclePrim = {
|
||||
kind: "circle",
|
||||
id: nextId++,
|
||||
x: rand() * WORLD_W,
|
||||
y: rand() * WORLD_H,
|
||||
radius: 80 + rand() * 220,
|
||||
priority: 2,
|
||||
style: { strokeWidthPx: 1, strokeAlpha: 0.6 },
|
||||
hitSlopPx: 0,
|
||||
};
|
||||
primitives.push(orbit);
|
||||
}
|
||||
|
||||
// 10 filled translucent circles (zones).
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const zone: CirclePrim = {
|
||||
kind: "circle",
|
||||
id: nextId++,
|
||||
x: rand() * WORLD_W,
|
||||
y: rand() * WORLD_H,
|
||||
radius: 150 + rand() * 250,
|
||||
priority: 0,
|
||||
style: { fillColor: 0x37474f, fillAlpha: 0.25 },
|
||||
hitSlopPx: 0,
|
||||
};
|
||||
primitives.push(zone);
|
||||
}
|
||||
|
||||
// 10 lines (routes between random anchor points).
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const route: LinePrim = {
|
||||
kind: "line",
|
||||
id: nextId++,
|
||||
x1: rand() * WORLD_W,
|
||||
y1: rand() * WORLD_H,
|
||||
x2: rand() * WORLD_W,
|
||||
y2: rand() * WORLD_H,
|
||||
priority: 3,
|
||||
style: { strokeWidthPx: 1, strokeAlpha: 0.8 },
|
||||
hitSlopPx: 0,
|
||||
};
|
||||
primitives.push(route);
|
||||
}
|
||||
|
||||
return new World(WORLD_W, WORLD_H, primitives);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Hit-test pass over the world primitives.
|
||||
//
|
||||
// Algorithm: convert the cursor to world coordinates, then walk every
|
||||
// primitive computing its squared distance to the cursor in world
|
||||
// units. The threshold for a hit is (visualRadius + slopWorld)²
|
||||
// where slopWorld = slopPx / camera.scale, so the on-screen click
|
||||
// margin stays constant regardless of zoom. Candidates are sorted by
|
||||
// (-priority, distSq, kindOrder, id) and the best is returned.
|
||||
//
|
||||
// In torus mode, distance is measured along the toroidal shortest
|
||||
// path on each axis. In no-wrap mode, distance is plain Euclidean
|
||||
// and a primitive does not get matched through wrap copies.
|
||||
|
||||
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
|
||||
import {
|
||||
DEFAULT_HIT_SLOP_PX,
|
||||
KIND_ORDER,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
type LinePrim,
|
||||
type PointPrim,
|
||||
type Primitive,
|
||||
type Viewport,
|
||||
type World,
|
||||
type WrapMode,
|
||||
} from "./world";
|
||||
|
||||
export interface Hit {
|
||||
primitive: Primitive;
|
||||
distSq: number; // in world units squared
|
||||
}
|
||||
|
||||
// hitTest returns the best-matching primitive under the cursor, or
|
||||
// null if no primitive matches within its hit slop.
|
||||
export function hitTest(
|
||||
world: World,
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
cursorPx: { x: number; y: number },
|
||||
mode: WrapMode,
|
||||
): Hit | null {
|
||||
const cursor = screenToWorld(cursorPx, camera, viewport);
|
||||
const candidates: Hit[] = [];
|
||||
|
||||
for (const p of world.primitives) {
|
||||
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
|
||||
const slopWorld = slopPx / camera.scale;
|
||||
let result: number | null;
|
||||
if (p.kind === "point") {
|
||||
result = matchPoint(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||
} else if (p.kind === "circle") {
|
||||
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||
} else {
|
||||
result = matchLine(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||
}
|
||||
if (result !== null) {
|
||||
candidates.push({ primitive: p, distSq: result });
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort(compareHits);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function compareHits(a: Hit, b: Hit): number {
|
||||
if (a.primitive.priority !== b.primitive.priority) {
|
||||
return b.primitive.priority - a.primitive.priority;
|
||||
}
|
||||
if (a.distSq !== b.distSq) return a.distSq - b.distSq;
|
||||
const ka = KIND_ORDER[a.primitive.kind];
|
||||
const kb = KIND_ORDER[b.primitive.kind];
|
||||
if (ka !== kb) return ka - kb;
|
||||
return a.primitive.id - b.primitive.id;
|
||||
}
|
||||
|
||||
// torusDelta returns (cursor - origin) measured along the toroidal
|
||||
// shortest path when world is non-null, otherwise plain Euclidean.
|
||||
function torusDelta(
|
||||
originX: number,
|
||||
originY: number,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
world: World | null,
|
||||
): { dx: number; dy: number } {
|
||||
if (world === null) {
|
||||
return { dx: cursorX - originX, dy: cursorY - originY };
|
||||
}
|
||||
return {
|
||||
dx: torusShortestDelta(originX, cursorX, world.width),
|
||||
dy: torusShortestDelta(originY, cursorY, world.height),
|
||||
};
|
||||
}
|
||||
|
||||
function matchPoint(
|
||||
p: PointPrim,
|
||||
cursor: { x: number; y: number },
|
||||
slopWorld: number,
|
||||
world: World | null,
|
||||
): number | null {
|
||||
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const r = slopWorld;
|
||||
if (distSq <= r * r) return distSq;
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchCircle(
|
||||
p: CirclePrim,
|
||||
cursor: { x: number; y: number },
|
||||
slopWorld: number,
|
||||
world: World | null,
|
||||
): number | null {
|
||||
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const isFilled = p.style.fillColor !== undefined && (p.style.fillAlpha ?? 1) > 0;
|
||||
if (isFilled) {
|
||||
const r = p.radius + slopWorld;
|
||||
if (distSq <= r * r) return distSq;
|
||||
return null;
|
||||
}
|
||||
// Stroke-only ring: cursor must be within slop of the ring.
|
||||
const dist = Math.sqrt(distSq);
|
||||
if (Math.abs(dist - p.radius) <= slopWorld) {
|
||||
const ringGap = dist - p.radius;
|
||||
return ringGap * ringGap;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchLine(
|
||||
p: LinePrim,
|
||||
cursor: { x: number; y: number },
|
||||
slopWorld: number,
|
||||
world: World | null,
|
||||
): number | null {
|
||||
// In torus mode the canonical line representation goes from
|
||||
// (x1,y1) to (x1 + dx, y1 + dy) where (dx,dy) is the torus-
|
||||
// shortest delta from end1 to end2. The cursor's distance is
|
||||
// then the perpendicular distance to this canonical segment,
|
||||
// using the torus-shortest cursor-to-end1 delta as the basis.
|
||||
if (world === null) {
|
||||
const distSq = distSqPointToSegment(cursor.x, cursor.y, p.x1, p.y1, p.x2, p.y2);
|
||||
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||
return null;
|
||||
}
|
||||
const segDx = torusShortestDelta(p.x1, p.x2, world.width);
|
||||
const segDy = torusShortestDelta(p.y1, p.y2, world.height);
|
||||
const cur = torusDelta(p.x1, p.y1, cursor.x, cursor.y, world);
|
||||
const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
|
||||
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Public surface of the map renderer module.
|
||||
|
||||
export {
|
||||
DEFAULT_HIT_SLOP_PX,
|
||||
KIND_ORDER,
|
||||
DARK_THEME,
|
||||
World,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
type LinePrim,
|
||||
type PointPrim,
|
||||
type Primitive,
|
||||
type PrimitiveBase,
|
||||
type PrimitiveID,
|
||||
type PrimitiveKind,
|
||||
type Style,
|
||||
type Theme,
|
||||
type Viewport,
|
||||
type WrapMode,
|
||||
} from "./world";
|
||||
|
||||
export {
|
||||
clamp,
|
||||
distSqPointToSegment,
|
||||
screenToWorld,
|
||||
torusShortestDelta,
|
||||
worldToScreen,
|
||||
} from "./math";
|
||||
|
||||
export {
|
||||
clampCameraNoWrap,
|
||||
minScaleNoWrap,
|
||||
pivotZoom,
|
||||
} from "./no-wrap";
|
||||
|
||||
export { hitTest, type Hit } from "./hit-test";
|
||||
|
||||
export {
|
||||
createRenderer,
|
||||
type RendererHandle,
|
||||
type RendererOptions,
|
||||
type RendererPreference,
|
||||
} from "./render";
|
||||
|
||||
export { sampleWorld } from "./fixtures";
|
||||
@@ -0,0 +1,91 @@
|
||||
// Geometry primitives used by the map renderer.
|
||||
//
|
||||
// All distances are in world units (TS numbers, float64). Functions
|
||||
// in this file are pure and side-effect-free; tests exercise them
|
||||
// directly.
|
||||
|
||||
import type { Camera, Viewport } from "./world";
|
||||
|
||||
// clamp returns v constrained to [lo, hi]. If lo > hi the function
|
||||
// returns lo (callers are expected to keep the bounds well-formed).
|
||||
export function clamp(v: number, lo: number, hi: number): number {
|
||||
if (v < lo) return lo;
|
||||
if (v > hi) return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
// torusShortestDelta returns the signed delta from a to b on a circle
|
||||
// of circumference `size`, picking the direction with the smaller
|
||||
// absolute distance. Result lies in (-size/2, size/2].
|
||||
//
|
||||
// At exactly size/2 the function returns +size/2 (positive direction);
|
||||
// the lower bound is exclusive so a delta of -size/2 wraps to +size/2.
|
||||
// This deterministic tie-break keeps the function self-consistent
|
||||
// regardless of input order. The `+0` at the end normalises -0 (which
|
||||
// JavaScript produces for some modulo cases) to +0.
|
||||
export function torusShortestDelta(a: number, b: number, size: number): number {
|
||||
if (!(size > 0)) {
|
||||
throw new Error(`torusShortestDelta: size must be positive, got ${size}`);
|
||||
}
|
||||
let d = (b - a) % size;
|
||||
if (d > size / 2) d -= size;
|
||||
else if (d <= -size / 2) d += size;
|
||||
return d + 0;
|
||||
}
|
||||
|
||||
// distSqPointToSegment returns the squared distance from point (px,py)
|
||||
// to the segment (ax,ay)–(bx,by). For zero-length segments it falls
|
||||
// back to point-to-point distance.
|
||||
export function distSqPointToSegment(
|
||||
px: number,
|
||||
py: number,
|
||||
ax: number,
|
||||
ay: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
): number {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) {
|
||||
const ex = px - ax;
|
||||
const ey = py - ay;
|
||||
return ex * ex + ey * ey;
|
||||
}
|
||||
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
|
||||
if (t < 0) t = 0;
|
||||
else if (t > 1) t = 1;
|
||||
const fx = ax + t * dx;
|
||||
const fy = ay + t * dy;
|
||||
const ex = px - fx;
|
||||
const ey = py - fy;
|
||||
return ex * ex + ey * ey;
|
||||
}
|
||||
|
||||
// screenToWorld converts cursor pixel coordinates (relative to the
|
||||
// viewport top-left) to world coordinates under the given camera.
|
||||
export function screenToWorld(
|
||||
cursorPx: { x: number; y: number },
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
): { x: number; y: number } {
|
||||
const offX = cursorPx.x - viewport.widthPx / 2;
|
||||
const offY = cursorPx.y - viewport.heightPx / 2;
|
||||
return {
|
||||
x: camera.centerX + offX / camera.scale,
|
||||
y: camera.centerY + offY / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
// worldToScreen converts a world-space point to viewport pixel
|
||||
// coordinates under the given camera.
|
||||
export function worldToScreen(
|
||||
world: { x: number; y: number },
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: viewport.widthPx / 2 + (world.x - camera.centerX) * camera.scale,
|
||||
y: viewport.heightPx / 2 + (world.y - camera.centerY) * camera.scale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Camera helpers for bounded-plane (no-wrap) mode.
|
||||
//
|
||||
// In no-wrap mode the world is a finite rectangle [0, W) × [0, H).
|
||||
// The camera must keep the visible viewport inside the world, except
|
||||
// when the visible viewport is larger than the world along some axis
|
||||
// — in that case the camera is centred on that axis. This is the
|
||||
// semantics asserted by the tests in tests/map-no-wrap.test.ts.
|
||||
|
||||
import { clamp } from "./math";
|
||||
import type { Camera, Viewport, World } from "./world";
|
||||
|
||||
// minScaleNoWrap returns the smallest camera.scale value at which the
|
||||
// visible viewport fits inside the world along both axes. Below this
|
||||
// scale the user would see "void" outside world bounds.
|
||||
export function minScaleNoWrap(viewport: Viewport, world: World): number {
|
||||
return Math.max(viewport.widthPx / world.width, viewport.heightPx / world.height);
|
||||
}
|
||||
|
||||
// clampCameraNoWrap returns a camera whose centre is constrained so
|
||||
// that the visible viewport stays within world bounds. When the
|
||||
// visible viewport span exceeds world span on an axis, the camera is
|
||||
// centred on that axis (independent of input centerX/centerY).
|
||||
//
|
||||
// The function does not modify camera.scale. Callers that want to
|
||||
// also enforce minScaleNoWrap should call that separately.
|
||||
export function clampCameraNoWrap(camera: Camera, viewport: Viewport, world: World): Camera {
|
||||
const halfSpanX = viewport.widthPx / (2 * camera.scale);
|
||||
const halfSpanY = viewport.heightPx / (2 * camera.scale);
|
||||
|
||||
let centerX = camera.centerX;
|
||||
if (halfSpanX * 2 >= world.width) {
|
||||
centerX = world.width / 2;
|
||||
} else {
|
||||
centerX = clamp(centerX, halfSpanX, world.width - halfSpanX);
|
||||
}
|
||||
|
||||
let centerY = camera.centerY;
|
||||
if (halfSpanY * 2 >= world.height) {
|
||||
centerY = world.height / 2;
|
||||
} else {
|
||||
centerY = clamp(centerY, halfSpanY, world.height - halfSpanY);
|
||||
}
|
||||
|
||||
return { centerX, centerY, scale: camera.scale };
|
||||
}
|
||||
|
||||
// pivotZoom keeps the world point under cursor stable while changing
|
||||
// camera.scale from oldScale to newScale. It returns a new camera
|
||||
// with the same scale=newScale and a recomputed centre.
|
||||
//
|
||||
// Invariant: screenToWorld(cursorPx, returned, viewport) ===
|
||||
// screenToWorld(cursorPx, { ...camera, scale: oldScale }, viewport)
|
||||
// (within float64 precision, see tests/map-no-wrap.test.ts).
|
||||
export function pivotZoom(
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
cursorPx: { x: number; y: number },
|
||||
newScale: number,
|
||||
): Camera {
|
||||
const oldScale = camera.scale;
|
||||
if (!(newScale > 0)) {
|
||||
throw new Error(`pivotZoom: newScale must be positive, got ${newScale}`);
|
||||
}
|
||||
const offX = cursorPx.x - viewport.widthPx / 2;
|
||||
const offY = cursorPx.y - viewport.heightPx / 2;
|
||||
const worldX = camera.centerX + offX / oldScale;
|
||||
const worldY = camera.centerY + offY / oldScale;
|
||||
return {
|
||||
centerX: worldX - offX / newScale,
|
||||
centerY: worldY - offY / newScale,
|
||||
scale: newScale,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Data model for the map renderer.
|
||||
//
|
||||
// World coordinates are TypeScript numbers (float64). The world is a
|
||||
// rectangle [0, W) × [0, H). When wrap mode is 'torus', the world
|
||||
// behaves toroidally — primitives near the right edge are visible at
|
||||
// the left edge once the camera scrolls past, etc. When wrap mode is
|
||||
// 'no-wrap', the world is a bounded plane and the camera is clamped
|
||||
// at its edges.
|
||||
//
|
||||
// The algorithm specification for hit-test, torus wrap, and no-wrap
|
||||
// camera behaviour lives in ui/docs/renderer.md. See that document
|
||||
// before changing the contract of the types in this file.
|
||||
|
||||
export type PrimitiveID = number;
|
||||
|
||||
export type WrapMode = "torus" | "no-wrap";
|
||||
|
||||
// Style describes the visual appearance of a primitive. Any field may
|
||||
// be omitted; missing fields fall back to the active theme defaults.
|
||||
export interface Style {
|
||||
fillColor?: number; // 0xRRGGBB
|
||||
fillAlpha?: number; // 0..1
|
||||
strokeColor?: number; // 0xRRGGBB
|
||||
strokeAlpha?: number; // 0..1
|
||||
strokeWidthPx?: number; // pixels at any zoom
|
||||
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
|
||||
}
|
||||
|
||||
// PrimitiveBase carries the fields shared by every primitive kind.
|
||||
//
|
||||
// priority is used for deterministic ordering during hit-test: higher
|
||||
// priority wins ties. hitSlopPx is an optional per-primitive override
|
||||
// of the kind default, in screen pixels.
|
||||
export interface PrimitiveBase {
|
||||
id: PrimitiveID;
|
||||
priority: number;
|
||||
style: Style;
|
||||
hitSlopPx: number; // 0 = use kind default
|
||||
}
|
||||
|
||||
export interface PointPrim extends PrimitiveBase {
|
||||
kind: "point";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CirclePrim extends PrimitiveBase {
|
||||
kind: "circle";
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number; // world units
|
||||
}
|
||||
|
||||
export interface LinePrim extends PrimitiveBase {
|
||||
kind: "line";
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export type Primitive = PointPrim | CirclePrim | LinePrim;
|
||||
|
||||
export type PrimitiveKind = Primitive["kind"];
|
||||
|
||||
// Default hit slop in screen pixels per primitive kind. Chosen for
|
||||
// touch ergonomics; per-primitive `hitSlopPx` overrides the default.
|
||||
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
|
||||
point: 8,
|
||||
circle: 6,
|
||||
line: 6,
|
||||
};
|
||||
|
||||
// kindOrder is the deterministic tie-break order used during hit-test
|
||||
// when two primitives match a cursor at identical priority and
|
||||
// distance. Smaller value wins.
|
||||
export const KIND_ORDER: Record<PrimitiveKind, number> = {
|
||||
point: 0,
|
||||
line: 1,
|
||||
circle: 2,
|
||||
};
|
||||
|
||||
// Camera describes the world point at the centre of the viewport and
|
||||
// the scale (pixels per world unit). Pan/zoom mutate this struct;
|
||||
// `pixi-viewport` keeps its own internal state and we mirror it here
|
||||
// for hit-test and for tests that read camera state directly.
|
||||
export interface Camera {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
// World is the immutable container of primitives plus the toroidal
|
||||
// dimensions. The renderer reindexes nothing — the brute-force
|
||||
// hit-test walks all primitives on every pointer event, which is
|
||||
// adequate for the ~1000-primitive Phase 9 budget.
|
||||
export class World {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly primitives: Primitive[];
|
||||
|
||||
constructor(width: number, height: number, primitives: Primitive[] = []) {
|
||||
if (!(width > 0) || !(height > 0)) {
|
||||
throw new Error(`World: width and height must be positive, got ${width}×${height}`);
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.primitives = primitives;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme carries the default colours used when a primitive's `style`
|
||||
// leaves a colour unset. Phase 9 ships a single dark theme; runtime
|
||||
// theme switching is deferred to Phase 35.
|
||||
export interface Theme {
|
||||
background: number;
|
||||
pointFill: number;
|
||||
circleStroke: number;
|
||||
lineStroke: number;
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
background: 0x0a0e1a,
|
||||
pointFill: 0xe8eaf6,
|
||||
circleStroke: 0x4fc3f7,
|
||||
lineStroke: 0xa5d6a7,
|
||||
};
|
||||
Reference in New Issue
Block a user