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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user