Skip to content

Commit

Permalink
[sigma] Adds Promises to camera animate methods
Browse files Browse the repository at this point in the history
Details:
- When calling camera#animate without callback, it returns a promise,
  that behaves exactly as the callback (it resolves when the animation
  ends or is interrupted)
- Makes other animation methods (animatedZoom, animatedUnzoom and
  animatedReset) return a promise
- Adds some unit tests for these promises behaviours
  • Loading branch information
jacomyal committed Oct 7, 2024
1 parent 230045a commit 32f319c
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 85 deletions.
115 changes: 38 additions & 77 deletions packages/sigma/src/core/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ export type CameraEvents = {

/**
* Camera class
*
* @constructor
*/
export default class Camera extends TypedEventEmitter<CameraEvents> implements CameraState {
x = 0.5;
Expand All @@ -50,9 +48,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Static method used to create a Camera object with a given state.
*
* @param state
* @return {Camera}
*/
static from(state: CameraState): Camera {
const camera = new Camera();
Expand All @@ -61,8 +56,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to enable the camera.
*
* @return {Camera}
*/
enable(): this {
this.enabled = true;
Expand All @@ -71,8 +64,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to disable the camera.
*
* @return {Camera}
*/
disable(): this {
this.enabled = false;
Expand All @@ -81,8 +72,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to retrieve the camera's current state.
*
* @return {object}
*/
getState(): CameraState {
return {
Expand All @@ -95,17 +84,13 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to check whether the camera has the given state.
*
* @return {object}
*/
hasState(state: CameraState): boolean {
return this.x === state.x && this.y === state.y && this.ratio === state.ratio && this.angle === state.angle;
}

/**
* Method used to retrieve the camera's previous state.
*
* @return {object}
*/
getPreviousState(): CameraState | null {
const state = this.previousState;
Expand All @@ -122,9 +107,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to check minRatio and maxRatio values.
*
* @param ratio
* @return {number}
*/
getBoundedRatio(ratio: number): number {
let r = ratio;
Expand All @@ -135,9 +117,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to check various things to return a legit state candidate.
*
* @param state
* @return {object}
*/
validateState(state: Partial<CameraState>): Partial<CameraState> {
const validatedState: Partial<CameraState> = {};
Expand All @@ -150,24 +129,17 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to check whether the camera is currently being animated.
*
* @return {boolean}
*/
isAnimated(): boolean {
return !!this.nextFrame;
}

/**
* Method used to set the camera's state.
*
* @param {object} state - New state.
* @return {Camera}
*/
setState(state: Partial<CameraState>): this {
if (!this.enabled) return this;

// TODO: update by function

// Keeping track of last state
this.previousState = this.getState();

Expand All @@ -185,10 +157,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to update the camera's state using a function.
*
* @param {function} updater - Updated function taking current state and
* returning next state.
* @return {Camera}
*/
updateState(updater: (state: CameraState) => Partial<CameraState>): this {
this.setState(updater(this.getState()));
Expand All @@ -197,17 +165,22 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Method used to animate the camera.
*
* @param {object} state - State to reach eventually.
* @param {object} opts - Options:
* @param {number} duration - Duration of the animation.
* @param {string | number => number} easing - Easing function or name of an existing one
* @param {function} callback - Callback
*/
animate(state: Partial<CameraState>, opts?: Partial<AnimateOptions>, callback?: () => void): void {
animate(state: Partial<CameraState>, opts: Partial<AnimateOptions>, callback: () => void): void;
animate(state: Partial<CameraState>, opts?: Partial<AnimateOptions>): Promise<void>;
animate(
state: Partial<CameraState>,
opts: Partial<AnimateOptions> = {},
callback?: () => void,
): void | Promise<void> {
if (!callback) return new Promise((resolve) => this.animate(state, opts, resolve));

if (!this.enabled) return;

const options: AnimateOptions = Object.assign({}, ANIMATE_DEFAULTS, opts);
const options: AnimateOptions = {
...ANIMATE_DEFAULTS,
...opts,
};
const validState = this.validateState(state);

const easing: (k: number) => number =
Expand Down Expand Up @@ -257,57 +230,47 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C
} else {
fn();
}

this.animationCallback = callback;
}

/**
* Method used to zoom the camera.
*
* @param {number|object} factorOrOptions - Factor or options.
* @return {function}
*/
animatedZoom(factorOrOptions?: number | (Partial<AnimateOptions> & { factor?: number })): void {
if (!factorOrOptions) {
this.animate({ ratio: this.ratio / DEFAULT_ZOOMING_RATIO });
} else {
if (typeof factorOrOptions === "number") return this.animate({ ratio: this.ratio / factorOrOptions });
else
this.animate(
{
ratio: this.ratio / (factorOrOptions.factor || DEFAULT_ZOOMING_RATIO),
},
factorOrOptions,
);
}
animatedZoom(factorOrOptions?: number | (Partial<AnimateOptions> & { factor?: number })): Promise<void> {
if (!factorOrOptions) return this.animate({ ratio: this.ratio / DEFAULT_ZOOMING_RATIO });

if (typeof factorOrOptions === "number") return this.animate({ ratio: this.ratio / factorOrOptions });

return this.animate(
{
ratio: this.ratio / (factorOrOptions.factor || DEFAULT_ZOOMING_RATIO),
},
factorOrOptions,
);
}

/**
* Method used to unzoom the camera.
*
* @param {number|object} factorOrOptions - Factor or options.
*/
animatedUnzoom(factorOrOptions?: number | (Partial<AnimateOptions> & { factor?: number })): void {
if (!factorOrOptions) {
this.animate({ ratio: this.ratio * DEFAULT_ZOOMING_RATIO });
} else {
if (typeof factorOrOptions === "number") return this.animate({ ratio: this.ratio * factorOrOptions });
else
this.animate(
{
ratio: this.ratio * (factorOrOptions.factor || DEFAULT_ZOOMING_RATIO),
},
factorOrOptions,
);
}
animatedUnzoom(factorOrOptions?: number | (Partial<AnimateOptions> & { factor?: number })): Promise<void> {
if (!factorOrOptions) return this.animate({ ratio: this.ratio * DEFAULT_ZOOMING_RATIO });

if (typeof factorOrOptions === "number") return this.animate({ ratio: this.ratio * factorOrOptions });

return this.animate(
{
ratio: this.ratio * (factorOrOptions.factor || DEFAULT_ZOOMING_RATIO),
},
factorOrOptions,
);
}

/**
* Method used to reset the camera.
*
* @param {object} options - Options.
*/
animatedReset(options?: Partial<AnimateOptions>): void {
this.animate(
animatedReset(options?: Partial<AnimateOptions>): Promise<void> {
return this.animate(
{
x: 0.5,
y: 0.5,
Expand All @@ -320,8 +283,6 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C

/**
* Returns a new Camera instance, with the same state as the current camera.
*
* @return {Camera}
*/
copy(): Camera {
return Camera.from(this.getState());
Expand Down
105 changes: 97 additions & 8 deletions packages/test/unit/sigma/camera.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Camera } from "sigma";
import { describe, expect, test } from "vitest";

function wait(timeout: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, timeout));
}

describe("Camera", function () {
test("it should be possible to read the camera's state.", function () {
const camera = new Camera();
Expand Down Expand Up @@ -158,16 +162,26 @@ describe("Camera", function () {
expect(camera.getState()).toEqual(state1);
});

test("it should trigger the animation callback when starting a new animation (regression #1107).", function () {
let flag = false;

test("it should check for ratio extrema (feature #1161).", function () {
const camera = new Camera();
camera.animate({}, { duration: 500 }, () => {
flag = true;
});
camera.animate({});

expect(flag).toEqual(true);
camera.minRatio = null;
camera.maxRatio = 10;
camera.setState({ ratio: 20 });
expect(camera.ratio).toEqual(10);

camera.minRatio = 0.1;
camera.maxRatio = null;
camera.setState({ ratio: 0.05 });
expect(camera.ratio).toEqual(0.1);

// Also check weird values (expect maxRatio to "win" that):
camera.minRatio = 10;
camera.maxRatio = 0.1;
camera.setState({ ratio: 0.05 });
expect(camera.ratio).toEqual(0.1);
camera.setState({ ratio: 20 });
expect(camera.ratio).toEqual(0.1);
});

test("it should check for ratio extrema (feature #1161).", function () {
Expand All @@ -191,4 +205,79 @@ describe("Camera", function () {
camera.setState({ ratio: 20 });
expect(camera.ratio).toEqual(0.1);
});

describe("Animations", () => {
test("it should trigger the animation callback when starting a new animation (regression #1107).", function () {
let flag = false;

const camera = new Camera();
camera.animate({}, { duration: 500 }, () => {
flag = true;
});
camera.animate({});

expect(flag).toEqual(true);
});

test("it should return promises that resolve when animation ends, when called without callback.", async function () {
const camera = new Camera();
const targetState = {
x: 1,
y: 0,
ratio: 0.1,
angle: Math.PI,
};
const duration = 50;
const t0 = Date.now();
await camera.animate(targetState, { duration });
const t1 = Date.now();

expect(Math.abs(t1 - t0 - duration)).toBeLessThan(duration / 2);
expect(camera.getState()).toEqual(targetState);
});

test("it should resolve promises when animation is interrupted by a new animation (using #animate).", async function () {
const camera = new Camera();
const targetState1 = { ...camera.getState(), x: 1 };
const targetState2 = { ...camera.getState(), x: 2 };
const duration = 50;
const delay = 10;

const t0 = Date.now();
await Promise.all([
camera.animate(targetState1, { duration }),
(async () => {
await wait(10);
await camera.animate(targetState2, { duration: 0 });
})(),
]);
const t1 = Date.now();

expect(camera.getState()).toEqual(targetState2);
// Time measures are very rough at this scale, we just want to check that t1 - t0 (the actual spent time)
// is basically closer to delay than to duration:
expect(Math.abs(t1 - t0 - delay)).toBeLessThan(delay);
});

test("it should resolve promises when animation is interrupted by a new animation (using the shortcut methods).", async function () {
const camera = new Camera();
const duration = 50;
const delay = 10;

const t0 = Date.now();
await Promise.all([
camera.animatedZoom({ duration }),
(async () => {
await wait(10);
await camera.animatedReset({ duration: 0 });
})(),
]);
const t1 = Date.now();

expect(camera.ratio).toEqual(1);
// Time measures are very rough at this scale, we just want to check that t1 - t0 (the actual spent time)
// is basically closer to delay than to duration:
expect(Math.abs(t1 - t0 - delay)).toBeLessThan(delay);
});
});
});

0 comments on commit 32f319c

Please sign in to comment.