Keep a transition's pace through new steps and pans (#309)
A wheel step in the middle of a transition restarted the ease-out curve at full speed, so a run of quick steps kicked at each one. A pan called finish() first and made the zoom jump to its target. Both made the zoom and the gliding lists feel jerky. - A new step starts from the speed the view already has and then eases out. Quick steps in one direction move in one stroke. A step the other way still starts from rest. - A pan takes the transition along. The zoom goes on while dragging and lands where it was heading, moved by the pan. This covers all eight pan places: scene and container, graph, plugin board, texture and pixel art, timeline, pattern, song and sound. - finish() is gone, it has no callers left. Dragging the scrollbar, a jump to a row, another document or a new view size still end the transition as before. Found: on a second step the old curve jumped to 2.47 times the speed the view had just before. Tests: - npm run check on stein2: 299 files, 4598 tests (5 new) - e2e 46 on stein2: ALL PASSED, 24 checks (1 new). A pan by 40/30 px during a 1000 ms zoom step leaves it moving (2.33 on the way from 2 to 3), it lands on 3 with the world point moved by the pan. - Counter-checks: with the pace reset to 3 the new unit test fails with 2.47 instead of at most 1.1. Without carry the pan test fails. - melektaus-atx went offline mid-run, so both ran on stein2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be41b1f0d7
commit
597367c804
12 files changed
+307
-72
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2017 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -554,8 +554,9 @@ export class TimelineCanvas extends Widget {
|
||||
const local = this.toLocal(e.global);
|
||||
const point = { x: local.x, y: local.y };
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
|
||||
this.tween.finish();
|
||||
this.panning = { pointer: point, panX: this.view.panX };
|
||||
this.tween.target(() => {
|
||||
this.panning = { pointer: point, panX: this.view.panX };
|
||||
});
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -616,7 +617,10 @@ export class TimelineCanvas extends Widget {
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
if (this.panning) {
|
||||
this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) });
|
||||
const { panning } = this;
|
||||
this.tween.carry(() => {
|
||||
this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) });
|
||||
});
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -593,8 +593,9 @@ export class GraphCanvas extends Widget {
|
||||
// The middle button pans from anywhere, a node under the pointer included – it is the one
|
||||
// gesture that never means "grab this". Nodes let it through for exactly that reason.
|
||||
const startPan = () => {
|
||||
this.tween.finish();
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
this.tween.target(() => {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
});
|
||||
};
|
||||
if (e.button === 1) {
|
||||
startPan();
|
||||
@@ -782,9 +783,14 @@ export class GraphCanvas extends Widget {
|
||||
this.trackIncoming(dragged, e);
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||||
const { panning } = this;
|
||||
if (panning) {
|
||||
this.tween.carry(() => {
|
||||
this.pan = {
|
||||
x: panning.panX + (e.global.x - panning.startX),
|
||||
y: panning.panY + (e.global.y - panning.startY),
|
||||
};
|
||||
});
|
||||
this.applyView();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,13 +76,16 @@ export function panAndZoom(
|
||||
): void {
|
||||
let panning: { start: Point; pan: Point } | null = null;
|
||||
surface.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
tween.finish();
|
||||
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
|
||||
tween.target(() => {
|
||||
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
|
||||
});
|
||||
});
|
||||
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (!panning) return;
|
||||
const { start, pan } = panning;
|
||||
set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan');
|
||||
tween.carry(() =>
|
||||
set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'),
|
||||
);
|
||||
});
|
||||
surface.on('pointerup', () => (panning = null));
|
||||
surface.on('pointerupoutside', () => (panning = null));
|
||||
|
||||
@@ -566,9 +566,11 @@ export class PianoRoll extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.rowTween.finish();
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
this.timeTween.target(() =>
|
||||
this.rowTween.target(() => {
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const g = this.inGrid(p);
|
||||
@@ -660,17 +662,21 @@ export class PianoRoll extends Widget {
|
||||
const free = e.altKey || e.shiftKey;
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
this.view = clampRows(
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.gridWidth,
|
||||
this.earliest,
|
||||
),
|
||||
this.gridHeight,
|
||||
this.timeTween.carry(() =>
|
||||
this.rowTween.carry(() => {
|
||||
this.view = clampRows(
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.gridWidth,
|
||||
this.earliest,
|
||||
),
|
||||
this.gridHeight,
|
||||
);
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'key': {
|
||||
|
||||
@@ -756,9 +756,11 @@ export class SongTimeline extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.trackTween.finish();
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
this.timeTween.target(() =>
|
||||
this.trackTween.target(() => {
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.take) return;
|
||||
@@ -911,17 +913,21 @@ export class SongTimeline extends Widget {
|
||||
const picked = new Set(this.pickedIds);
|
||||
switch (gesture.kind) {
|
||||
case 'pan':
|
||||
this.view = clampTracks(
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.lanesWidth,
|
||||
),
|
||||
asset.tracks.length,
|
||||
this.lanesHeight,
|
||||
this.timeTween.carry(() =>
|
||||
this.trackTween.carry(() => {
|
||||
this.view = clampTracks(
|
||||
clampTimePan(
|
||||
{
|
||||
...this.view,
|
||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||
},
|
||||
this.lanesWidth,
|
||||
),
|
||||
asset.tracks.length,
|
||||
this.lanesHeight,
|
||||
);
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'loop': {
|
||||
|
||||
@@ -380,8 +380,9 @@ export class WaveCanvas extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
||||
this.timeTween.target(() => {
|
||||
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0 || p.x < 0) return;
|
||||
@@ -410,7 +411,13 @@ export class WaveCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
if (g.kind === 'pan') {
|
||||
this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.areaWidth);
|
||||
this.timeTween.carry(() => {
|
||||
this.view = clampWave(
|
||||
{ ...this.view, panX: g.panX + p.x - g.pointerX },
|
||||
wave.duration,
|
||||
this.areaWidth,
|
||||
);
|
||||
});
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
return;
|
||||
|
||||
@@ -142,8 +142,9 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
const local = this.toLocal(e.global);
|
||||
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
|
||||
if (e.button === 1 || pans) {
|
||||
this.tween.finish();
|
||||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||||
this.tween.target(() => {
|
||||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||||
});
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -160,7 +161,9 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
this.move({ x: local.x, y: local.y }, e);
|
||||
return;
|
||||
}
|
||||
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
|
||||
this.tween.carry(() =>
|
||||
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)),
|
||||
);
|
||||
this.panned();
|
||||
}
|
||||
|
||||
|
||||
+116
-14
@@ -13,8 +13,60 @@ export interface ViewMap {
|
||||
|
||||
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
|
||||
export function easeOut(t: number): number {
|
||||
return glide(t, 3);
|
||||
}
|
||||
|
||||
/** The steepest start `glide` takes without running past its end. */
|
||||
const STEEPEST = 4;
|
||||
|
||||
/**
|
||||
* The share of the way at `t`, setting off at `speed` (shares per duration) and settling with
|
||||
* neither speed nor pull left at the end. At 3 it is the cubic ease-out, at 0 it starts at rest.
|
||||
*/
|
||||
export function glide(t: number, speed: number): number {
|
||||
const rest = 1 - Math.min(1, Math.max(0, t));
|
||||
return 1 - rest * rest * rest;
|
||||
return 1 - (4 - speed) * rest ** 3 - (speed - 3) * rest ** 4;
|
||||
}
|
||||
|
||||
/** How fast `glide` moves at `t`, in shares per duration. */
|
||||
export function glideSlope(t: number, speed: number): number {
|
||||
const rest = 1 - Math.min(1, Math.max(0, t));
|
||||
return 3 * (4 - speed) * rest ** 2 + 4 * (speed - 3) * rest ** 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* The speed a new way from `from` to `to`, `ms` long, sets off at to go on as fast as the old one
|
||||
* moved: `rate` shares of `old` per ms, at share `at`. Along the scale where the new way zooms,
|
||||
* along its line where it slides. Moving against the new way starts it at rest.
|
||||
*/
|
||||
export function carriedSpeed(
|
||||
old: { from: ViewMap; to: ViewMap },
|
||||
at: number,
|
||||
rate: number,
|
||||
from: ViewMap,
|
||||
to: ViewMap,
|
||||
ms: number,
|
||||
): number {
|
||||
const h = at < 0.5 ? 1e-4 : -1e-4;
|
||||
const a = tweenView(old.from, old.to, at);
|
||||
const b = tweenView(old.from, old.to, at + h);
|
||||
const per = (p: number, q: number) => ((q - p) / h) * rate * ms;
|
||||
const zoom = Math.log(to.scale / from.scale);
|
||||
let speed: number;
|
||||
if (Math.abs(zoom) > 1e-9) speed = per(Math.log(a.scale), Math.log(b.scale)) / zoom;
|
||||
else {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = dx * dx + dy * dy;
|
||||
speed = length > 0 ? (per(a.x, b.x) * dx + per(a.y, b.y) * dy) / length : 0;
|
||||
}
|
||||
return Number.isFinite(speed) ? Math.max(0, speed) : 0;
|
||||
}
|
||||
|
||||
/** The move that takes the screen of view `a` to that of `b`, applied to `view`. */
|
||||
function moved(view: ViewMap, a: ViewMap, b: ViewMap): ViewMap {
|
||||
const k = b.scale / a.scale;
|
||||
return { scale: view.scale * k, x: b.x + (view.x - a.x) * k, y: b.y + (view.y - a.y) * k };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,11 +119,19 @@ const tickerClock: TweenClock = {
|
||||
* Shows a zoom or a Fit as a short transition instead of a jump (#309). The surface changes its view
|
||||
* as it always did, and the tween shows the way from what was on screen to it.
|
||||
*
|
||||
* Changing the view by other means – a pan, another document, a resize – ends the transition where
|
||||
* it stands: the tween notices that the view is no longer the one it left and lets go.
|
||||
* A pan made through `carry` moves the transition along with it. Changing the view by other means –
|
||||
* another document, a resize, a jump – ends the transition where it stands: the tween notices that
|
||||
* the view is no longer the one it left and lets go.
|
||||
*/
|
||||
export class ViewTween {
|
||||
private run: { from: ViewMap; to: ViewMap; start: number; ms: number; left: ViewMap } | null = null;
|
||||
private run: {
|
||||
from: ViewMap;
|
||||
to: ViewMap;
|
||||
start: number;
|
||||
ms: number;
|
||||
speed: number;
|
||||
left: ViewMap;
|
||||
} | null = null;
|
||||
private readonly tick = () => this.step();
|
||||
|
||||
constructor(
|
||||
@@ -93,7 +153,7 @@ export class ViewTween {
|
||||
/**
|
||||
* Carries out `change` and shows the way to the view it leaves. A change made during a transition
|
||||
* starts from where that one was heading, so quick wheel steps add up to exactly as many steps,
|
||||
* while the way to the new view starts from what is on screen.
|
||||
* while the way to the new view starts from what is on screen, as fast as the screen moved.
|
||||
*/
|
||||
change(change: () => void): void {
|
||||
const shown = this.view.read();
|
||||
@@ -101,7 +161,7 @@ export class ViewTween {
|
||||
if (running) this.view.write(running.to);
|
||||
change();
|
||||
const to = this.view.read();
|
||||
const ms = this.duration();
|
||||
let ms = this.duration();
|
||||
if (ms <= 0 || near(shown, to)) {
|
||||
this.stop();
|
||||
if (running) this.view.draw();
|
||||
@@ -111,18 +171,60 @@ export class ViewTween {
|
||||
this.view.write(shown);
|
||||
return;
|
||||
}
|
||||
if (!running) this.clock.add(this.tick);
|
||||
const now = this.clock.now();
|
||||
let speed = 3;
|
||||
if (running) {
|
||||
const t = Math.min(1, (now - running.start) / running.ms);
|
||||
const rate = glideSlope(t, running.speed) / running.ms;
|
||||
speed = carriedSpeed(running, glide(t, running.speed), rate, shown, to, ms);
|
||||
// Faster than the rest of the way allows without running past it: get there sooner instead.
|
||||
if (speed > STEEPEST) {
|
||||
ms *= STEEPEST / speed;
|
||||
speed = STEEPEST;
|
||||
}
|
||||
} else this.clock.add(this.tick);
|
||||
// Not drawn here: the next frame draws the first step of the way before it is rendered.
|
||||
this.view.write(shown);
|
||||
this.run = { from: shown, to, start: this.clock.now(), ms, left: this.view.read() };
|
||||
this.run = { from: shown, to, start: now, ms, speed, left: this.view.read() };
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
|
||||
finish(): void {
|
||||
if (!this.run) return;
|
||||
this.land();
|
||||
this.view.draw();
|
||||
/**
|
||||
* Runs `fn` on the view a transition is heading for rather than the one on screen – where a pan
|
||||
* takes its hold, so a pan made through `carry` moves on from there.
|
||||
*/
|
||||
target(fn: () => void): void {
|
||||
const run = this.run;
|
||||
if (!run) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
const shown = this.view.read();
|
||||
this.view.write(run.to);
|
||||
fn();
|
||||
this.view.write(shown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries out a pan during a transition without ending it: `fn` moves the view the transition is
|
||||
* heading for, and the view on screen and the rest of the way move with it.
|
||||
*/
|
||||
carry(fn: () => void): void {
|
||||
const run = this.run;
|
||||
if (!run) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
const shown = this.view.read();
|
||||
this.view.write(run.to);
|
||||
fn();
|
||||
const to = this.view.read();
|
||||
const left = moved(shown, run.to, to);
|
||||
run.from = moved(run.from, run.to, to);
|
||||
run.to = to;
|
||||
this.view.write(left);
|
||||
run.left = this.view.read();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Puts the view where the transition was heading, without drawing – before it is saved. */
|
||||
@@ -141,7 +243,7 @@ export class ViewTween {
|
||||
return;
|
||||
}
|
||||
const t = (this.clock.now() - run.start) / run.ms;
|
||||
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
|
||||
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, glide(t, run.speed)));
|
||||
run.left = this.view.read();
|
||||
if (t >= 1) this.stop();
|
||||
this.view.draw();
|
||||
|
||||
@@ -133,17 +133,21 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
|
||||
}
|
||||
|
||||
protected startPan(e: FederatedPointerEvent): void {
|
||||
this.tween.finish();
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
this.tween.target(() => {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
});
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
|
||||
protected panTo(e: FederatedPointerEvent): boolean {
|
||||
if (!this.panning) return false;
|
||||
const scale = this.worldScale();
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
|
||||
const { panning } = this;
|
||||
if (!panning) return false;
|
||||
this.tween.carry(() => {
|
||||
const scale = this.worldScale();
|
||||
this.view.x = panning.viewX - (e.global.x - panning.startX) / scale;
|
||||
this.view.y = panning.viewY - (e.global.y - panning.startY) / scale;
|
||||
});
|
||||
this.moveCamera();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { zoomedAbout } from '../../src/editor/graph/graphView';
|
||||
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
||||
import {
|
||||
axisTween,
|
||||
carriedSpeed,
|
||||
easeOut,
|
||||
glide,
|
||||
glideSlope,
|
||||
scrollTween,
|
||||
tweenView,
|
||||
type ViewMap,
|
||||
@@ -178,7 +181,7 @@ describe('the transition of a view', () => {
|
||||
expect(clock.attached).toBe(0);
|
||||
});
|
||||
|
||||
it('lands on the target without drawing, and finishes by drawing it', () => {
|
||||
it('lands on the target without drawing', () => {
|
||||
const clock = fakeClock();
|
||||
const { s, target } = surface();
|
||||
const tween = new ViewTween(target, () => 200, clock);
|
||||
@@ -188,12 +191,82 @@ describe('the transition of a view', () => {
|
||||
tween.land();
|
||||
expect(s.view.scale).toBe(2);
|
||||
expect(s.draws).toBe(draws);
|
||||
tween.change(double(s));
|
||||
tween.finish();
|
||||
expect(s.view.scale).toBe(4);
|
||||
expect(s.draws).toBe(draws + 1);
|
||||
expect(clock.attached).toBe(0);
|
||||
});
|
||||
|
||||
/** How fast the scale moves, in ratio per ms, over the next `ms`. */
|
||||
const pace = (s: { view: ViewMap }, clock: ReturnType<typeof fakeClock>, ms: number) => {
|
||||
const before = s.view.scale;
|
||||
clock.advance(ms);
|
||||
return Math.log(s.view.scale / before) / ms;
|
||||
};
|
||||
|
||||
it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
|
||||
const clock = fakeClock();
|
||||
const { s, target } = surface();
|
||||
const tween = new ViewTween(target, () => 200, clock);
|
||||
tween.change(double(s));
|
||||
clock.advance(48);
|
||||
const before = pace(s, clock, 2);
|
||||
tween.change(double(s));
|
||||
const after = pace(s, clock, 2);
|
||||
expect(after / before).toBeGreaterThan(0.9);
|
||||
expect(after / before).toBeLessThan(1.1);
|
||||
clock.advance(200);
|
||||
expect(s.view.scale).toBe(4);
|
||||
});
|
||||
|
||||
it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
|
||||
const clock = fakeClock();
|
||||
const { s, target } = surface();
|
||||
const tween = new ViewTween(target, () => 200, clock);
|
||||
tween.change(double(s));
|
||||
clock.advance(60);
|
||||
let hold = { x: 0, y: 0 };
|
||||
tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
|
||||
expect(hold).toEqual({ x: 0, y: 0 });
|
||||
const shown = { ...s.view };
|
||||
tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
|
||||
expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
|
||||
expect(tween.moving).toBe(true);
|
||||
clock.advance(16);
|
||||
expect(tween.moving).toBe(true);
|
||||
expect(s.view.scale).toBeGreaterThan(shown.scale);
|
||||
clock.advance(200);
|
||||
expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the curve of a transition', () => {
|
||||
it('starts at the speed it is given and settles at the end', () => {
|
||||
for (const speed of [0, 1.5, 3, 4]) {
|
||||
expect(glide(0, speed)).toBe(0);
|
||||
expect(glide(1, speed)).toBe(1);
|
||||
expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
|
||||
expect(glideSlope(1, speed)).toBe(0);
|
||||
expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
|
||||
}
|
||||
});
|
||||
|
||||
it('never runs back or past its end up to the steepest start', () => {
|
||||
for (const speed of [0, 2, 4]) {
|
||||
let last = 0;
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const share = glide(i / 100, speed);
|
||||
expect(share).toBeGreaterThanOrEqual(last);
|
||||
expect(share).toBeLessThanOrEqual(1);
|
||||
last = share;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
|
||||
const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
|
||||
const from = tweenView(old.from, old.to, 0.5);
|
||||
// 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
|
||||
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
|
||||
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a view whose axes zoom apart (#377)', () => {
|
||||
|
||||
+22
-1
@@ -471,7 +471,28 @@ export async function topbar(t) {
|
||||
samePoint(landed.world, start.world),
|
||||
JSON.stringify({ jumped, landed }),
|
||||
);
|
||||
// Handed back as found: no transition for the steps after this one, and the zoom it had.
|
||||
// A pan during the transition takes it along instead of jumping to its end.
|
||||
const by = { x: 40, y: 30 };
|
||||
const moved = { x: at.x + by.x, y: at.y + by.y };
|
||||
await t.mouse('mouseMoved', at.x, at.y);
|
||||
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
|
||||
await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' });
|
||||
const panned = await json('__gm.sceneView()');
|
||||
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' });
|
||||
const settled = await json('__gm.sceneView()');
|
||||
const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`);
|
||||
check(
|
||||
'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan',
|
||||
panned.moving === true &&
|
||||
panned.zoom > landed.zoom &&
|
||||
panned.zoom < settled.zoom &&
|
||||
settled.zoom > landed.zoom &&
|
||||
samePoint(heldAfter, start.world),
|
||||
JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }),
|
||||
);
|
||||
// Handed back as found: no transition for the steps after this one, and the view it had.
|
||||
await t.evaluate('__gm.zoomTransition(0)');
|
||||
await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' });
|
||||
await t.wheel(at.x, at.y, 100);
|
||||
await t.wheel(at.x, at.y, 100);
|
||||
}
|
||||
Reference in new issue
Block a user