ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
3 changed files with 70 additions and 31 deletions
Showing only changes of commit cc004f935d - Show all commits
+17 -17
View File
@@ -147,28 +147,28 @@ each copy renders the full line at its offset, and PixiJS' culling
naturally drops the parts outside its container's reachable area. naturally drops the parts outside its container's reachable area.
The nine-copy upper bound assumes the visible viewport never The nine-copy upper bound assumes the visible viewport never
exceeds three tile-widths or three tile-heights of the world. In exceeds three tile-widths or three tile-heights of the world. To
no-wrap mode we enforce `clampZoom({ minScale })` directly. In hold this assumption in both modes, the renderer enforces
torus mode we do not enforce a minScale; the playground starts at `clampZoom({ minScale })` with `minScale = max(viewport.W/world.W,
`minScale * 1.2` so a user has to zoom out aggressively before viewport.H/world.H)` regardless of wrap mode. Without this, in
seeing more than nine copies. If profiling ever reveals that torus mode the user could zoom out far enough to see the 3×3 grid
users do this, the renderer should switch to a generalised tile of wrap copies at once — the copies are there to fill partial slack
loop. near a panned edge, not to be visible simultaneously. The clamp is
re-evaluated on every viewport resize so a window resize does not
strand the camera below the new minimum.
## No-wrap camera ## No-wrap camera
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin `pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
keeps the camera inside the world rectangle by default. We layer keeps the camera inside the world rectangle by default. We layer
two project-specific rules on top, both implemented via the the project-specific centring rule on top, implemented via the
`'moved'` event: `'moved'` event: when the visible viewport is larger than the world
along an axis, the camera is **centred** on that axis.
1. When the visible viewport is larger than the world along an `pixi-viewport`'s default would pin the world to the top-left of
axis, the camera is **centred** on that axis. `pixi-viewport`'s the screen, which is jarring at low zoom. The shared
default would pin the world to the top-left of the screen, `clampZoom({ minScale })` (described above) prevents this case in
which is jarring at low zoom. practice, but the centring rule stays as a defensive layer for
2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W, windowed-resize transients.
viewport.H/world.H)` so the user cannot zoom out below
"viewport fits world".
`pivotZoom` keeps the world point under the cursor stable during `pivotZoom` keeps the world point under the cursor stable during
zoom. The math is symmetric and tested in zoom. The math is symmetric and tested in
+11 -6
View File
@@ -138,24 +138,29 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
for (let i = 0; i < copies.length; i++) { for (let i = 0; i < copies.length; i++) {
copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX; copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX;
} }
// Always reset clamp plugins; reattach for no-wrap. // Always reset clamp plugins; reattach per mode.
viewport.plugins.remove("clamp"); viewport.plugins.remove("clamp");
viewport.plugins.remove("clamp-zoom"); viewport.plugins.remove("clamp-zoom");
viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", enforceCentreWhenLarger);
if (newMode === "no-wrap") {
const minScale = minScaleNoWrap( const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight }, { widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world, opts.world,
); );
// Both modes enforce minScale on zoom-out: the world (origin
// copy) always fills at least the viewport. Without this, in
// torus mode the user would zoom out far enough to see the
// 3×3 grid of wrap copies at once; the copies are there to
// fill the partial slack near a panned edge, not to be
// visible simultaneously.
viewport.clampZoom({ minScale }); viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (newMode === "no-wrap") {
viewport.clamp({ direction: "all" }); viewport.clamp({ direction: "all" });
viewport.on("moved", enforceCentreWhenLarger); viewport.on("moved", enforceCentreWhenLarger);
enforceCentreWhenLarger(); enforceCentreWhenLarger();
} else {
// Torus mode: drop tight bounds, allow free pan.
viewport.moveCenter(viewport.center.x, viewport.center.y);
} }
// Torus mode keeps free pan (no `clamp()`); the visible wrap
// copies handle the cross-edge case naturally.
}; };
applyMode(mode); applyMode(mode);
@@ -180,11 +185,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
resize: (w, h) => { resize: (w, h) => {
app.renderer.resize(w, h); app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height); viewport.resize(w, h, opts.world.width, opts.world.height);
if (mode === "no-wrap") {
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
viewport.plugins.remove("clamp-zoom"); viewport.plugins.remove("clamp-zoom");
viewport.clampZoom({ minScale }); viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (mode === "no-wrap") {
enforceCentreWhenLarger(); enforceCentreWhenLarger();
} }
}, },
@@ -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"); 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 ({ test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
page, page,
}, testInfo) => { }, testInfo) => {