Files
Ilia Denisov db415f8aa4 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>
2026-05-08 14:06:23 +02:00

110 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
});