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.
The nine-copy upper bound assumes the visible viewport never
exceeds three tile-widths or three tile-heights of the world. In
no-wrap mode we enforce `clampZoom({ minScale })` directly. In
torus mode we do not enforce a minScale; the playground starts at
`minScale * 1.2` so a user has to zoom out aggressively before
seeing more than nine copies. If profiling ever reveals that
users do this, the renderer should switch to a generalised tile
loop.
exceeds three tile-widths or three tile-heights of the world. To
hold this assumption in both modes, the renderer enforces
`clampZoom({ minScale })` with `minScale = max(viewport.W/world.W,
viewport.H/world.H)` regardless of wrap mode. Without this, in
torus mode the user could zoom out far enough to see the 3×3 grid
of wrap copies at once — the copies are there to fill partial slack
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
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
keeps the camera inside the world rectangle by default. We layer
two project-specific rules on top, both implemented via the
`'moved'` event:
1. When the visible viewport is larger than the world along an
axis, the camera is **centred** on that axis. `pixi-viewport`'s
default would pin the world to the top-left of the screen,
which is jarring at low zoom.
2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W,
viewport.H/world.H)` so the user cannot zoom out below
"viewport fits world".
the project-specific centring rule on top, implemented via the
`'moved'` event: when the visible viewport is larger than the world
along an axis, the camera is **centred** on that axis.
`pixi-viewport`'s default would pin the world to the top-left of
the screen, which is jarring at low zoom. The shared
`clampZoom({ minScale })` (described above) prevents this case in
practice, but the centring rule stays as a defensive layer for
windowed-resize transients.
`pivotZoom` keeps the world point under the cursor stable during
zoom. The math is symmetric and tested in
+19 -14
View File
@@ -138,24 +138,29 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
for (let i = 0; i < copies.length; i++) {
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-zoom");
viewport.off("moved", enforceCentreWhenLarger);
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
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 });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (newMode === "no-wrap") {
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world,
);
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
viewport.clamp({ direction: "all" });
viewport.on("moved", 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);
@@ -180,11 +185,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
viewport.plugins.remove("clamp-zoom");
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (mode === "no-wrap") {
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
viewport.plugins.remove("clamp-zoom");
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
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");
});
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) => {