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>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// 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,
|
||
};
|
||
}
|