diff --git a/packages/sigma/src/core/camera.ts b/packages/sigma/src/core/camera.ts index fc90dea7f..f38c10ba1 100644 --- a/packages/sigma/src/core/camera.ts +++ b/packages/sigma/src/core/camera.ts @@ -22,8 +22,6 @@ export type CameraEvents = { /** * Camera class - * - * @constructor */ export default class Camera extends TypedEventEmitter implements CameraState { x = 0.5; @@ -50,9 +48,6 @@ export default class Camera extends TypedEventEmitter 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(); @@ -61,8 +56,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to enable the camera. - * - * @return {Camera} */ enable(): this { this.enabled = true; @@ -71,8 +64,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to disable the camera. - * - * @return {Camera} */ disable(): this { this.enabled = false; @@ -81,8 +72,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to retrieve the camera's current state. - * - * @return {object} */ getState(): CameraState { return { @@ -95,8 +84,6 @@ export default class Camera extends TypedEventEmitter 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; @@ -104,8 +91,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to retrieve the camera's previous state. - * - * @return {object} */ getPreviousState(): CameraState | null { const state = this.previousState; @@ -122,9 +107,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to check minRatio and maxRatio values. - * - * @param ratio - * @return {number} */ getBoundedRatio(ratio: number): number { let r = ratio; @@ -135,9 +117,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to check various things to return a legit state candidate. - * - * @param state - * @return {object} */ validateState(state: Partial): Partial { const validatedState: Partial = {}; @@ -150,8 +129,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to check whether the camera is currently being animated. - * - * @return {boolean} */ isAnimated(): boolean { return !!this.nextFrame; @@ -159,15 +136,10 @@ export default class Camera extends TypedEventEmitter implements C /** * Method used to set the camera's state. - * - * @param {object} state - New state. - * @return {Camera} */ setState(state: Partial): this { if (!this.enabled) return this; - // TODO: update by function - // Keeping track of last state this.previousState = this.getState(); @@ -185,10 +157,6 @@ export default class Camera extends TypedEventEmitter 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): this { this.setState(updater(this.getState())); @@ -197,17 +165,22 @@ export default class Camera extends TypedEventEmitter 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, opts?: Partial, callback?: () => void): void { + animate(state: Partial, opts: Partial, callback: () => void): void; + animate(state: Partial, opts?: Partial): Promise; + animate( + state: Partial, + opts: Partial = {}, + callback?: () => void, + ): void | Promise { + 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 = @@ -257,57 +230,47 @@ export default class Camera extends TypedEventEmitter 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 & { 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 & { factor?: number })): Promise { + 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 & { 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 & { factor?: number })): Promise { + 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): void { - this.animate( + animatedReset(options?: Partial): Promise { + return this.animate( { x: 0.5, y: 0.5, @@ -320,8 +283,6 @@ export default class Camera extends TypedEventEmitter implements C /** * Returns a new Camera instance, with the same state as the current camera. - * - * @return {Camera} */ copy(): Camera { return Camera.from(this.getState()); diff --git a/packages/test/unit/sigma/camera.ts b/packages/test/unit/sigma/camera.ts index 53db3f4de..84ae0654a 100644 --- a/packages/test/unit/sigma/camera.ts +++ b/packages/test/unit/sigma/camera.ts @@ -1,6 +1,10 @@ import { Camera } from "sigma"; import { describe, expect, test } from "vitest"; +function wait(timeout: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeout)); +} + describe("Camera", function () { test("it should be possible to read the camera's state.", function () { const camera = new Camera(); @@ -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 () { @@ -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); + }); + }); });