Source: renderers/fractalRenderer.js

import {debugPanel, updateInfo} from "../ui/ui";
import {
    compareComplex,
    comparePalettes,
    ddAdd,
    ddMake,
    ddSet,
    ddValue,
    hslToRgb,
    lerp,
    normalizeRotation,
    rgbToHsl
} from "../global/utils";
import {
    ADAPTIVE_QUALITY_COOLDOWN,
    ADAPTIVE_QUALITY_MIN,
    ADAPTIVE_QUALITY_STEP,
    ADAPTIVE_QUALITY_THRESHOLD_HIGH,
    ADAPTIVE_QUALITY_THRESHOLD_LOW,
    CONSOLE_GROUP_STYLE,
    CONSOLE_MESSAGE_STYLE,
    DEBUG_MODE,
    EASE_TYPE,
    FF_ADAPTIVE_QUALITY,
    FF_DEMO_ALWAYS_RESETS,
    log,
    PI
} from "../global/constants";
import vertexFragmentShaderRaw from '../shaders/vertexShaderInit.vert';
import Renderer from "./renderer";

/**
 * FractalRenderer
 *
 * @author Radim Brnka (c) 2025-2026
 * @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.
 * @copyright Synaptory Fractal Traveler, 2025-2026
 * @license MIT
 * @abstract
 */
class FractalRenderer extends Renderer {

    /**
     * @param {HTMLCanvasElement} canvas
     */
    constructor(canvas) {
        super(canvas);

        // Defaults:
        this.MAX_ITER = 2000;

        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 = 1e-80;
        this.MIN_ZOOM = 40;

        /**
         * Interesting points / details / zooms / views
         * @type {Array.<PRESET>}
         */
        this.PRESETS = [];

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

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

        /** DD accumulator mirrors into this.pan */
        this.panDD = {
            x: ddMake(this.pan[0], 0),
            y: ddMake(this.pan[1], 0),
        };

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

        this.currentPanAnimationFrame = null;
        this.currentZoomAnimationFrame = null;
        this.currentRotationAnimationFrame = null;
        this.currentColorAnimationFrame = null;
        /** Resolve callback for pending color animation (to handle interruption) */
        this._colorAnimationResolve = null;

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

        this.interactionActive = false;
        this.interactionTimer = null;

        /** @type {number} */
        this.iterations = 0;
        this.extraIterations = 0;

        /** Timestamp of last adaptive quality adjustment */
        this.adaptiveQualityLastAdjustment = 0;

        /** Runtime toggle for adaptive quality (initialized from constant) */
        this.adaptiveQualityEnabled = FF_ADAPTIVE_QUALITY;

        /** Runtime adjustable min iterations offset (initialized from constant) */
        this.adaptiveQualityMin = ADAPTIVE_QUALITY_MIN;

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

        // Uniform dirty tracking - only upload uniforms when values change
        this._uniformCache = {
            resW: -1,
            resH: -1,
            pan0: NaN,
            pan1: NaN,
            zoom: NaN,
            rotation: NaN,
            iterations: NaN,
            color0: NaN,
            color1: NaN,
            color2: NaN,
        };

        /** Vertex shader */
        this.vertexShaderSource = vertexFragmentShaderRaw;
    }

    /**
     * Hook for perturbation renderers to request orbit rebuild.
     * No-op by default.
     */
    markOrbitDirty() {}

    /**
     * Invalidates the uniform cache, forcing all uniforms to be re-uploaded on next draw().
     * Called after GL program creation/recreation.
     */
    invalidateUniformCache() {
        this._uniformCache.resW = -1;
        this._uniformCache.resH = -1;
        this._uniformCache.pan0 = NaN;
        this._uniformCache.pan1 = NaN;
        this._uniformCache.zoom = NaN;
        this._uniformCache.rotation = NaN;
        this._uniformCache.iterations = NaN;
        this._uniformCache.color0 = NaN;
        this._uniformCache.color1 = NaN;
        this._uniformCache.color2 = NaN;
    }

