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