Source: renderers/fractalRenderer.js

import {updateInfo} from "../ui/ui";
import {compareComplex, comparePalettes, hslToRgb, lerp, normalizeRotation, rgbToHsl} from "../global/utils";
import {DEBUG_MODE, DEFAULT_CONSOLE_GROUP_COLOR, EASE_TYPE, PI} from "../global/constants";

/**
 * FractalRenderer
 *
 * @author Radim Brnka
 * @description This module defines a fractalRenderer abstract class that encapsulates the WebGL code, including shader compilation, drawing, reset, and screenToFractal conversion. It also includes common animation methods.
 * @abstract
 */
export class FractalRenderer {

    /**
     * @param {HTMLCanvasElement} canvas
     */
    constructor(canvas) {
        this.canvas = canvas;
        this.gl = this.canvas.getContext('webgl', {
            antialias: false, // Already disabling anti-aliasing.
            alpha: false, // Disable alpha channel if not needed.
            depth: false, // Disable depth buffer.
            stencil: false, // Disable stencil buffer.
            preserveDrawingBuffer: false, // Do not preserve drawing buffer (faster).
            powerPreference: 'high-performance', // Hint for high-performance GPU.
            //failIfMajorPerformanceCaveat: true // Fail if forced to use software renderer.
        });

        if (!this.gl) {
            alert('WebGL is not supported by your browser or crashed.');
            return;
        }

        // Default values:
        this.DEFAULT_ROTATION = 0;
        this.DEFAULT_ZOOM = 3.0;
        /** @type {COMPLEX} */
        this.DEFAULT_PAN = [0, 0];
        this.DEFAULT_PALETTE = [1.0, 1.0, 1.0];
        this.MAX_ZOOM = 0.000017;
        this.MIN_ZOOM = 40;

        /** Interesting zoom-ins
         *  @type {Array.<PRESET>}
         */
        this.PRESETS = [];

        /**
         * Zoom. Lower number = higher zoom.
         * @type {number}
         */
        this.zoom = this.DEFAULT_ZOOM;

        /** @type {COMPLEX} */
        this.pan = [...this.DEFAULT_PAN]; // Copy

        /**
         * Rotation in rad
         * @type {number}
         */
        this.rotation = this.DEFAULT_ROTATION;

        this.currentPanAnimationFrame = null;
        this.currentZoomAnimationFrame = null;
        this.currentRotationAnimationFrame = null;
        this.currentColorAnimationFrame = null;

        this.demoActive = false;
        this.currentPresetIndex = 0;

        /**
         * Determines the level of fractal rendering detail
         * @type {number}
         */
        this.iterations = 0;
        this.extraIterations = 0;

        /** @type PALETTE */
        this.colorPalette = [...this.DEFAULT_PALETTE];

        /**  Vertex shader initialization snippet */
        this.vertexShaderSource = `
			precision mediump float;
			attribute vec4 a_position;
	   
			void main() {
				gl_Position = a_position;
			}
		`;

        this.canvas.addEventListener('webglcontextlost', this.onWebGLContextLost);
    }