    // --------- Pan API (use these; they keep DD + array in sync) ---------

    /** @returns {number[]} the canonical array */
    getPan() {
        return [this.pan[0], this.pan[1]];
    }

    /**
     * Sets the pan values for the object.
     *
     * @param {number} x - The horizontal pan value.
     * @param {number} y - The vertical pan value.
     * @return {void}
     */
    setPan(x, y) {
        ddSet(this.panDD.x, x);
        ddSet(this.panDD.y, y);
        this.pan[0] = ddValue(this.panDD.x);
        this.pan[1] = ddValue(this.panDD.y);
    }

    /**
     * Adjusts the pan values by adding the specified deltas to the current pan values.
     *
     * @param {number} dx - The change in the x-direction to be added to the pan.
     * @param {number} dy - The change in the y-direction to be added to the pan.
     * @return {void}
     */
    addPan(dx, dy) {
        ddAdd(this.panDD.x, dx);
        ddAdd(this.panDD.y, dy);
        this.pan[0] = ddValue(this.panDD.x);
        this.pan[1] = ddValue(this.panDD.y);
    }

    /**
     * Set pan so that fractal point (fxAnchor,fyAnchor) remains under a screen point
     * whose view vector is (vx,vy).
     */
    setPanFromAnchor(fxAnchor, fyAnchor, vx, vy) {
        const px = fxAnchor - vx * this.zoom;
        const py = fyAnchor - vy * this.zoom;
        this.setPan(px, py);
    }

    /**
     * Sets zoom while keeping the fractal point under a given screen anchor fixed.
     * Uses DD-preserving arithmetic to avoid precision loss at deep zoom.
     *
     * @param {number} targetZoom
     * @param {number} anchorX CSS px relative to canvas
     * @param {number} anchorY CSS px relative to canvas
     */
    setZoomKeepingAnchor(targetZoom, anchorX, anchorY) {
        const [vx, vy] = this.screenToViewVector(anchorX, anchorY);
        const deltaZoom = this.zoom - targetZoom;

        this.addPan(vx * deltaZoom, vy * deltaZoom);
        this.zoom = targetZoom;
    }

    /**
     * Marks the app as "in interaction" (drag/wheel/key-pan) and schedules a settle phase.
     * Perturbation renderers can use this to defer expensive orbit rebuilds until the user stops moving.
     *
     * @param {number} settleMs
     */
    noteInteraction(settleMs = 160) {
        this.interactionActive = true;
        this._lastInteractionTime = performance.now();

        if (this.interactionTimer) {
            clearTimeout(this.interactionTimer);
            this.interactionTimer = null;
        }

        this.interactionTimer = setTimeout(() => {
            this.interactionActive = false;

            // Request a clean rebuild at rest for perturbation renderers (safe no-op otherwise)
            this.markOrbitDirty();

            // One clean redraw at settle time (prevents “swim” and removes lingering error)
            this.draw();
            updateInfo(true);
        }, settleMs);
    }

    /** Destructor */
    destroy() {
        console.groupCollapsed(`%c ${this.constructor.name}: %c destroy`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        // 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;
        }

        if (this.interactionTimer) {
            clearTimeout(this.interactionTimer);
            this.interactionTimer = null;
        }

        super.destroy();

        console.groupEnd();
    }

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

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

    init() {
        this.generatePresetIDs();
        this.initGLProgram();
        this.draw();
    }

