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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.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 local = this.toLocal(e.global);
|
||||||
const point = { x: local.x, y: local.y };
|
const point = { x: local.x, y: local.y };
|
||||||
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
|
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
|
||||||
this.tween.finish();
|
this.tween.target(() => {
|
||||||
this.panning = { pointer: point, panX: this.view.panX };
|
this.panning = { pointer: point, panX: this.view.panX };
|
||||||
|
});
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -616,7 +617,10 @@ export class TimelineCanvas extends Widget {
|
|||||||
private onPointerMove(e: FederatedPointerEvent): void {
|
private onPointerMove(e: FederatedPointerEvent): void {
|
||||||
const local = this.toLocal(e.global);
|
const local = this.toLocal(e.global);
|
||||||
if (this.panning) {
|
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();
|
this.redraw();
|
||||||
return;
|
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
|
// 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.
|
// gesture that never means "grab this". Nodes let it through for exactly that reason.
|
||||||
const startPan = () => {
|
const startPan = () => {
|
||||||
this.tween.finish();
|
this.tween.target(() => {
|
||||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
startPan();
|
startPan();
|
||||||
@@ -782,9 +783,14 @@ export class GraphCanvas extends Widget {
|
|||||||
this.trackIncoming(dragged, e);
|
this.trackIncoming(dragged, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.panning) {
|
const { panning } = this;
|
||||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
if (panning) {
|
||||||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
this.tween.carry(() => {
|
||||||
|
this.pan = {
|
||||||
|
x: panning.panX + (e.global.x - panning.startX),
|
||||||
|
y: panning.panY + (e.global.y - panning.startY),
|
||||||
|
};
|
||||||
|
});
|
||||||
this.applyView();
|
this.applyView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,13 +76,16 @@ export function panAndZoom(
|
|||||||
): void {
|
): void {
|
||||||
let panning: { start: Point; pan: Point } | null = null;
|
let panning: { start: Point; pan: Point } | null = null;
|
||||||
surface.on('pointerdown', (e: FederatedPointerEvent) => {
|
surface.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||||
tween.finish();
|
tween.target(() => {
|
||||||
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
|
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
|
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||||
if (!panning) return;
|
if (!panning) return;
|
||||||
const { start, pan } = panning;
|
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('pointerup', () => (panning = null));
|
||||||
surface.on('pointerupoutside', () => (panning = null));
|
surface.on('pointerupoutside', () => (panning = null));
|
||||||
|
|||||||
@@ -566,9 +566,11 @@ export class PianoRoll extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
this.timeTween.finish();
|
this.timeTween.target(() =>
|
||||||
this.rowTween.finish();
|
this.rowTween.target(() => {
|
||||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const g = this.inGrid(p);
|
const g = this.inGrid(p);
|
||||||
@@ -660,17 +662,21 @@ export class PianoRoll extends Widget {
|
|||||||
const free = e.altKey || e.shiftKey;
|
const free = e.altKey || e.shiftKey;
|
||||||
switch (gesture.kind) {
|
switch (gesture.kind) {
|
||||||
case 'pan':
|
case 'pan':
|
||||||
this.view = clampRows(
|
this.timeTween.carry(() =>
|
||||||
clampTimePan(
|
this.rowTween.carry(() => {
|
||||||
{
|
this.view = clampRows(
|
||||||
...this.view,
|
clampTimePan(
|
||||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
{
|
||||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
...this.view,
|
||||||
},
|
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||||
this.gridWidth,
|
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||||
this.earliest,
|
},
|
||||||
),
|
this.gridWidth,
|
||||||
this.gridHeight,
|
this.earliest,
|
||||||
|
),
|
||||||
|
this.gridHeight,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'key': {
|
case 'key': {
|
||||||
|
|||||||
@@ -756,9 +756,11 @@ export class SongTimeline extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
this.timeTween.finish();
|
this.timeTween.target(() =>
|
||||||
this.trackTween.finish();
|
this.trackTween.target(() => {
|
||||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.take) return;
|
if (this.take) return;
|
||||||
@@ -911,17 +913,21 @@ export class SongTimeline extends Widget {
|
|||||||
const picked = new Set(this.pickedIds);
|
const picked = new Set(this.pickedIds);
|
||||||
switch (gesture.kind) {
|
switch (gesture.kind) {
|
||||||
case 'pan':
|
case 'pan':
|
||||||
this.view = clampTracks(
|
this.timeTween.carry(() =>
|
||||||
clampTimePan(
|
this.trackTween.carry(() => {
|
||||||
{
|
this.view = clampTracks(
|
||||||
...this.view,
|
clampTimePan(
|
||||||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
{
|
||||||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
...this.view,
|
||||||
},
|
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||||
this.lanesWidth,
|
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||||
),
|
},
|
||||||
asset.tracks.length,
|
this.lanesWidth,
|
||||||
this.lanesHeight,
|
),
|
||||||
|
asset.tracks.length,
|
||||||
|
this.lanesHeight,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'loop': {
|
case 'loop': {
|
||||||
|
|||||||
@@ -380,8 +380,9 @@ export class WaveCanvas extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ui().focus.focus(this);
|
ui().focus.focus(this);
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
this.timeTween.finish();
|
this.timeTween.target(() => {
|
||||||
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0 || p.x < 0) return;
|
if (e.button !== 0 || p.x < 0) return;
|
||||||
@@ -410,7 +411,13 @@ export class WaveCanvas extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (g.kind === 'pan') {
|
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.needsFit = false;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -142,8 +142,9 @@ export abstract class SheetCanvas<A> extends Widget {
|
|||||||
const local = this.toLocal(e.global);
|
const local = this.toLocal(e.global);
|
||||||
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
|
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
|
||||||
if (e.button === 1 || pans) {
|
if (e.button === 1 || pans) {
|
||||||
this.tween.finish();
|
this.tween.target(() => {
|
||||||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||||||
|
});
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -160,7 +161,9 @@ export abstract class SheetCanvas<A> extends Widget {
|
|||||||
this.move({ x: local.x, y: local.y }, e);
|
this.move({ x: local.x, y: local.y }, e);
|
||||||
return;
|
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();
|
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. */
|
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
|
||||||
export function easeOut(t: number): number {
|
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));
|
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
|
* 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.
|
* 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
|
* A pan made through `carry` moves the transition along with it. Changing the view by other means –
|
||||||
* it stands: the tween notices that the view is no longer the one it left and lets go.
|
* 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 {
|
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();
|
private readonly tick = () => this.step();
|
||||||
|
|
||||||
constructor(
|
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
|
* 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,
|
* 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 {
|
change(change: () => void): void {
|
||||||
const shown = this.view.read();
|
const shown = this.view.read();
|
||||||
@@ -101,7 +161,7 @@ export class ViewTween {
|
|||||||
if (running) this.view.write(running.to);
|
if (running) this.view.write(running.to);
|
||||||
change();
|
change();
|
||||||
const to = this.view.read();
|
const to = this.view.read();
|
||||||
const ms = this.duration();
|
let ms = this.duration();
|
||||||
if (ms <= 0 || near(shown, to)) {
|
if (ms <= 0 || near(shown, to)) {
|
||||||
this.stop();
|
this.stop();
|
||||||
if (running) this.view.draw();
|
if (running) this.view.draw();
|
||||||
@@ -111,18 +171,60 @@ export class ViewTween {
|
|||||||
this.view.write(shown);
|
this.view.write(shown);
|
||||||
return;
|
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.
|
// Not drawn here: the next frame draws the first step of the way before it is rendered.
|
||||||
this.view.write(shown);
|
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();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
|
/**
|
||||||
finish(): void {
|
* Runs `fn` on the view a transition is heading for rather than the one on screen – where a pan
|
||||||
if (!this.run) return;
|
* takes its hold, so a pan made through `carry` moves on from there.
|
||||||
this.land();
|
*/
|
||||||
this.view.draw();
|
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. */
|
/** Puts the view where the transition was heading, without drawing – before it is saved. */
|
||||||
@@ -141,7 +243,7 @@ export class ViewTween {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = (this.clock.now() - run.start) / run.ms;
|
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();
|
run.left = this.view.read();
|
||||||
if (t >= 1) this.stop();
|
if (t >= 1) this.stop();
|
||||||
this.view.draw();
|
this.view.draw();
|
||||||
|
|||||||
@@ -133,17 +133,21 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected startPan(e: FederatedPointerEvent): void {
|
protected startPan(e: FederatedPointerEvent): void {
|
||||||
this.tween.finish();
|
this.tween.target(() => {
|
||||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||||
|
});
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
|
/** 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 {
|
protected panTo(e: FederatedPointerEvent): boolean {
|
||||||
if (!this.panning) return false;
|
const { panning } = this;
|
||||||
const scale = this.worldScale();
|
if (!panning) return false;
|
||||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
|
this.tween.carry(() => {
|
||||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
|
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();
|
this.moveCamera();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { zoomedAbout } from '../../src/editor/graph/graphView';
|
|||||||
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
||||||
import {
|
import {
|
||||||
axisTween,
|
axisTween,
|
||||||
|
carriedSpeed,
|
||||||
easeOut,
|
easeOut,
|
||||||
|
glide,
|
||||||
|
glideSlope,
|
||||||
scrollTween,
|
scrollTween,
|
||||||
tweenView,
|
tweenView,
|
||||||
type ViewMap,
|
type ViewMap,
|
||||||
@@ -178,7 +181,7 @@ describe('the transition of a view', () => {
|
|||||||
expect(clock.attached).toBe(0);
|
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 clock = fakeClock();
|
||||||
const { s, target } = surface();
|
const { s, target } = surface();
|
||||||
const tween = new ViewTween(target, () => 200, clock);
|
const tween = new ViewTween(target, () => 200, clock);
|
||||||
@@ -188,12 +191,82 @@ describe('the transition of a view', () => {
|
|||||||
tween.land();
|
tween.land();
|
||||||
expect(s.view.scale).toBe(2);
|
expect(s.view.scale).toBe(2);
|
||||||
expect(s.draws).toBe(draws);
|
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);
|
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)', () => {
|
describe('a view whose axes zoom apart (#377)', () => {
|
||||||
|
|||||||
+22
-1
@@ -471,7 +471,28 @@ export async function topbar(t) {
|
|||||||
samePoint(landed.world, start.world),
|
samePoint(landed.world, start.world),
|
||||||
JSON.stringify({ jumped, landed }),
|
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.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);
|
await t.wheel(at.x, at.y, 100);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user