Zoom the tile tab in the same steps as the scene view
The tile tab zoomed freely, so the pixels of a tile could end up
unevenly wide at odd factors. Scene view, container tab and animation
preview already snap to FREE_ZOOM_STEPS. The tile tab now does the
same, so every pixel editor zooms alike.
- The wheel steps through FREE_ZOOM_STEPS, counted in device pixels.
The board position is rounded to whole device pixels.
- New wheelSteps() in src/viewport/view.ts turns wheel events into
steps: one notch is one step, a trackpad gathers 50 px per step.
FreeViewport uses it too, so the rule exists only once.
- Fit takes the largest step at which the whole board fits. Refitting
on a new tab or board size stays as before.
- panAndZoom takes an optional zoom rule instead of limits. Graph and
node board keep zooming freely.
- A saved view from before the steps snaps to the nearest step.
- The cap parameter of frameBoxes from 4027afc has no user anymore.
It is removed together with its unit test.
Fit rounds down, and below 100 percent the steps are far apart. In the
small e2e window the 48 px board lands at 25 instead of 41 percent,
the 16 px board at 100 instead of 124 percent.
Tests:
- check.sh: 161 files, 2370 tests, exit 0 (-1 cap test,
+1 wheelSteps test)
- e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now
expect steps: Fit on a step with the next one too large, one notch
goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent.
- Counter check: a free Fit factor fails exactly those three checks
(zoom 0.413 and 1.2396).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4027afc228
commit
021a6dcb56
8 files changed
+110
-47
No files matched your search
@@ -148,24 +148,21 @@ export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null {
|
||||
|
||||
/**
|
||||
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
|
||||
* Never zooms past `cap`, 1 by default: a two-node graph blown up to fill the screen helps nobody.
|
||||
* The Tile tab lifts it, since its board is measured in image pixels of a tile – 16 px of them
|
||||
* at 100 % is a stamp in the corner. The scene view does not share the cap at all, which is why
|
||||
* only the bounds sum is common.
|
||||
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view
|
||||
* deliberately does not share that cap, which is why only the bounds sum is common.
|
||||
*/
|
||||
export function frameBoxes(
|
||||
boxes: readonly Box[],
|
||||
view: { w: number; h: number },
|
||||
margin: number,
|
||||
limits: { min: number; max: number },
|
||||
cap = 1,
|
||||
): Framing {
|
||||
const bounds = boundsOf(boxes);
|
||||
if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
|
||||
const { minX, minY, maxX, maxY } = bounds;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, cap);
|
||||
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
|
||||
const zoom = Math.min(Math.max(fitted, limits.min), limits.max);
|
||||
return {
|
||||
zoom,
|
||||
|
||||
@@ -41,6 +41,13 @@ export function zoomedAbout(
|
||||
return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom };
|
||||
}
|
||||
|
||||
/**
|
||||
* How the wheel zooms: smoothly within limits, or by a rule of the surface's own – the Tile tab
|
||||
* takes the scene view's steps. A rule returns null while the wheel has not made a step yet.
|
||||
*/
|
||||
export type WheelZoom =
|
||||
{ min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null);
|
||||
|
||||
/**
|
||||
* Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer
|
||||
* when its content takes none. `set` receives each new view and which gesture made it.
|
||||
@@ -49,7 +56,7 @@ export function panAndZoom(
|
||||
surface: Container,
|
||||
get: () => GraphView,
|
||||
set: (view: GraphView, gesture: 'pan' | 'zoom') => void,
|
||||
limits: { min: number; max: number } = ZOOM_LIMITS,
|
||||
zoom: WheelZoom = ZOOM_LIMITS,
|
||||
): void {
|
||||
let panning: { start: Point; pan: Point } | null = null;
|
||||
surface.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
@@ -63,8 +70,13 @@ export function panAndZoom(
|
||||
surface.on('pointerup', () => (panning = null));
|
||||
surface.on('pointerupoutside', () => (panning = null));
|
||||
surface.on('wheel', (e: FederatedWheelEvent) => {
|
||||
set(zoomedAbout(get(), surface.toLocal(e.global), zoomFactor(e.deltaY), limits), 'zoom');
|
||||
e.preventDefault();
|
||||
const at = surface.toLocal(e.global);
|
||||
const view =
|
||||
typeof zoom === 'function'
|
||||
? zoom(get(), at, e.deltaY)
|
||||
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
|
||||
if (view) set(view, 'zoom');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
tilePlayback,
|
||||
} from '../../core/model/tile';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { type Size, Theme, textStyle, Widget } from '../../ui';
|
||||
import { type Size, Theme, textStyle, ui, Widget } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { type Box, frameBoxes } from '../framing';
|
||||
import { type GraphView, panAndZoom } from '../graph/graphView';
|
||||
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
|
||||
import type { Point } from '../framing';
|
||||
import { type GraphView, panAndZoom, zoomedAbout } from '../graph/graphView';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */
|
||||
@@ -38,8 +39,6 @@ const MIN_CAPTION_SIZE = 6;
|
||||
const CAPTION_MARGIN = 4;
|
||||
const SECTION_GAP = 1.2;
|
||||
const FIT_MARGIN = 16;
|
||||
/** Wider than the graph's: a 48 px tile is small, and its quarters are what one wants to look at. */
|
||||
const ZOOM_LIMITS = { min: 0.1, max: 6 };
|
||||
|
||||
/** One tile of the board: where it sits and which quarters it is drawn from. */
|
||||
interface BoardTile {
|
||||
@@ -64,7 +63,8 @@ interface Caption {
|
||||
* The Tile tab's picture (#118): every tile the block can produce, captioned with the
|
||||
* neighbours that ask for it, and below or beside them a sample map from the same quarters – the
|
||||
* one place that shows whether the edges really continue across tile borders. A drag pans, the
|
||||
* wheel zooms, nothing is edited here.
|
||||
* wheel zooms in the scene view's steps (`FREE_ZOOM_STEPS`, device pixels), so every texel of a
|
||||
* tile gets as many screen pixels as its neighbour; nothing is edited here.
|
||||
*/
|
||||
export class TileCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -107,6 +107,7 @@ export class TileCanvas extends Widget {
|
||||
/** The view size and the board a fit was made for. */
|
||||
private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } };
|
||||
private readonly stopPictures: () => void;
|
||||
private readonly wheel = wheelSteps();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
@@ -126,7 +127,7 @@ export class TileCanvas extends Widget {
|
||||
this.followsFit = false;
|
||||
this.applyView();
|
||||
},
|
||||
ZOOM_LIMITS,
|
||||
(view, at, deltaY) => this.zoomedStep(view, at, deltaY),
|
||||
);
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
@@ -174,7 +175,9 @@ export class TileCanvas extends Widget {
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: GraphView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { pan: { ...view.pan }, zoom: view.zoom };
|
||||
// A view saved before the steps, or on a screen with another scaling, lands on the nearest one.
|
||||
const zoom = nearestZoomStep(view.zoom * this.ratio()) / this.ratio();
|
||||
this.view = { pan: { ...view.pan }, zoom };
|
||||
this.needsFit = false;
|
||||
this.followsFit = false;
|
||||
this.redraw();
|
||||
@@ -204,7 +207,9 @@ export class TileCanvas extends Widget {
|
||||
tile: number;
|
||||
problem: string;
|
||||
beside: boolean;
|
||||
/** CSS pixels per image pixel; times `ratio` it is one of the scene view's steps. */
|
||||
zoom: number;
|
||||
ratio: number;
|
||||
pan: { x: number; y: number };
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
/** Grid and sample map together, in image pixels of the tile. */
|
||||
@@ -223,6 +228,7 @@ export class TileCanvas extends Widget {
|
||||
problem: info?.problem ?? '',
|
||||
beside: this.beside,
|
||||
zoom: this.view.zoom,
|
||||
ratio: this.ratio(),
|
||||
pan: { ...this.view.pan },
|
||||
rect: { x: origin.x, y: origin.y, w: this.w, h: this.h },
|
||||
size: { ...this.board },
|
||||
@@ -310,9 +316,11 @@ export class TileCanvas extends Widget {
|
||||
const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h;
|
||||
if (this.followsFit && regrown) this.needsFit = true;
|
||||
if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) {
|
||||
const box: Box = { id: 'board', x: 0, y: 0, w: this.board.w, h: this.board.h };
|
||||
const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS, ZOOM_LIMITS.max);
|
||||
this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom };
|
||||
// The largest step the board fits at, as in the scene view – no longer a free factor.
|
||||
const { w, h } = this.board;
|
||||
const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h);
|
||||
const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio();
|
||||
this.view = { pan: { x: (this.w - w * zoom) / 2, y: (this.h - h * zoom) / 2 }, zoom };
|
||||
this.needsFit = false;
|
||||
this.fittedFor = { w: this.w, h: this.h, board: { ...this.board } };
|
||||
}
|
||||
@@ -498,8 +506,25 @@ export class TileCanvas extends Widget {
|
||||
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
|
||||
}
|
||||
|
||||
/** One step about the pointer once the wheel has travelled far enough; null until then. */
|
||||
private zoomedStep(view: GraphView, at: Point, deltaY: number): GraphView | null {
|
||||
const direction = this.wheel(deltaY);
|
||||
if (direction === 0) return null;
|
||||
const zoom = stepFreeZoom(view.zoom * this.ratio(), direction) / this.ratio();
|
||||
if (zoom === view.zoom) return null;
|
||||
return zoomedAbout(view, at, zoom / view.zoom, { min: zoom, max: zoom });
|
||||
}
|
||||
|
||||
/** Device pixels per CSS pixel: the steps count device pixels, as in the scene view. */
|
||||
private ratio(): number {
|
||||
return ui().app.renderer.resolution;
|
||||
}
|
||||
|
||||
/** The board starts on a whole device pixel, so a step draws every texel alike. */
|
||||
private applyView(): void {
|
||||
this.content.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y));
|
||||
const ratio = this.ratio();
|
||||
const snap = (v: number) => Math.round(v * ratio) / ratio;
|
||||
this.content.position.set(snap(this.view.pan.x), snap(this.view.pan.y));
|
||||
this.content.scale.set(this.view.zoom);
|
||||
this.placeCaptions();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
screenToWorld,
|
||||
stepFreeZoom,
|
||||
type View,
|
||||
wheelSteps,
|
||||
zoomStepBelow,
|
||||
} from '../../viewport/view';
|
||||
import { WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
|
||||
const WHEEL_STEP = 50;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
|
||||
@@ -29,8 +28,7 @@ export abstract class FreeViewport extends WorldViewport {
|
||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||
/** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */
|
||||
private screenOrigin = { x: 0, y: 0 };
|
||||
/** Wheel travel not yet spent on a step; reset when the direction turns. */
|
||||
private wheel = 0;
|
||||
private readonly wheel = wheelSteps();
|
||||
|
||||
protected constructor(state: EditorState) {
|
||||
super(state);
|
||||
@@ -134,11 +132,9 @@ export abstract class FreeViewport extends WorldViewport {
|
||||
/** Zooms a step about the pointer, so the world point under it stays under it. */
|
||||
private zoomAt(e: FederatedWheelEvent): void {
|
||||
e.preventDefault();
|
||||
if (Math.sign(e.deltaY) !== Math.sign(this.wheel)) this.wheel = 0;
|
||||
this.wheel += e.deltaY;
|
||||
if (Math.abs(this.wheel) < WHEEL_STEP) return;
|
||||
const zoom = stepFreeZoom(this.view.zoom, this.wheel < 0 ? 1 : -1);
|
||||
this.wheel = 0;
|
||||
const direction = this.wheel(e.deltaY);
|
||||
if (direction === 0) return;
|
||||
const zoom = stepFreeZoom(this.view.zoom, direction);
|
||||
if (zoom === this.view.zoom) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
|
||||
@@ -49,6 +49,25 @@ function clampIndex(index: number): number {
|
||||
return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index));
|
||||
}
|
||||
|
||||
/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
|
||||
export const WHEEL_STEP = 50;
|
||||
|
||||
/**
|
||||
* Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel
|
||||
* left over is dropped when the direction turns, so a trackpad does not step back on its own.
|
||||
*/
|
||||
export function wheelSteps(): (deltaY: number) => 1 | -1 | 0 {
|
||||
let travel = 0;
|
||||
return (deltaY) => {
|
||||
if (Math.sign(deltaY) !== Math.sign(travel)) travel = 0;
|
||||
travel += deltaY;
|
||||
if (Math.abs(travel) < WHEEL_STEP) return 0;
|
||||
const direction = travel < 0 ? 1 : -1;
|
||||
travel = 0;
|
||||
return direction;
|
||||
};
|
||||
}
|
||||
|
||||
export { GAME_SCALE_STEPS, type GameScale };
|
||||
|
||||
/**
|
||||
|
||||
@@ -141,13 +141,6 @@ describe('fit', () => {
|
||||
expect(f.zoom).toBe(1);
|
||||
});
|
||||
|
||||
it('fills the view up to a raised cap – the Tile tab, whose board is a few tiles of 16 px', () => {
|
||||
const board: Box[] = [{ id: 'board', x: 0, y: 0, w: 345, h: 186 }];
|
||||
const f = frameBoxes(board, { w: 1000, h: 600 }, 16, { min: 0.1, max: 6 }, 6);
|
||||
expect(f.zoom).toBeCloseTo((1000 - 32) / 345);
|
||||
expect(frameBoxes(board, { w: 10000, h: 6000 }, 16, { min: 0.1, max: 6 }, 6).zoom).toBe(6);
|
||||
});
|
||||
|
||||
it('never zooms out past the limit, however far the nodes are apart', () => {
|
||||
const far: Box[] = [
|
||||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
nearestZoomStep,
|
||||
screenToWorld,
|
||||
stepFreeZoom,
|
||||
wheelSteps,
|
||||
worldBounds,
|
||||
worldToScreen,
|
||||
zoomStepBelow,
|
||||
@@ -71,6 +72,17 @@ describe('free view zoom steps under Windows scaling', () => {
|
||||
expect(nearestZoomStep(1.37)).toBe(1);
|
||||
expect(nearestZoomStep(0.4)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('a mouse notch is a step, a trackpad gathers travel, and turning drops what was left', () => {
|
||||
const wheel = wheelSteps();
|
||||
expect(wheel(-100)).toBe(1);
|
||||
expect(wheel(100)).toBe(-1);
|
||||
expect([wheel(-20), wheel(-20), wheel(-20)]).toEqual([0, 0, 1]);
|
||||
expect(wheel(-40)).toBe(0);
|
||||
// The 40 up still pending are dropped: 40 down alone are no step yet, 60 are.
|
||||
expect(wheel(40)).toBe(0);
|
||||
expect(wheel(20)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cameraView', () => {
|
||||
|
||||
+20
-11
@@ -208,14 +208,19 @@ export async function tile(t) {
|
||||
await settle();
|
||||
board = await json('__gm.tile.board()');
|
||||
const fitted = { zoom: board.zoom, pan: board.pan };
|
||||
// Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner:
|
||||
// framed, it reaches the margin on one side (or the zoom limit).
|
||||
const fills = (b) =>
|
||||
b.zoom === 6 ||
|
||||
Math.abs(Math.max((b.size.w * b.zoom) / (b.rect.w - 32), (b.size.h * b.zoom) / (b.rect.h - 32)) - 1) <
|
||||
0.01;
|
||||
// Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner.
|
||||
// It takes the scene view's steps (device pixels, `FREE_ZOOM_STEPS`): the largest one the board
|
||||
// fits at, so the next one up would run past the margin.
|
||||
const STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24];
|
||||
const fills = (b) => {
|
||||
const at = STEPS.findIndex((step) => Math.abs(step - b.zoom * b.ratio) < 1e-9);
|
||||
const fits = (step) =>
|
||||
(b.size.w * step) / b.ratio <= b.rect.w - 32 + 1e-6 &&
|
||||
(b.size.h * step) / b.ratio <= b.rect.h - 32 + 1e-6;
|
||||
return at >= 0 && fits(STEPS[at]) && (at === STEPS.length - 1 || !fits(STEPS[at + 1]));
|
||||
};
|
||||
check(
|
||||
'Fit fills the view with the board',
|
||||
'Fit fills the view with the board, at a step of the scene view',
|
||||
fills(board),
|
||||
JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }),
|
||||
);
|
||||
@@ -230,7 +235,11 @@ export async function tile(t) {
|
||||
await wheel(centre.x, centre.y, -300);
|
||||
await settle();
|
||||
board = await json('__gm.tile.board()');
|
||||
check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom }));
|
||||
check(
|
||||
'the wheel zooms it one step up',
|
||||
STEPS.indexOf(board.zoom * board.ratio) === STEPS.indexOf(fitted.zoom * board.ratio) + 1,
|
||||
JSON.stringify({ fitted, now: board.zoom }),
|
||||
);
|
||||
// The captions are text in screen space at one size for all: before, each was shrunk to its
|
||||
// tile on its own inside the zoomed board, so `N` came out large and soft and `NESW-ne,se,sw,nw`
|
||||
// tiny. Two lines for a notched tile, none of them running into another.
|
||||
@@ -408,10 +417,10 @@ export async function tile(t) {
|
||||
JSON.stringify(board),
|
||||
);
|
||||
// A third of the tile size, and the view untouched since the last Fit: the board frames itself
|
||||
// again, past 100 %, instead of shrinking to a third where it stood.
|
||||
// again, at 100 % or more, instead of shrinking to a third where it stood.
|
||||
check(
|
||||
'smaller tiles are framed again, past 100 %',
|
||||
board.zoom > 1 && fills(board),
|
||||
'smaller tiles are framed again, at 100 % or more',
|
||||
board.zoom * board.ratio >= 1 && fills(board),
|
||||
JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }),
|
||||
);
|
||||
const wholeTile = (tx, ty) => [
|
||||
|
||||
Reference in new issue
Block a user