    resizeCanvas() {
        log(`resizeCanvas`, this.constructor.name);

        this.gl.useProgram(this.program);

        // Keep the center fixed
        const oldRect = this.canvas.getBoundingClientRect();
        const cx = oldRect.width / 2;
        const cy = oldRect.height / 2;

        const [centerFx, centerFy] = this.screenToFractal(cx, cy);

        const dpr = window.devicePixelRatio || 1;
        this.canvas.width = Math.floor(oldRect.width * dpr);
        this.canvas.height = Math.floor(oldRect.height * dpr);

        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 [vx, vy] = this.screenToViewVector(cx, cy);
        this.setPanFromAnchor(centerFx, centerFy, vx, vy);

        // After resizing, request a clean rebuild for perturbation renderers (safe no-op otherwise)
        this.markOrbitDirty();
        this.draw();
    }


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

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

        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 WebGL program, shaders, quad, and uniform caches.
     */
    initGLProgram() {
        if (DEBUG_MODE) console.groupCollapsed(`%c ${this.constructor.name}:%c initGLProgram`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

        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);

        // 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);

        this.updateUniforms();
        this.invalidateUniformCache();

        if (typeof this.onProgramCreated === "function") {
            this.onProgramCreated();
        }

        if (DEBUG_MODE) console.groupEnd();
    }

    /** @abstract */
    onProgramCreated() {
        throw new Error("The onProgramCreated method must be implemented in child classes");
    }

    /**
     * kept for compatibility; perturbation renderers may ignore
     * @abstract
     */
    needsRebase() {
        throw new Error("The needsRebase method must be implemented in child classes");
    }

