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
+3 -1
View File
@@ -13,7 +13,9 @@
},
"dependencies": {
"flatbuffers": "^25.9.23",
"idb": "^8.0.3"
"idb": "^8.0.3",
"pixi-viewport": "^6.0.3",
"pixi.js": "^8.18.1"
},
"devDependencies": {
"@bufbuild/protobuf": "^2.12.0",
+100
View File
@@ -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);
}
+153
View File
@@ -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;
}
+45
View File
@@ -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";
+91
View File
@@ -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,
};
}
+73
View File
@@ -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,
};
}
+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 });
}
+132
View File
@@ -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,
};
@@ -0,0 +1,195 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { page } from "$app/state";
import {
createRenderer,
sampleWorld,
type RendererHandle,
type RendererPreference,
type WrapMode,
} from "../../../map/index";
interface DebugMapSurface {
ready: true;
getMode(): WrapMode;
setMode(mode: WrapMode): void;
getCamera(): { centerX: number; centerY: number; scale: number };
getViewport(): { widthPx: number; heightPx: number };
getBackend(): string;
getWorldSize(): { width: number; height: number };
hitAt(x: number, y: number): number | null;
}
type DebugMapWindow = typeof globalThis & { __galaxyMap?: DebugMapSurface };
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let mode: WrapMode = $state("torus");
let backend = $state("");
let initError: string | null = $state(null);
let handle: RendererHandle | null = null;
let onResize: (() => void) | null = null;
function readPreference(): RendererPreference | RendererPreference[] {
const v = page.url.searchParams.get("renderer");
if (v === "webgl") return "webgl";
if (v === "webgpu") return "webgpu";
return ["webgpu", "webgl"];
}
function describe(err: unknown): string {
if (err instanceof Error) return `${err.name}: ${err.message}`;
return String(err);
}
onMount(() => {
(async () => {
if (canvasEl === null || containerEl === null) return;
const world = sampleWorld();
try {
handle = await createRenderer({
canvas: canvasEl,
world,
mode,
preference: readPreference(),
});
} catch (err) {
initError = describe(err);
return;
}
backend = handle.getBackend();
// Initial camera: place world centre.
handle.viewport.moveCenter(world.width / 2, world.height / 2);
// Initial zoom: fit-ish (slight zoom-in from minScale).
const minScale = Math.max(
containerEl.clientWidth / world.width,
containerEl.clientHeight / world.height,
);
handle.viewport.setZoom(minScale * 1.2, true);
if (mode === "no-wrap") handle.setMode("no-wrap"); // re-clamp post zoom
const surface: DebugMapSurface = {
ready: true,
getMode: () => handle?.getMode() ?? "torus",
setMode: (m) => {
if (handle === null) return;
handle.setMode(m);
mode = m;
},
getCamera: () => handle?.getCamera() ?? { centerX: 0, centerY: 0, scale: 1 },
getViewport: () =>
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
getBackend: () => handle?.getBackend() ?? "",
getWorldSize: () => ({ width: world.width, height: world.height }),
hitAt: (x, y) => {
if (handle === null) return null;
const hit = handle.hitAt({ x, y });
return hit?.primitive.id ?? null;
},
};
(window as DebugMapWindow).__galaxyMap = surface;
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
})();
});
onDestroy(() => {
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
}
const w = window as DebugMapWindow;
if (w.__galaxyMap !== undefined) delete w.__galaxyMap;
});
function toggleMode(): void {
if (handle === null) return;
const next: WrapMode = mode === "torus" ? "no-wrap" : "torus";
handle.setMode(next);
mode = next;
}
</script>
<main>
<header>
<h1>map debug</h1>
<div class="controls">
<button type="button" data-testid="mode-toggle" onclick={toggleMode}>
mode: {mode}
</button>
<span data-testid="backend" data-backend={backend}>backend: {backend || "…"}</span>
</div>
</header>
{#if initError !== null}
<p class="error" data-testid="init-error">{initError}</p>
{/if}
<div class="canvas-wrap" bind:this={containerEl}>
<canvas bind:this={canvasEl}></canvas>
</div>
</main>
<style>
main {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
font-family: system-ui, sans-serif;
color: #e8eaf6;
background: #0a0e1a;
}
header {
padding: 0.5rem 1rem;
display: flex;
gap: 1rem;
align-items: center;
border-bottom: 1px solid #20253a;
}
h1 {
margin: 0;
font-size: 1rem;
font-weight: 500;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
}
button {
padding: 0.25rem 0.75rem;
background: #1c2238;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:hover {
background: #232b48;
}
.canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.error {
padding: 0.5rem 1rem;
background: #4a1820;
color: #ffb4b4;
}
</style>
@@ -0,0 +1,155 @@
// Phase 9 end-to-end checks for the map renderer playground.
//
// Each Playwright project exercises a different rendering backend:
// chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile
// projects pick their default. The window.__galaxyMap surface
// (defined in src/routes/__debug/map/+page.svelte) lets the spec
// read the camera and viewport state without poking Pixi internals.
import { expect, test, type Page } from "@playwright/test";
interface DebugMapSurface {
ready: true;
getMode(): "torus" | "no-wrap";
setMode(mode: "torus" | "no-wrap"): void;
getCamera(): { centerX: number; centerY: number; scale: number };
getViewport(): { widthPx: number; heightPx: number };
getBackend(): string;
getWorldSize(): { width: number; height: number };
hitAt(x: number, y: number): number | null;
}
declare global {
interface Window {
__galaxyMap?: DebugMapSurface;
}
}
function preferenceFor(projectName: string): "webgpu" | "webgl" | null {
if (projectName === "chromium-desktop") return "webgpu";
if (projectName === "webkit-desktop") return "webgl";
return null;
}
async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise<void> {
const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map";
await page.goto(url);
await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, {
timeout: 15_000,
});
await expect(page.getByTestId("backend")).toBeVisible();
}
test("map mounts in the requested backend and reports it via data-backend", async ({
page,
}, testInfo) => {
const pref = preferenceFor(testInfo.project.name);
await bootMap(page, pref);
const backend = await page.getByTestId("backend").getAttribute("data-backend");
expect(backend).not.toBeNull();
if (pref === null) {
// Mobile projects auto-pick; just assert a real backend was chosen.
expect(["webgl", "webgpu", "canvas"]).toContain(backend);
} else {
// The renderer should honour the requested preference unless the
// runner lacks a working WebGPU adapter, in which case Pixi
// falls back to WebGL. Both are acceptable.
expect(["webgl", "webgpu"]).toContain(backend);
}
});
test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
const before = await page.evaluate(() => window.__galaxyMap!.getCamera());
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await page.waitForTimeout(40);
}
await page.waitForTimeout(100);
const after = await page.evaluate(() => window.__galaxyMap!.getCamera());
expect(after.scale).toBeGreaterThan(before.scale);
});
test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
await page.getByTestId("mode-toggle").click();
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap");
await page.getByTestId("mode-toggle").click();
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
});
test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
page,
}, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap"));
await page.waitForTimeout(50);
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
// Drag right-to-left across most of the canvas so the camera
// would, without clamp, push past the right edge of the world.
const startX = box.x + box.width * 0.85;
const endX = box.x + box.width * 0.15;
const y = box.y + box.height / 2;
await page.mouse.move(startX, y);
await page.mouse.down();
for (let step = 1; step <= 20; step++) {
const x = startX + ((endX - startX) * step) / 20;
await page.mouse.move(x, y);
}
await page.mouse.up();
await page.waitForTimeout(200);
const { cam, vp, world } = await page.evaluate(() => ({
cam: window.__galaxyMap!.getCamera(),
vp: window.__galaxyMap!.getViewport(),
world: window.__galaxyMap!.getWorldSize(),
}));
const halfSpanX = vp.widthPx / (2 * cam.scale);
const tol = 1; // tolerance in world units; clamp is applied in pixels
expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol);
expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol);
});
test("hitAt returns a primitive id when the cursor is over the world centre", async ({
page,
}, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
const cx = Math.round(box.width / 2);
const cy = Math.round(box.height / 2);
// The fixture world is dense (~950 stars in 4000×4000). Anywhere
// within the canvas should land near at least one primitive.
// We sweep a small grid around the centre to find any hit; the
// goal is to confirm the hit-test plumbing works against the
// live renderer, not to assert a specific id.
const found = await page.evaluate(
({ cx, cy }) => {
const m = window.__galaxyMap!;
for (let dy = -40; dy <= 40; dy += 8) {
for (let dx = -40; dx <= 40; dx += 8) {
const id = m.hitAt(cx + dx, cy + dy);
if (id !== null) return id;
}
}
return null;
},
{ cx, cy },
);
expect(found).not.toBeNull();
});
+263
View File
@@ -0,0 +1,263 @@
// Hand-built cases for the hit-test pass in src/map/hit-test.ts.
//
// Each describe block exercises one rule from the algorithm spec in
// ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
import {
type Camera,
type Primitive,
type Viewport,
World,
type WrapMode,
} from "../src/map/world";
const VP: Viewport = { widthPx: 200, heightPx: 200 };
// Centre the camera over the world centre at scale=1 so screen px
// equals world units inside the visible region.
function camAt(centerX: number, centerY: number, scale = 1): Camera {
return { centerX, centerY, scale };
}
// Cursor at world point (wx, wy) under the given camera.
function cursorOver(wx: number, wy: number, cam: Camera, vp: Viewport = VP) {
return {
x: vp.widthPx / 2 + (wx - cam.centerX) * cam.scale,
y: vp.heightPx / 2 + (wy - cam.centerY) * cam.scale,
};
}
function point(
id: number,
x: number,
y: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "point",
id,
x,
y,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function circle(
id: number,
x: number,
y: number,
radius: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "circle",
id,
x,
y,
radius,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function line(
id: number,
x1: number,
y1: number,
x2: number,
y2: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "line",
id,
x1,
y1,
x2,
y2,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function ids(world: World, mode: WrapMode, cam: Camera, cursorPx: { x: number; y: number }) {
const h = hitTest(world, cam, VP, cursorPx, mode);
return h?.primitive.id ?? null;
}
describe("hitTest — point primitive", () => {
const cam = camAt(500, 500);
const w = new World(1000, 1000, [point(1, 500, 500)]);
test("direct hit at centre", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("hit within default slop (8px)", () => {
// 7 world units away at scale=1 → within 8px slop.
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
});
test("miss just outside default slop", () => {
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
});
test("custom hitSlopPx widens the hit area", () => {
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1);
});
});
describe("hitTest — torus wrap", () => {
test("point near the right edge is hit by cursor near the left edge", () => {
// World 100×100, point at x=98. Camera at left edge (x=2).
// Cursor at x=4 is 6 units from x=98 via the wrap; default
// point slop is 8px → hit.
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
});
test("no-wrap mode does not match through the torus seam", () => {
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "no-wrap", cam, cursorOver(4, 50, cam))).toBe(null);
});
test("line spanning the torus seam is hit at the wrapped midpoint", () => {
// World 100×100, line from (95, 50) to (5, 50).
// Torus-shortest is the wrap segment of length 10.
// Cursor at x=0,y=50 is on the wrapped segment.
const cam = camAt(0, 50);
const w = new World(100, 100, [line(1, 95, 50, 5, 50)]);
expect(ids(w, "torus", cam, cursorOver(0, 50, cam))).toBe(1);
});
});
describe("hitTest — circle primitive", () => {
const cam = camAt(500, 500);
test("filled circle: cursor inside disc hits", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 50, { style: { fillColor: 0xffffff, fillAlpha: 1 } }),
]);
expect(ids(w, "torus", cam, cursorOver(530, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor inside disc but far from ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(510, 500, cam))).toBe(null);
});
test("stroked-only circle: cursor on ring within slop hits", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
// Cursor at (548, 500): distance to centre is 48; ring at 50;
// gap is 2 < default slop 6 → hit.
expect(ids(w, "torus", cam, cursorOver(548, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor far outside the ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(580, 500, cam))).toBe(null);
});
});
describe("hitTest — line primitive", () => {
const cam = camAt(500, 500);
test("cursor on the segment hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("cursor near the segment within slop hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units away at scale=1 → within default slop 6.
expect(ids(w, "torus", cam, cursorOver(500, 504, cam))).toBe(1);
});
test("cursor near the segment outside slop misses", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 510, cam))).toBe(null);
});
test("cursor beyond endpoint clamps and slop applies", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units beyond x=520 along x; default slop 6.
expect(ids(w, "torus", cam, cursorOver(524, 500, cam))).toBe(1);
// 8 world units beyond x=520 → outside slop.
expect(ids(w, "torus", cam, cursorOver(528, 500, cam))).toBe(null);
});
});
describe("hitTest — ordering", () => {
const cam = camAt(500, 500);
test("higher priority wins over lower priority at equal distance", () => {
const w = new World(1000, 1000, [
point(1, 500, 500, { priority: 0 }),
point(2, 500, 500, { priority: 5 }),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("smaller distance wins at equal priority", () => {
const w = new World(1000, 1000, [point(1, 504, 500), point(2, 502, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("kind tie-break: point beats circle at exact distance and priority", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 0.0001, { style: { fillColor: 0xffffff } }),
point(2, 500, 500),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("id tie-break: smaller id wins at exact tie", () => {
const w = new World(1000, 1000, [point(7, 500, 500), point(3, 500, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(3);
});
});
describe("hitTest — empty results and scale", () => {
const cam = camAt(500, 500);
test("returns null when nothing matches", () => {
const w = new World(1000, 1000, [point(1, 100, 100)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the on-screen slop in world units", () => {
// At scale=4, 8px on screen = 2 world units.
// A point 3 world units away misses.
const w = new World(1000, 1000, [point(1, 503, 500)]);
expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe(
null,
);
// A point 1.5 world units away hits at scale=4 (≤ 2).
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]);
expect(
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
).toBe(1);
});
test("lower zoom widens the on-screen slop in world units", () => {
// At scale=0.5, 8px on screen = 16 world units.
const w = new World(1000, 1000, [point(1, 514, 500)]);
expect(
ids(
w,
"torus",
camAt(500, 500, 0.5),
cursorOver(500, 500, camAt(500, 500, 0.5)),
),
).toBe(1);
});
});
+106
View File
@@ -0,0 +1,106 @@
// Unit tests for the geometry primitives in src/map/math.ts.
//
// These functions are the foundation for hit-test and the no-wrap
// camera helpers; they run far more often than their callers and any
// regression here ripples everywhere. Each test asserts a single
// algebraic property; the cases together cover the contract of the
// functions described in ui/docs/renderer.md.
import { describe, expect, test } from "vitest";
import {
clamp,
distSqPointToSegment,
screenToWorld,
torusShortestDelta,
worldToScreen,
} from "../src/map/math";
describe("clamp", () => {
test("returns the value when inside the bounds", () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(0, 0, 10)).toBe(0);
expect(clamp(10, 0, 10)).toBe(10);
});
test("clamps to the lower bound", () => {
expect(clamp(-3, 0, 10)).toBe(0);
});
test("clamps to the upper bound", () => {
expect(clamp(13, 0, 10)).toBe(10);
});
});
describe("torusShortestDelta", () => {
test("returns zero for equal inputs", () => {
expect(torusShortestDelta(50, 50, 100)).toBe(0);
});
test("returns the direct delta when no wrap is shorter", () => {
expect(torusShortestDelta(10, 30, 100)).toBe(20);
expect(torusShortestDelta(30, 10, 100)).toBe(-20);
});
test("wraps to the shorter direction near the seam", () => {
// from=10, to=90: direct=+80, wrap=-20 — wrap wins.
expect(torusShortestDelta(10, 90, 100)).toBe(-20);
// from=90, to=10: direct=-80, wrap=+20 — wrap wins.
expect(torusShortestDelta(90, 10, 100)).toBe(20);
});
test("normalises inputs outside [0, size)", () => {
expect(torusShortestDelta(-10, 10, 100)).toBe(20);
expect(torusShortestDelta(110, 10, 100)).toBe(-100 + 100); // wraps to 0
});
test("at exactly size/2 picks the positive direction deterministically", () => {
// from=0, to=50, size=100 — both directions are equal.
// The contract: returns +size/2.
expect(torusShortestDelta(0, 50, 100)).toBe(50);
});
test("rejects non-positive size", () => {
expect(() => torusShortestDelta(0, 0, 0)).toThrow();
expect(() => torusShortestDelta(0, 0, -1)).toThrow();
});
});
describe("distSqPointToSegment", () => {
test("zero distance when the point is on the segment", () => {
expect(distSqPointToSegment(5, 0, 0, 0, 10, 0)).toBe(0);
expect(distSqPointToSegment(0, 0, 0, 0, 10, 0)).toBe(0);
expect(distSqPointToSegment(10, 0, 0, 0, 10, 0)).toBe(0);
});
test("perpendicular foot inside the segment", () => {
// segment along the x-axis from (0,0) to (10,0); point at (5,3).
// foot is (5,0), distance is 3, distSq is 9.
expect(distSqPointToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(9, 12);
});
test("foot beyond the start endpoint clamps to start", () => {
expect(distSqPointToSegment(-2, 0, 0, 0, 10, 0)).toBeCloseTo(4, 12);
});
test("foot beyond the end endpoint clamps to end", () => {
expect(distSqPointToSegment(15, 0, 0, 0, 10, 0)).toBeCloseTo(25, 12);
});
test("zero-length segment falls back to point distance", () => {
expect(distSqPointToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(25, 12);
});
});
describe("screenToWorld and worldToScreen", () => {
const viewport = { widthPx: 800, heightPx: 600 };
const camera = { centerX: 1000, centerY: 500, scale: 2 };
test("centre of viewport maps to camera centre in world space", () => {
const w = screenToWorld({ x: 400, y: 300 }, camera, viewport);
expect(w.x).toBeCloseTo(1000, 12);
expect(w.y).toBeCloseTo(500, 12);
});
test("worldToScreen is the inverse of screenToWorld", () => {
const screenIn = { x: 123.5, y: 456.25 };
const world = screenToWorld(screenIn, camera, viewport);
const screenOut = worldToScreen(world, camera, viewport);
expect(screenOut.x).toBeCloseTo(screenIn.x, 9);
expect(screenOut.y).toBeCloseTo(screenIn.y, 9);
});
test("scale propagates: 2px on screen = 1 world unit at scale=2", () => {
const w0 = screenToWorld({ x: 400, y: 300 }, camera, viewport);
const w1 = screenToWorld({ x: 402, y: 300 }, camera, viewport);
expect(w1.x - w0.x).toBeCloseTo(1, 12);
});
});
+109
View File
@@ -0,0 +1,109 @@
// Unit tests for the no-wrap camera helpers in src/map/no-wrap.ts.
//
// The bounded-plane mode has three invariants that the helpers must
// uphold together:
//
// 1. The visible viewport stays inside the world rectangle, except
// when the visible viewport span exceeds the world span on an
// axis — in that case the camera centres on that axis.
// 2. minScaleNoWrap is the smallest scale at which the visible
// viewport fits the world along both axes.
// 3. pivotZoom keeps the world point under the cursor stable
// across a scale change.
//
// Each invariant is tested in isolation here; the renderer composes
// them in render.ts.
import { describe, expect, test } from "vitest";
import { screenToWorld } from "../src/map/math";
import { clampCameraNoWrap, minScaleNoWrap, pivotZoom } from "../src/map/no-wrap";
import { World } from "../src/map/world";
const world = new World(1000, 800);
const viewport = { widthPx: 400, heightPx: 300 };
describe("clampCameraNoWrap", () => {
test("leaves the camera unchanged when the viewport sits inside the world", () => {
const c = clampCameraNoWrap({ centerX: 500, centerY: 400, scale: 1 }, viewport, world);
expect(c.centerX).toBe(500);
expect(c.centerY).toBe(400);
});
test("clamps the camera to the left edge", () => {
const c = clampCameraNoWrap({ centerX: 0, centerY: 400, scale: 1 }, viewport, world);
expect(c.centerX).toBe(viewport.widthPx / 2);
});
test("clamps the camera to the right edge", () => {
const c = clampCameraNoWrap({ centerX: 9999, centerY: 400, scale: 1 }, viewport, world);
expect(c.centerX).toBe(world.width - viewport.widthPx / 2);
});
test("clamps the camera to the top edge", () => {
const c = clampCameraNoWrap({ centerX: 500, centerY: -50, scale: 1 }, viewport, world);
expect(c.centerY).toBe(viewport.heightPx / 2);
});
test("clamps the camera to the bottom edge", () => {
const c = clampCameraNoWrap({ centerX: 500, centerY: 9999, scale: 1 }, viewport, world);
expect(c.centerY).toBe(world.height - viewport.heightPx / 2);
});
test("centres the camera on an axis when the viewport span exceeds world span", () => {
// At scale=0.1, viewport.widthPx/scale = 4000 world units > world.width=1000.
const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.1 }, viewport, world);
expect(c.centerX).toBe(world.width / 2);
expect(c.centerY).toBe(world.height / 2);
});
test("does not modify scale", () => {
const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.5 }, viewport, world);
expect(c.scale).toBe(0.5);
});
});
describe("minScaleNoWrap", () => {
test("equals the larger axis ratio (width-bound)", () => {
// world 1000×800, viewport 400×300:
// width ratio = 0.4, height ratio = 0.375 — width wins.
expect(minScaleNoWrap(viewport, world)).toBeCloseTo(0.4, 12);
});
test("equals the larger axis ratio (height-bound)", () => {
// world 100×100, viewport 200×400: height ratio = 4 wins over width = 2.
expect(minScaleNoWrap({ widthPx: 200, heightPx: 400 }, new World(100, 100))).toBeCloseTo(
4,
12,
);
});
});
describe("pivotZoom", () => {
const camera = { centerX: 500, centerY: 400, scale: 1 };
test("keeps the world point under the cursor stable", () => {
const cursor = { x: 100, y: 250 };
const before = screenToWorld(cursor, camera, viewport);
const newCam = pivotZoom(camera, viewport, cursor, 2.5);
const after = screenToWorld(cursor, newCam, viewport);
expect(after.x).toBeCloseTo(before.x, 9);
expect(after.y).toBeCloseTo(before.y, 9);
expect(newCam.scale).toBe(2.5);
});
test("invariant holds when the cursor sits at the viewport centre", () => {
const cursor = { x: viewport.widthPx / 2, y: viewport.heightPx / 2 };
const before = screenToWorld(cursor, camera, viewport);
const newCam = pivotZoom(camera, viewport, cursor, 0.4);
const after = screenToWorld(cursor, newCam, viewport);
expect(after.x).toBeCloseTo(before.x, 9);
expect(after.y).toBeCloseTo(before.y, 9);
});
test("invariant holds at the viewport corner", () => {
const cursor = { x: 0, y: 0 };
const before = screenToWorld(cursor, camera, viewport);
const newCam = pivotZoom(camera, viewport, cursor, 7.7);
const after = screenToWorld(cursor, newCam, viewport);
expect(after.x).toBeCloseTo(before.x, 9);
expect(after.y).toBeCloseTo(before.y, 9);
});
test("rejects non-positive scale", () => {
expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, 0)).toThrow();
expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, -1)).toThrow();
});
});