diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 273ab674..2f3493dd 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -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, diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index 73419f4c..eba202fa 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -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'); }); } diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 590f55ab..e5e0061e 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -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(); } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 3a9edde1..9ed919ca 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -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); diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 77b3f72e..4a7f3ac9 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -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 }; /** diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index e4813bcd..eec73c25 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -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 }, diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index a6eec78c..74346316 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -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', () => { diff --git a/tools/e2e/39-tile.mjs b/tools/e2e/39-tile.mjs index 094cdec7..30999e64 100644 --- a/tools/e2e/39-tile.mjs +++ b/tools/e2e/39-tile.mjs @@ -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) => [