    /**
     * Cache common uniforms used by all renderers
     */
    updateUniforms() {
        this.gl.useProgram(this.program);
        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 and sets basic uniforms.
     * Subclasses can override draw() but must call super.draw() for viewport+common uniforms+draw call.
     * Uses dirty checking to avoid redundant uniform uploads.
     */
    draw() {
        this.gl.useProgram(this.program);

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

        this.gl.viewport(0, 0, w, h);

        // Only upload uniforms that have changed
        if (this.resolutionLoc && (uc.resW !== w || uc.resH !== h)) {
            this.gl.uniform2f(this.resolutionLoc, w, h);
            uc.resW = w;
            uc.resH = h;
        }

        if (this.panLoc && (uc.pan0 !== this.pan[0] || uc.pan1 !== this.pan[1])) {
            this.gl.uniform2fv(this.panLoc, this.pan);
            uc.pan0 = this.pan[0];
            uc.pan1 = this.pan[1];
        }

        if (this.zoomLoc && uc.zoom !== this.zoom) {
            this.gl.uniform1f(this.zoomLoc, this.zoom);
            uc.zoom = this.zoom;
        }

        if (this.rotationLoc && uc.rotation !== this.rotation) {
            this.gl.uniform1f(this.rotationLoc, this.rotation);
            uc.rotation = this.rotation;
        }

        if (this.iterLoc && uc.iterations !== this.iterations) {
            this.gl.uniform1f(this.iterLoc, this.iterations);
            uc.iterations = this.iterations;
        }

        if (this.colorLoc && (uc.color0 !== this.colorPalette[0] ||
            uc.color1 !== this.colorPalette[1] ||
            uc.color2 !== this.colorPalette[2])) {
            this.gl.uniform3fv(this.colorLoc, this.colorPalette);
            uc.color0 = this.colorPalette[0];
            uc.color1 = this.colorPalette[1];
            uc.color2 = this.colorPalette[2];
        }

        this.gl.clearColor(0, 0, 0, 1);
        this.gl.clear(this.gl.COLOR_BUFFER_BIT);
        debugPanel?.beginGpuTimer();
        this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
        debugPanel?.endGpuTimer();
        this.adjustAdaptiveQuality();
    }

    /**
     * Adjusts extraIterations based on GPU performance metrics.
     * Called after each frame when adaptive quality is enabled.
     */
    adjustAdaptiveQuality() {
        if (!this.adaptiveQualityEnabled) return;
        if (!debugPanel?.perf) return;

        const gpuMs = debugPanel.perf.gpuMsSmoothed;
        if (!Number.isFinite(gpuMs)) return;

        // Don't adjust during active interaction (avoid jarring quality shifts while dragging)
        if (this.interactionActive) return;

        // Cooldown between adjustments
        const now = performance.now();
        if (now - this.adaptiveQualityLastAdjustment < ADAPTIVE_QUALITY_COOLDOWN) return;

        // Reduce quality if GPU time too high
        if (gpuMs > ADAPTIVE_QUALITY_THRESHOLD_HIGH) {
            const newExtra = Math.max(this.adaptiveQualityMin, this.extraIterations - ADAPTIVE_QUALITY_STEP);
            if (newExtra !== this.extraIterations) {
                this.extraIterations = newExtra;
                this.adaptiveQualityLastAdjustment = now;
            }
        }
        // Restore quality if GPU time is comfortable and we're below baseline
        else if (gpuMs < ADAPTIVE_QUALITY_THRESHOLD_LOW && this.extraIterations < 0) {
            const newExtra = Math.min(0, this.extraIterations + ADAPTIVE_QUALITY_STEP);
            if (newExtra !== this.extraIterations) {
                this.extraIterations = newExtra;
                this.adaptiveQualityLastAdjustment = now;
            }
        }
    }

    /**
     * Toggles adaptive quality on/off.
     * When turning off, resets extraIterations to 0.
     */
    toggleAdaptiveQuality() {
        this.adaptiveQualityEnabled = !this.adaptiveQualityEnabled;
        if (!this.adaptiveQualityEnabled) {
            this.extraIterations = 0;
        }
        log(`Adaptive quality: ${this.adaptiveQualityEnabled ? 'ON' : 'OFF'}`, this.constructor.name);
        this.draw();
    }

    /**
     * Adjusts the minimum iterations offset for adaptive quality.
     * @param {number} delta - Amount to change (positive = less aggressive, negative = more aggressive)
     */
    adjustAdaptiveQualityMin(delta) {
        const oldMin = this.adaptiveQualityMin;
        // Clamp between -3000 (very aggressive) and -100 (minimal)
        this.adaptiveQualityMin = Math.max(-3000, Math.min(-100, this.adaptiveQualityMin + delta));
        // Also clamp current extraIterations to new min
        if (this.extraIterations < this.adaptiveQualityMin) {
            this.extraIterations = this.adaptiveQualityMin;
        }
        log(`Adaptive quality min: ${oldMin} → ${this.adaptiveQualityMin} (delta: ${delta > 0 ? '+' : ''}${delta})`, this.constructor.name);
        this.draw();
    }

    /**
     * Directly adjusts extraIterations for manual quality control.
     * Useful for finding the right adaptiveQualityMin limit or boosting quality.
     * @param {number} delta - Amount to change (positive = more iterations, negative = fewer)
     */
    adjustExtraIterations(delta) {
        const oldExtra = this.extraIterations;
        // Clamp between adaptiveQualityMin and its positive counterpart
        const maxExtra = Math.abs(this.adaptiveQualityMin);
        this.extraIterations = Math.max(this.adaptiveQualityMin, Math.min(maxExtra, this.extraIterations + delta));
        if (oldExtra !== this.extraIterations) {
            log(`extraIterations: ${oldExtra} → ${this.extraIterations} (delta: ${delta > 0 ? '+' : ''}${delta})`, this.constructor.name);
            this.draw();
        }
    }

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

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

        this.colorPalette = [...this.DEFAULT_PALETTE];
        this.setPan(this.DEFAULT_PAN[0], this.DEFAULT_PAN[1]);
        this.zoom = this.DEFAULT_ZOOM;
        this.rotation = this.DEFAULT_ROTATION;
        this.extraIterations = 0;
        this.currentPresetIndex = 0;

        this.resizeCanvas();

        this.markOrbitDirty();
        this.draw();

        console.groupEnd();
        updateInfo();
    }