    onWebGLContextLost(event) {
        event.preventDefault();
        console.warn(`%c ${this.constructor.name}: onWebGLContextLost %c WebGL context lost. Attempting to recover...`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        this.init(); // Reinitialize WebGL context
    }

    /** Destructor */
    destroy() {
        console.groupCollapsed(`%c ${this.constructor.name}: %c destroy`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        // Cancel any ongoing animations.
        this.stopAllNonColorAnimations();
        this.stopCurrentColorAnimations();

        // Remove event listeners from the canvas.
        if (this.canvas) {
            this.canvas.removeEventListener('webglcontextlost', this.onWebGLContextLost);
        }

        // Free WebGL resources.
        if (this.program) {
            this.gl.deleteProgram(this.program);
            this.program = null;
        }
        if (this.vertexShader) {
            this.gl.deleteShader(this.vertexShader);
            this.vertexShader = null;
        }
        if (this.fragmentShader) {
            this.gl.deleteShader(this.fragmentShader);
            this.fragmentShader = null;
        }

        this.canvas = null;
        this.gl = null;

        console.groupEnd();
    }

    //region > CONTROL METHODS -----------------------------------------------------------------------------------------

    generatePresetIDs() {
        this.PRESETS.forEach((preset, index) => {
            preset.id = index;
        });
    }

    /** WebGL init & initial uniforms setting */
    init() {
        this.generatePresetIDs();
        this.initGLProgram();  // Initialize WebGL program and uniforms
        this.draw();
    }

    /** Updates the canvas size based on the current visual viewport and redraws the fractal */
    resizeCanvas() {
        console.groupCollapsed(`%c ${this.constructor.name}: resizeCanvas`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        console.log(`Canvas before resize: ${this.canvas.width}x${this.canvas.height}`);

        this.gl.useProgram(this.program);

        // Use visual viewport if available, otherwise fallback to window dimensions.
        const vw = window.visualViewport ? window.visualViewport.width : window.innerWidth;
        const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;

        // Compute the center based on the visible viewport.
        const centerX = vw / 2;
        const centerY = vh / 2;

        // Get the device pixel ratio.
        const dpr = window.devicePixelRatio || 1;

        // Set the drawing-buffer size to match the visible viewport.
        this.canvas.width = Math.floor(vw * dpr);
        this.canvas.height = Math.floor(vh * dpr);

        // Set the CSS size to match the visible viewport.
        this.canvas.style.width = vw + "px";
        this.canvas.style.height = vh + "px";

        console.log(`Canvas after resize: ${this.canvas.width}x${this.canvas.height}`);

        // Update the WebGL viewport and the resolution uniform.
        if (this.gl) {
            this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
        }
        if (this.resolutionLoc) {
            this.gl.uniform2f(this.resolutionLoc, this.canvas.width, this.canvas.height);
        }

        const [fx, fy] = this.screenToFractal(centerX, centerY);
        this.pan[0] = fx;
        this.pan[1] = fy;

        this.draw();

        console.groupEnd();
    }

    /**
     * Defines the shader code for rendering the fractal shape
     *
     * @abstract
     */
    createFragmentShaderSource() {
        throw new Error('The draw method must be implemented in child classes');
    }

    /**
     * Compiles the shader code
     *
     * @param {string} source
     * @param {GLenum} type
     * @return {WebGLShader|null}
     */
    compileShader(source, type) {
        if (DEBUG_MODE) console.groupCollapsed(`%c ${this.constructor.name}: compileShader`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        if (DEBUG_MODE) console.log(`Shader GLenum type: ${type}`);
        if (DEBUG_MODE) console.log(`Shader code: ${source}`);

        this.gl.useProgram(this.program);

        const shader = this.gl.createShader(type);
        this.gl.shaderSource(shader, source);
        this.gl.compileShader(shader);

        if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
            console.error(this.gl.getShaderInfoLog(shader));
            this.gl.deleteShader(shader);
            if (DEBUG_MODE) console.groupEnd();
            return null;
        }
        if (DEBUG_MODE) console.groupEnd();

        return shader;
    }

    /**
     * Initializes the WebGL program, shaders and sets initial position
     */
    initGLProgram() {
        if (DEBUG_MODE) console.groupCollapsed(`%c ${this.constructor.name}: initGLProgram`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        if (this.program) this.gl.deleteProgram(this.program);
        if (this.fragmentShader) this.gl.deleteShader(this.fragmentShader);

        if (!this.vertexShader) {
            this.vertexShader = this.compileShader(this.vertexShaderSource, this.gl.VERTEX_SHADER);
        }
        this.fragmentShader = this.compileShader(this.createFragmentShaderSource(), this.gl.FRAGMENT_SHADER);

        this.program = this.gl.createProgram();
        this.gl.attachShader(this.program, this.vertexShader);
        this.gl.attachShader(this.program, this.fragmentShader);
        this.gl.linkProgram(this.program);

        if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
            console.error(this.gl.getProgramInfoLog(this.program));
        }
        this.gl.useProgram(this.program);

        // Set up a full-screen quad
        const positionBuffer = this.gl.createBuffer();
        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
        const positions = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
        this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
        const positionLoc = this.gl.getAttribLocation(this.program, 'a_position');
        this.gl.enableVertexAttribArray(positionLoc);
        this.gl.vertexAttribPointer(positionLoc, 2, this.gl.FLOAT, false, 0, 0);

        if (DEBUG_MODE) console.groupEnd();
    }

    /**
     * Updates uniforms (should be done on every redraw)
     */
    updateUniforms() {
        this.gl.useProgram(this.program);
        // Cache the uniform locations.
        this.panLoc = this.gl.getUniformLocation(this.program, 'u_pan');
        this.zoomLoc = this.gl.getUniformLocation(this.program, 'u_zoom');
        this.iterLoc = this.gl.getUniformLocation(this.program, 'u_iterations');
        this.colorLoc = this.gl.getUniformLocation(this.program, 'u_colorPalette');
        this.rotationLoc = this.gl.getUniformLocation(this.program, 'u_rotation');
        this.resolutionLoc = this.gl.getUniformLocation(this.program, 'u_resolution');
    }

    /**
     * Draws the fractal's and sets basic uniforms. Customize iterations number to determine level of detail.
     */
    draw() {
        this.gl.useProgram(this.program);

        const w = this.canvas.width;
        const h = this.canvas.height;

        // Update the viewport.
        this.gl.viewport(0, 0, w, h);

        if (this.resolutionLoc === undefined) {
            // Cache the resolution location if not already cached.
            this.resolutionLoc = this.gl.getUniformLocation(this.program, 'u_resolution');
        }
        if (this.resolutionLoc) {
            this.gl.uniform2f(this.resolutionLoc, w, h);
        }

        this.gl.uniform2fv(this.panLoc, this.pan);
        this.gl.uniform1f(this.zoomLoc, this.zoom);
        this.gl.uniform1f(this.rotationLoc, this.rotation);
        this.gl.uniform1f(this.iterLoc, this.iterations);
        this.gl.uniform3fv(this.colorLoc, this.colorPalette);

        this.updateUniforms();

        this.gl.clearColor(0, 0, 0, 1);
        this.gl.clear(this.gl.COLOR_BUFFER_BIT);
        this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
    }

    /**
     * Resets the fractal to its initial state (default pan, zoom, palette, rotation, etc.), resizes and redraws.
     */
    reset() {
        console.groupCollapsed(`%c ${this.constructor.name}: reset`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        this.stopAllNonColorAnimations();
        this.stopCurrentColorAnimations();

        this.colorPalette = [...this.DEFAULT_PALETTE];
        this.pan = [...this.DEFAULT_PAN];
        this.zoom = this.DEFAULT_ZOOM;
        this.rotation = this.DEFAULT_ROTATION;
        this.extraIterations = 0;
        this.currentPresetIndex = 0;
        this.resizeCanvas();
        this.draw();

        console.groupEnd();
        updateInfo();
    }

    /**
     * Calculates coordinates from screen point [x, y] to the fractal scale [x, yi]
     *
     * @param {number} screenX
     * @param {number} screenY
     * @returns {COMPLEX} fractal plane coords [x, yi]
     */
    screenToFractal(screenX, screenY) {
        const dpr = window.devicePixelRatio || 1;
        // Use the canvas's bounding rectangle for CSS dimensions.
        const rect = this.canvas.getBoundingClientRect();
        // Use the actual CSS size of the canvas.
        const w = rect.width * dpr;
        const h = rect.height * dpr;

        // Convert the screen (touch/mouse) coordinate to drawing-buffer pixels.
        const bufferX = screenX * dpr;
        const bufferY = screenY * dpr;

        // Normalize to [0,1]
        const normX = bufferX / w;
        const normY = bufferY / h;

        // In the shader, I subtract 0.5 and flip Y because gl_FragCoord.y starts from the bottom.
        let stX = normX - 0.5;
        let stY = (1 - normY) - 0.5;

        // Adjust x by the aspect ratio.
        const aspect = w / h;
        stX *= aspect;

        // Apply rotation correction (using the current fractalApp.rotation)
        const cosR = Math.cos(this.rotation);
        const sinR = Math.sin(this.rotation);
        const rotatedX = cosR * stX - sinR * stY;
        const rotatedY = sinR * stX + cosR * stY;

        // Map to fractal coordinates (using current zoom and pan)
        const fx = rotatedX * this.zoom + this.pan[0];
        const fy = rotatedY * this.zoom + this.pan[1];

        return [fx, fy];
    }

    // endregion--------------------------------------------------------------------------------------------------------
    // region > ANIMATION METHODS --------------------------------------------------------------------------------------

    /** Stops all currently running animations that are not a color transition */
    stopAllNonColorAnimations() {
        console.log(`%c ${this.constructor.name}: %c stopAllNonColorAnimations`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

        this.stopCurrentPanAnimation();
        this.stopCurrentZoomAnimation()
        this.stopCurrentRotationAnimation();
    }

    /** Stops currently running pan animation */
    stopCurrentPanAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentPanAnimation`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

        if (this.currentPanAnimationFrame !== null) {
            cancelAnimationFrame(this.currentPanAnimationFrame);
            this.currentPanAnimationFrame = null;
        }
    }

    /** Stops currently running zoom animation */
    stopCurrentZoomAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentZoomAnimation`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

        if (this.currentZoomAnimationFrame !== null) {
            cancelAnimationFrame(this.currentZoomAnimationFrame);
            this.currentZoomAnimationFrame = null;
        }
    }

    /** Stops currently running rotation animation */
    stopCurrentRotationAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentRotationAnimation`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

        if (this.currentRotationAnimationFrame !== null) {
            cancelAnimationFrame(this.currentRotationAnimationFrame);
            this.currentRotationAnimationFrame = null;
        }
    }

    /** Stops currently running color animation */
    stopCurrentColorAnimations() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentColorAnimation`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

        if (this.currentColorAnimationFrame !== null) {
            cancelAnimationFrame(this.currentColorAnimationFrame);
            this.currentColorAnimationFrame = null;
        }
    }

    /** Stops current demo and resets demo variables */
    stopDemo() {
        console.log(`%c ${this.constructor.name}: %c stopDemo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        this.demoActive = false;
        this.currentPresetIndex = 0;
        this.stopAllNonColorAnimations();
    }

    /** Default callback after every animation that requires on-screen info update */
    onAnimationFinished() {
        this.resizeCanvas();

        setTimeout(() => {
            updateInfo();
        }, 50);
    }

    /**
     * Smoothly transitions fractalApp.colorPalette from its current value
     * to the provided newPalette over the specified duration (in milliseconds).
     *
     * @param {PALETTE} newPalette - The target palette as [r, g, b] (each in [0,1]).
     * @param {number} [duration] - Duration of the transition in milliseconds.
     * @param {Function} [coloringCallback] A method called at every animation step
     * @return {Promise<void>}
     */
    async animateColorPaletteTransition(newPalette, duration = 250, coloringCallback = null) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateColorPaletteTransition`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentColorAnimations();

        if (comparePalettes(this.colorPalette, newPalette)) {
            console.warn(`Identical palette found. Skipping.`);
            return;
        }
        console.log(`Animating to ${newPalette}.`);

        const startPalette = [...this.colorPalette];

        await new Promise(resolve => {
            let startTime = null;

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;
                const progress = Math.min((timestamp - startTime) / duration, 1);

                // Interpolate each channel.
                this.colorPalette = [
                    lerp(startPalette[0], newPalette[0], progress),
                    lerp(startPalette[1], newPalette[1], progress),
                    lerp(startPalette[2], newPalette[2], progress)
                ];
                this.draw();

                if (coloringCallback) coloringCallback();

                if (progress < 1) {
                    this.currentColorAnimationFrame = requestAnimationFrame(step);
                } else {
                    this.stopCurrentColorAnimations();
                    console.groupEnd();
                    resolve();
                }
            };
            this.currentColorAnimationFrame = requestAnimationFrame(step);
        });
    }

    /**
     * Animates fractalApp.colorPalette by cycling through the entire color space.
     * The palette will continuously change hue from 0 to 360 degrees and starts from the current palette
     *
     * @param {number} [duration] - Duration (in milliseconds) for one full color cycle.
     * @param {Function} [coloringCallback]
     * @return {Promise<void>}
     */
    async animateFullColorSpaceCycle(duration = 15000, coloringCallback = null) {
        console.log(`%c ${this.constructor.name}: animateFullColorSpaceCycle`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentColorAnimations();

        const currentRGB = this.colorPalette;
        const hsl = rgbToHsl(currentRGB[0], currentRGB[1], currentRGB[2]);
        const startHue = hsl[0]; // starting hue in [0, 1]
        const fixedS = 1.0;
        const fixedL = 0.6;

        // Return a Promise that never resolves (continuous animation)
        await new Promise(() => {
            let startTime = null;

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;
                const progress = Math.min((timestamp - startTime) / duration, 1);

                const newHue = (startHue + progress) % 1;

                this.colorPalette = hslToRgb(newHue, fixedS, fixedL);
                this.draw();

                if (coloringCallback) coloringCallback();

                this.currentColorAnimationFrame = requestAnimationFrame(step);
            };
            this.currentColorAnimationFrame = requestAnimationFrame(step);
        });
    }

    /**
     * Animates pan from current position to the new one
     *
     * @param {COMPLEX} targetPan
     * @param [duration] in ms
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animatePanTo(targetPan, duration = 200, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentPanAnimation();

        if (compareComplex(this.pan, targetPan, 6)) {
            console.log(`Already at the target pan. Skipping.`);
            console.groupEnd();
            return;
        }
        console.log(`Panning to ${targetPan}.`);

        const startPan = [...this.pan];

        await new Promise(resolve => {
            let startTime = null;

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;
                const progress = Math.min((timestamp - startTime) / duration, 1);

                const easedProgress = easeFunction(progress);

                this.pan[0] = lerp(startPan[0], targetPan[0], easedProgress);
                this.pan[1] = lerp(startPan[1], targetPan[1], easedProgress);
                this.draw();

                updateInfo(true);

                if (easedProgress < 1) {
                    this.currentPanAnimationFrame = requestAnimationFrame(step);
                } else {
                    this.stopCurrentPanAnimation();
                    this.onAnimationFinished();
                    console.groupEnd();
                    resolve();
                }
            };
            this.currentPanAnimationFrame = requestAnimationFrame(step);
        });
    }

    /**
     * Animates to target zoom without panning.
     *
     * @param {number} targetZoom
     * @param {number} [duration] in ms
     * @param {EASE_TYPE|Function} easeFunction If none is provided, it defaults to exponential.
     * @return {Promise<void>}
     */
    async animateZoomTo(targetZoom, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateZoomTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentZoomAnimation();

        if (this.zoom.toFixed(6) === targetZoom.toFixed(6)) {
            console.log(`Already at the target zoom. Skipping.`);
            console.groupEnd();
            return;
        }
        console.log(`Zooming from ${this.zoom.toFixed(6)} to ${targetZoom.toFixed(6)}.`);

        const startZoom = this.zoom;

        await new Promise(resolve => {
            let startTime = null;

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;
                const progress = Math.min((timestamp - startTime) / duration, 1);

                if (easeFunction !== EASE_TYPE.NONE) {
                    const easedProgress = easeFunction(progress);
                    this.zoom = lerp(startZoom, targetZoom, easedProgress);
                } else {
                    this.zoom = startZoom * Math.pow(targetZoom / startZoom, progress); // Default to exponential
                }

                this.draw();

                updateInfo(true);

                if (progress < 1) {
                    this.currentZoomAnimationFrame = requestAnimationFrame(step);
                } else {
                    this.stopCurrentZoomAnimation();
                    this.onAnimationFinished();
                    console.groupEnd();
                    resolve();
                }
            };
            this.currentZoomAnimationFrame = requestAnimationFrame(step);
        });
    }

    /**
     * Animates to target rotation. Rotation is normalized into [0, 2*PI] interval
     *
     * @param {number} targetRotation
     * @param {number} [duration] in ms
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animateRotationTo(targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateRotationTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentRotationAnimation();

        // Normalize
        targetRotation = normalizeRotation(targetRotation);

        if (this.rotation.toFixed(6) === targetRotation.toFixed(6)) {
            console.log(`Already at the target rotation "${targetRotation}". Skipping.`);
            console.groupEnd();
            return;
        }
        console.log(`Rotating from ${this.rotation.toFixed(6)} to ${targetRotation.toFixed(6)}.`);

        const startRotation = this.rotation;

        await new Promise(resolve => {
            let startTime = null;

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;
                const progress = Math.min((timestamp - startTime) / duration, 1);
                const easedProgress = easeFunction(progress);

                this.rotation = lerp(startRotation, targetRotation, easedProgress);
                this.draw();

                updateInfo(true);

                if (progress < 1) {
                    this.currentRotationAnimationFrame = requestAnimationFrame(step);
                } else {
                    this.stopCurrentRotationAnimation();
                    this.onAnimationFinished();
                    console.groupEnd();
                    resolve();
                }
            };
            this.currentRotationAnimationFrame = requestAnimationFrame(step);
        });
    }

    /**
     * Animates sequential pan and then zooms into the target location.
     *
     * @param {COMPLEX} targetPan
     * @param {number} targetZoom
     * @param {number} panDuration in milliseconds
     * @param {number} zoomDuration in milliseconds
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animatePanThenZoomTo(targetPan, targetZoom, panDuration, zoomDuration, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanThenZoomTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        await this.animatePanTo(targetPan, panDuration, easeFunction);
        await this.animateZoomTo(targetZoom, zoomDuration, easeFunction);

        console.groupEnd();
    }

    /**
     * Animates pan and zoom simultaneously.
     *
     * @param {COMPLEX} targetPan
     * @param {number} targetZoom
     * @param {number} [duration] in milliseconds
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animatePanAndZoomTo(targetPan, targetZoom, duration = 1000, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanAndZoomTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        await Promise.all([
            this.animatePanTo(targetPan, duration, easeFunction),
            this.animateZoomTo(targetZoom, duration, easeFunction)
        ]);

        console.groupEnd();
    }

    /**
     * Animates to target zoom and rotation simultaneously. Rotation is normalized into [0, 2*PI] interval
     *
     * @param {number} targetZoom
     * @param {number} targetRotation
     * @param {number} [duration] in ms
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animateZoomRotationTo(targetZoom, targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateZoomRotationTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        await Promise.all([
            this.animateZoomTo(targetZoom, duration, easeFunction),
            this.animateRotationTo(targetRotation, duration, easeFunction)
        ]);

        console.groupEnd();
    }

    /**
     * Animates pan, zoom and rotation simultaneously
     *
     * @param {COMPLEX} targetPan
     * @param {number} targetZoom
     * @param {number} targetRotation
     * @param {number} [duration] in milliseconds
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animatePanZoomRotationTo(targetPan, targetZoom, targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanZoomRotationTo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

        await Promise.all([
            this.animatePanTo(targetPan, duration, easeFunction),
            this.animateZoomTo(targetZoom, duration, easeFunction),
            this.animateRotationTo(targetRotation, duration, easeFunction)
        ]);

        console.groupEnd();
    }

    /**
     *
     * @param {ROTATION_DIRECTION} direction
     * @param {number} step Speed in rad/frame
     * @return {Promise<void>}
     */
    async animateInfiniteRotation(direction, step = 0.001) {
        console.log(`%c ${this.constructor.name}: animateInfiniteRotation`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        this.stopCurrentRotationAnimation();

        const dir = direction >= 0 ? 1 : -1; // Normalize

        await new Promise(() => {

            const rotationStep = () => {
                this.rotation = normalizeRotation(this.rotation + dir * step + 2 * PI);
                this.draw();

                updateInfo(true);

                this.currentRotationAnimationFrame = requestAnimationFrame(rotationStep);
            };

            this.currentRotationAnimationFrame = requestAnimationFrame(rotationStep);
        });
        console.groupEnd();
    }

    /**
     * Animates travel to preset.
     * @abstract
     * @param {PRESET} preset - Parameters for the animation.
     * @param {number} duration - Parameters for the animation.
     * @return {Promise<void>}
     */
    async animateTravelToPreset(preset, duration) {
        throw new Error('The animateTravelToPreset method must be implemented in child classes');
    }

    /**
     * Animate travel to a preset with random rotation. This method waits for three stages:
     *   1. Zoom-out with rotation.
     *   2. Pan transition.
     *   3. Zoom-in with rotation.
     *
     * @abstract
     * @param {PRESET} preset - The target preset object with properties: pan, c, zoom, rotation.
     * @param {number} zoomOutDuration - Duration (ms) for the zoom-out stage.
     * @param {number} panDuration - Duration (ms) for the pan stage.
     * @param {number} zoomInDuration - Duration (ms) for the zoom-in stage.
     */
    async animateTravelToPresetWithRandomRotation(preset, zoomOutDuration, panDuration, zoomInDuration) {
        throw new Error('The animateTravelToPreset method must be implemented in child classes');
    }

    // endregion--------------------------------------------------------------------------------------------------------
}