[eric] canvas: stop the spawn camera ratcheting out forever, it reached 18% and nothing was readable

This commit is contained in:
ciregenz
2026-07-31 19:29:39 -07:00
parent aef1b69f76
commit 29f33b6821
3 changed files with 62 additions and 1 deletions
@@ -0,0 +1,36 @@
// Run: cd frontend && npx tsx --test src/app/pages/Dashboard/canvas/revealZoom.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.js';
const MIN = 0.15, MAX = 3.0;
test('a spawn never zooms IN, which is the behaviour worth keeping', () => {
assert.equal(revealZoom(0.8, 2.0, MIN, MAX), 0.8);
});
test('it still zooms out when the card genuinely does not fit', () => {
assert.equal(revealZoom(1.0, 0.7, MIN, MAX), 0.7);
});
test('but never below readable, which is the bug', () => {
assert.equal(revealZoom(1.0, 0.05, MIN, MAX), REVEAL_MIN_ZOOM);
assert.equal(revealZoom(0.6, 0.18, MIN, MAX), REVEAL_MIN_ZOOM);
});
test('the measured death spiral bottoms out instead of reaching 18%', () => {
// Replays the real session: each step is a reveal whose fit was tighter than the last.
let z = 1.0;
for (const fit of [0.88, 0.79, 0.76, 0.61, 0.36, 0.34, 0.18]) z = revealZoom(z, fit, MIN, MAX);
assert.equal(z, REVEAL_MIN_ZOOM);
assert.ok(z >= REVEAL_MIN_ZOOM, 'must not ratchet past the floor no matter how many spawns');
});
test('a camera the user already zoomed out by hand is left alone', () => {
// Below the floor already: the reveal must not yank them back IN, that would fight the user.
assert.equal(revealZoom(0.2, 0.9, MIN, MAX), 0.2);
});
test('it respects the hard bounds', () => {
assert.equal(revealZoom(5.0, 9.0, MIN, MAX), MAX);
});
@@ -0,0 +1,18 @@
// How far the camera is allowed to pull back when a new card appears.
//
// The reveal takes min(current, fit) so a spawn never zooms IN, which is right. But that made the
// camera monotonically decreasing: every spawn that did not fit ratcheted it out and nothing ever
// brought it back. Measured over one ordinary session: 100% -> 88% -> 79% -> 61% -> 36% -> 18%, at
// which point the workspace is a fifth of the viewport and not one word is readable.
//
// So the reveal gets a floor. Fitting every card on screen is not the goal; seeing the new one is.
// A hand-driven zoom is untouched and can still go to the hard minimum.
export const REVEAL_MIN_ZOOM = 0.5;
export function revealZoom(currentZoom: number, fitZoom: number, minZoom: number, maxZoom: number): number {
// Floor the FIT, not the result. Flooring the result would drag a user who had deliberately zoomed
// out back IN, which breaks the one rule this function already had: a reveal never zooms in.
const floor = Math.max(minZoom, REVEAL_MIN_ZOOM);
return Math.min(Math.min(currentZoom, Math.max(fitZoom, floor)), maxZoom);
}
@@ -7,8 +7,14 @@ import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
import { revealZoom } from '../../canvas/revealZoom';
const MIN_ZOOM = 0.15;
// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go
// down, so every spawn that did not fit ratcheted the camera out and nothing ever brought it back:
// measured 100% -> 88% -> 79% -> 61% -> 36% -> 18% over one ordinary session, at which point no word
// on the canvas is readable. A hand-driven zoom can still go all the way to MIN_ZOOM.
const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
@@ -792,7 +798,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
(v.width - REVEAL_MARGIN * 2) / (maxX - minX),
(v.height - REVEAL_MARGIN * 2) / (maxY - minY),
);
const zoom = clamp(Math.min(cur.zoom, fitZoom), MIN_ZOOM, MAX_ZOOM);
// Never auto-zoom below readable: showing every card at 18% is worse than showing the new one at 50%, and the pan below still brings it into view.
const zoom = clamp(revealZoom(cur.zoom, fitZoom, MIN_ZOOM, MAX_ZOOM), MIN_ZOOM, MAX_ZOOM);
// If zooming out, keep the viewport-center world point fixed first, then clamp.
const ratio = zoom / cur.zoom;
let panX = v.width / 2 - (v.width / 2 - cur.panX) * ratio;