ui/map-renderer: clamp torus zoom-out to minScaleNoWrap

The renderer's torus mode laid out the world in a 3×3 grid of wrap
copies (TORUS_OFFSETS) so the user could pan past an edge without
seeing a void. Below `minScale = max(viewport/world)` the world
shrinks below the viewport along at least one axis and the wrap
copies become visible side-by-side — the user reported a 9-tile
mosaic that pans and zooms as one rigid unit. The doc explicitly
deferred the fix ("if profiling ever reveals that users do this");
real usage is the trigger.

Apply `clampZoom({ minScale })` in both modes; torus still keeps
free pan (no `clamp({ direction: "all" })`) so the wrap copies
fill the cross-edge slack as designed. Resize re-evaluates the
clamp so a window resize does not strand the camera below the new
floor. Documentation in `ui/docs/renderer.md` updated to describe
the new shared invariant.

Regression test in `tests/e2e/playground-map.spec.ts` wheels out
aggressively in torus mode and asserts `camera.scale >= minScale`
across all four Playwright projects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 21:45:01 +02:00
parent 12e666ba91
commit cc004f935d
3 changed files with 70 additions and 31 deletions
@@ -86,6 +86,40 @@ test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
});
test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({
page,
}, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
// Wheel out aggressively. Without the torus-mode clampZoom the
// camera scale would drop below `minScale = max(viewport/world)`,
// exposing the 3×3 grid of wrap copies. With the fix in place the
// scale floor is never crossed.
for (let i = 0; i < 15; i++) {
await page.mouse.wheel(0, 600);
await page.waitForTimeout(40);
}
await page.waitForTimeout(200);
const snapshot = await page.evaluate(() => ({
cam: window.__galaxyMap!.getCamera(),
vp: window.__galaxyMap!.getViewport(),
world: window.__galaxyMap!.getWorldSize(),
}));
const minScale = Math.max(
snapshot.vp.widthPx / snapshot.world.width,
snapshot.vp.heightPx / snapshot.world.height,
);
const tol = 1e-3;
expect(snapshot.cam.scale).toBeGreaterThanOrEqual(minScale - tol);
});
test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
page,
}, testInfo) => {