ui: plan 01-27 done #1
+17
-17
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user