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
+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,
};
}