    /**
     * Screen point -> fractal coordinates (float64 JS side; fine for UI)
     * @param {number} screenX
     * @param {number} screenY
     * @returns {COMPLEX}
     */
    screenToFractal(screenX, screenY) {
        const dpr = window.devicePixelRatio || 1;
        const rect = this.canvas.getBoundingClientRect();
        const w = rect.width * dpr;
        const h = rect.height * dpr;

        const bufferX = screenX * dpr;
        const bufferY = screenY * dpr;

        const normX = bufferX / w;
        const normY = bufferY / h;

        let stX = normX - 0.5;
        let stY = (1 - normY) - 0.5;

        const aspect = w / h;
        stX *= aspect;

        const cosR = Math.cos(this.rotation);
        const sinR = Math.sin(this.rotation);
        const rotatedX = cosR * stX - sinR * stY;
        const rotatedY = sinR * stX + cosR * stY;

        return [rotatedX * this.zoom + this.pan[0], rotatedY * this.zoom + this.pan[1]];
    }

    /**
     * Returns the pan delta needed to center on a screen point.
     * This avoids precision loss at deep zoom by returning only the offset,
     * which can be applied via addPan() to preserve DD precision.
     *
     * @param {number} screenX CSS px relative to canvas
     * @param {number} screenY CSS px relative to canvas
     * @returns {COMPLEX} [deltaX, deltaY] offset from current pan
     */
    screenToPanDelta(screenX, screenY) {
        const [vx, vy] = this.screenToViewVector(screenX, screenY);
        return [vx * this.zoom, vy * this.zoom];
    }

    /**
     * Convert a screen point to the rotated, aspect-corrected "view vector" (no pan/zoom applied).
     * This is exactly what your shader calls `rotated`.
     *
     * @param {number} screenX CSS pixels relative to canvas (like your handlers use)
     * @param {number} screenY CSS pixels relative to canvas
     * @returns {number[]} view-space rotated vector
     */
    screenToViewVector(screenX, screenY) {
        const dpr = window.devicePixelRatio || 1;
        const rect = this.canvas.getBoundingClientRect();
        const w = rect.width * dpr;
        const h = rect.height * dpr;

        const bufferX = screenX * dpr;
        const bufferY = screenY * dpr;

        const normX = bufferX / w;
        const normY = bufferY / h;

        let stX = normX - 0.5;
        let stY = (1 - normY) - 0.5;

        const aspect = w / h;
        stX *= aspect;

        const cosR = Math.cos(this.rotation);
        const sinR = Math.sin(this.rotation);

        return [cosR * stX - sinR * stY, sinR * stX + cosR * stY];
    }

    /**
     * Compute CSS-space center of the canvas (for default anchored zoom).
     * @returns {number[]}
     */
    getCanvasCssCenter() {
        const rect = this.canvas.getBoundingClientRect();
        return [rect.width / 2, rect.height / 2];
    }

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

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

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

    /** Stops currently running pan animation */
    stopCurrentPanAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentPanAnimation`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

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

    /** Stops currently running zoom animation */
    stopCurrentZoomAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentZoomAnimation`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

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

