db415f8aa4
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>
110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
// 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();
|
||
});
|
||
});
|