    /** Stops currently running rotation animation */
    stopCurrentRotationAnimation() {
        console.log(`%c ${this.constructor.name}: %c stopCurrentRotationAnimation`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

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

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

        // Track if we're stopping an active cycle
        const wasActivelyCycling = this.paletteCyclingActive && !this._inPaletteCycleTransition;

        // Stop palette cycling if active (but not during internal palette transition)
        if (this.paletteCyclingActive && !this._inPaletteCycleTransition) {
            this.paletteCyclingActive = false;
            if (this.paletteCyclingTimeoutId) {
                clearTimeout(this.paletteCyclingTimeoutId);
                this.paletteCyclingTimeoutId = null;
            }
        }

        if (this.currentColorAnimationFrame !== null) {
            // Handle both requestAnimationFrame and setTimeout
            cancelAnimationFrame(this.currentColorAnimationFrame);
            clearTimeout(this.currentColorAnimationFrame);
            this.currentColorAnimationFrame = null;
        }

        // Resolve any pending color animation promise so Promise.all doesn't hang
        if (this._colorAnimationResolve) {
            this._colorAnimationResolve();
            this._colorAnimationResolve = null;
        }

        // Update UI button state if cycling was stopped
        if (wasActivelyCycling && typeof window !== 'undefined') {
            const cycleBtn = document.getElementById('palette-cycle');
            if (cycleBtn) {
                cycleBtn.classList.remove('active');
            }
        }
    }

    /** Stops current demo and resets demo variables */
    stopDemo() {
        console.log(`%c ${this.constructor.name}: %c stopDemo`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        this.demoActive = false;
        if (FF_DEMO_ALWAYS_RESETS) this.currentPresetIndex = 0;
        this.stopAllNonColorAnimations();
    }

    /** Default callback after every animation that requires on-screen info update */
    onAnimationFinished() {
        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`, CONSOLE_GROUP_STYLE);
        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) => {
            // Store resolve so stopCurrentColorAnimations can call it if interrupted
            this._colorAnimationResolve = resolve;
            let startTime = null;

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

                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._colorAnimationResolve = null;
                    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`, CONSOLE_GROUP_STYLE);
        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);
        });
    }

    /**
     * Starts continuous sequential palette cycling.
     * Smoothly transitions through all palettes in order, looping indefinitely.
     *
     * @param {number} [transitionDuration=2000] - Duration for each palette transition in ms.
     * @param {number} [holdDuration=3000] - Duration to hold each palette before transitioning.
     * @param {Function} [coloringCallback] - Callback during transitions (called per frame).
     * @param {Function} [onPaletteComplete] - Callback when each palette transition completes.
     * @return {Promise<void>}
     */
    async startPaletteCycling(transitionDuration = 2000, holdDuration = 3000, coloringCallback = null, onPaletteComplete = null) {
        console.log(`%c ${this.constructor.name}: startPaletteCycling`, CONSOLE_GROUP_STYLE);
        this.stopCurrentColorAnimations();

        if (!this.PALETTES || this.PALETTES.length === 0) {
            console.warn('No palettes available for cycling');
            return;
        }

        // Use a cycling flag since applyPaletteByIndex clears currentColorAnimationFrame
        this.paletteCyclingActive = true;

        // Update UI button state
        if (typeof window !== 'undefined') {
            const cycleBtn = document.getElementById('palette-cycle');
            if (cycleBtn && !cycleBtn.classList.contains('active')) {
                cycleBtn.classList.add('active');
            }
        }

        // Start from current palette or 0
        let nextIndex = (this.currentPaletteIndex >= 0 ? this.currentPaletteIndex + 1 : 1) % this.PALETTES.length;

        const cycleNext = async () => {
            if (!this.paletteCyclingActive) return; // Stopped

            // Apply next palette with transition (protect cycling flag during transition)
            this._inPaletteCycleTransition = true;
            await this.applyPaletteByIndex(nextIndex, transitionDuration, coloringCallback);
            this._inPaletteCycleTransition = false;

            // Notify that palette transition completed
            if (this.paletteCyclingActive && onPaletteComplete) {
                onPaletteComplete();
            }

            if (!this.paletteCyclingActive) return; // Stopped during transition

            // Move to next palette
            nextIndex = (nextIndex + 1) % this.PALETTES.length;

            // Schedule next cycle after hold duration
            this.paletteCyclingTimeoutId = setTimeout(() => {
                if (this.paletteCyclingActive) {
                    cycleNext();
                }
            }, holdDuration);
        };

        // Mark as active (for stopCurrentColorAnimations check) and start
        this.currentColorAnimationFrame = 1; // Non-null marker
        await cycleNext();
    }

    /**
     * Transitions between palettes by their unique identifiers, animating the change over the specified duration.
     *
     * @param {PRESET} preset - The configuration object containing the palette definition.
     * @param {string} preset.paletteId - The unique identifier for the target palette.
     * @param {number} duration - The duration of the animation in milliseconds.
     * @param {function} coloringCallback
     * @return {Promise<void>} Resolves once the palette transition animation is complete.
     */
    async animatePaletteByIdTransition(preset, duration, coloringCallback = null) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePaletteByIdTransition`, CONSOLE_GROUP_STYLE);
        if (preset.paletteId) {
            log(`Preset palette definition found: "${preset.paletteId}"`);
            const paletteIndex = this.PALETTES.findIndex(p => p.id === preset.paletteId);
            if (paletteIndex >= 0) {
                await this.applyPaletteByIndex(paletteIndex, duration, coloringCallback);
            } else {
                console.warn(`Palette "${preset.paletteId}" not found in PALETTES`);
            }
        } else {
            log(`Preset palette definition not found, keeping current palette.`);
        }
        console.groupEnd();
    }

    /**
     * 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`, CONSOLE_GROUP_STYLE);
        this.stopCurrentPanAnimation();

        this.markOrbitDirty();

        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 t = Math.min((timestamp - startTime) / duration, 1);

                const k = easeFunction(t);

                const nx = lerp(startPan[0], targetPan[0], k);
                const ny = lerp(startPan[1], targetPan[1], k);
                this.setPan(nx, ny);

                this.draw();
                updateInfo(true);

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

    /**
     * Animates pan by a delta (DD-stable). Unlike animatePanTo, this avoids computing
     * targetPan = startPan + delta in a single float64 operation (which breaks in deep zoom).
     *
     * This method applies incremental DD pan updates based on animation progress.
     *
     * @param {COMPLEX} deltaPan
     * @param [duration] in ms
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise}
     */
    async animatePanBy(deltaPan, duration = 200, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanBy`, CONSOLE_GROUP_STYLE);
        this.stopCurrentPanAnimation();

        this.markOrbitDirty();

        if (Math.abs(deltaPan[0]) < 1e-30 && Math.abs(deltaPan[1]) < 1e-30) {
            console.log(`Zero delta pan. Skipping.`);
            console.groupEnd();
            return;
        }

        console.log(`Panning by ${deltaPan}.`);

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

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;

                const t = Math.min((timestamp - startTime) / duration, 1);
                const k = easeFunction(t);

                const dk = k - prevK;
                prevK = k;

                if (dk !== 0) {
                    this.addPan(deltaPan[0] * dk, deltaPan[1] * dk);
                }

                this.draw();
                updateInfo(true);

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

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

    /**
     * Animates zoom while keeping the fractal point under an anchor screen coordinate fixed.
     *
     * @param {number} targetZoom
     * @param {number} [duration]
     * @param {EASE_TYPE|Function} easeFunction
     * @param {number|null} [anchorX] CSS px relative to canvas; defaults to canvas center
     * @param {number|null} [anchorY] CSS px relative to canvas; defaults to canvas center
     */
    async animateZoomTo(targetZoom, duration = 500, easeFunction = EASE_TYPE.NONE, anchorX = null, anchorY = null) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateZoomTo`, CONSOLE_GROUP_STYLE);
        this.stopCurrentZoomAnimation();

        if (this.zoom.toFixed(20) === targetZoom.toFixed(20)) {
            console.log(`Already at the target zoom. Skipping.`);
            console.groupEnd();
            return;
        }

        // Default anchor = canvas center (CSS)
        if (anchorX === null || anchorY === null) {
            const [cx, cy] = this.getCanvasCssCenter();
            anchorX = cx;
            anchorY = cy;
        }

        const startZoom = this.zoom;
        const ratio = targetZoom / startZoom;

        // Compute anchor once. During zoom animation, rotation is constant, so view-vector stays valid.
        const [vx, vy] = this.screenToViewVector(anchorX, anchorY);
        const fxAnchor = vx * startZoom + this.pan[0];
        const fyAnchor = vy * startZoom + this.pan[1];

        // orbit boundary policy hook (safe no-op for non-perturbation renderers)
        this.markOrbitDirty();

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

            const step = (timestamp) => {
                if (!startTime) startTime = timestamp;

                const t = Math.min((timestamp - startTime) / duration, 1);

                if (easeFunction !== EASE_TYPE.NONE) {
                    const k = easeFunction(t);
                    this.zoom = startZoom + (targetZoom - startZoom) * k;
                } else {
                    this.zoom = startZoom * Math.pow(ratio, t);
                }

                // Recompute pan from the fixed anchor point (stable in deep zoom)
                this.setPanFromAnchor(fxAnchor, fyAnchor, vx, vy);

                this.markOrbitDirty();
                this.draw();
                updateInfo(true);

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

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

    /**
     * Animates zoom from current value to target zoom without adjusting pan position.
     * Unlike animateZoomTo, this method does not keep any anchor point fixed during zooming.
     * The fractal will appear to zoom in/out from its current center position.
     *
     * @param {number} targetZoom - The target zoom level to animate to
     * @param {number} [duration=500] - Duration of the animation in milliseconds
     * @param {EASE_TYPE|Function} [easeFunction=EASE_TYPE.NONE] - Easing function to apply; EASE_TYPE.NONE uses exponential interpolation
     * @returns {Promise<void>} Promise that resolves when the animation completes
     */
    async animateZoomToNoPan(targetZoom, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateZoomToNoPan`, CONSOLE_GROUP_STYLE);

        this.stopCurrentZoomAnimation();

        this.markOrbitDirty();

        if (this.zoom.toFixed(20) === targetZoom.toFixed(20)) {
            console.log(`Already at the target zoom. Skipping.`);
            console.groupEnd();
            return;
        }

        const startZoom = this.zoom;

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

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

                if (easeFunction !== EASE_TYPE.NONE) {
                    const k = easeFunction(t);
                    this.zoom = startZoom + (targetZoom - startZoom) * k;
                } else {
                    this.zoom = startZoom * Math.pow(targetZoom / startZoom, t);
                }

                this.draw();
                updateInfo(true);

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

    async animateRotationTo(targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateRotationTo`, CONSOLE_GROUP_STYLE);
        this.stopCurrentRotationAnimation();

        // Compare actual values (not normalized) to allow multi-spin animations
        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 t = Math.min((timestamp - startTime) / duration, 1);

                const k = easeFunction(t);

                this.rotation = lerp(startRotation, targetRotation, k);
                this.draw();
                updateInfo(true);

                if (t < 1) {
                    this.currentRotationAnimationFrame = requestAnimationFrame(step);
                } else {
                    // Normalize final rotation to keep it in valid range
                    this.rotation = normalizeRotation(this.rotation);
                    this.draw();
                    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`, CONSOLE_GROUP_STYLE);

        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`, CONSOLE_GROUP_STYLE);

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

        console.groupEnd();
    }

    /**
     * Animates pan by delta and zoom simultaneously.
     * Uses addPan() internally to preserve DD precision at deep zoom levels.
     *
     * @param {COMPLEX} deltaPan - offset to add to current pan
     * @param {number} targetZoom
     * @param {number} [duration] in milliseconds
     * @param {EASE_TYPE|Function} easeFunction
     * @return {Promise<void>}
     */
    async animatePanByAndZoomTo(deltaPan, targetZoom, duration = 1000, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanByAndZoomTo`, CONSOLE_GROUP_STYLE);

        await Promise.all([
            this.animatePanBy(deltaPan, duration, easeFunction),
            this.animateZoomToNoPan(targetZoom, duration, easeFunction),
        ]);

        console.groupEnd();
    }

    async animateZoomRotationTo(targetZoom, targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateZoomRotationTo`, CONSOLE_GROUP_STYLE);

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

        console.groupEnd();
    }

    async animatePanZoomRotationTo(targetPan, targetZoom, targetRotation, duration = 500, easeFunction = EASE_TYPE.NONE) {
        console.groupCollapsed(`%c ${this.constructor.name}: animatePanZoomRotationTo`, CONSOLE_GROUP_STYLE);

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

        console.groupEnd();
    }

    async animateInfiniteRotation(direction, step = 0.001) {
        console.groupCollapsed(`%c ${this.constructor.name}: animateInfiniteRotation`, CONSOLE_GROUP_STYLE);
        this.stopCurrentRotationAnimation();

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

        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();
    }

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

export default FractalRenderer;