Source: global/utils.js

/**
 * @module Utils
 * @author Radim Brnka
 * @description Contains helper functions for working with URL parameters, colors, etc.
 */

import {DEBUG_MODE, DEFAULT_CONSOLE_GROUP_COLOR, FRACTAL_TYPE, PI} from "./constants";

let urlParamsSet = false;

/**
 * Updates browser URL with params of the selected point and zoom in the fractal
 * @param {FRACTAL_TYPE} mode
 * @param {number} px panX
 * @param {number} py panY
 * @param {number|null} cx Julia only
 * @param {number|null} cy Julia only
 * @param {number} zoom
 * @param {number} rotation
 */
export function updateURLParams(mode, px, py, zoom, rotation, cx = null, cy = null) {
    const params = {
        mode: mode != null ? mode.toFixed(0) : FRACTAL_TYPE.MANDELBROT,
        px: px != null ? px.toFixed(6) : null,
        py: py != null ? py.toFixed(6) : null,
        zoom: zoom != null ? zoom.toFixed(6) : null,
        r: rotation != null ? rotation.toFixed(6) : 0, // Rotation is not necessary to be defined
        cx: cx != null ? cx.toFixed(6) : null,
        cy: cy != null ? cy.toFixed(6) : null,
    };

    if (DEBUG_MODE) console.log(`%c updateURLParams: %c Setting URL: ${JSON.stringify(params)}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

    if ([px, py, zoom, rotation].some(el => el == null)) {
        console.error(`%c updateURLParams: %c Fractal params incomplete, can't generate URL! ${JSON.stringify(params)}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        return;
    }

    if (mode === FRACTAL_TYPE.JULIA && [cx, cy].some(el => el == null)) {
        console.error(`%c updateURLParams: %c Julia params incomplete, can't generate URL! ${JSON.stringify(params)}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        return;
    }

    // Convert to Base64 for compact representation
    const encodedParams = btoa(JSON.stringify(params));

    // Update the URL with a single "params" field
    const hashPath = mode === FRACTAL_TYPE.JULIA ? '#julia' : '#';
    window.history.pushState({}, '', `${hashPath}?view=${encodedParams}`);

    urlParamsSet = true;
}

/**
 * Fetches and recalculates coords and zoom from URL and sets them to the fractalApp instance
 * @return {URL_PRESET}
 */
export function loadFractalParamsFromURL() {
    if (DEBUG_MODE) console.groupCollapsed(`%c loadFractalParamsFromURL`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    const url = new URL(window.location.href);
    const hash = url.hash; // Get the hash part of the URL (e.g., #julia?view=...)

    if (!hash) {
        if (DEBUG_MODE) console.log(`No hash found in the URL, return default mode.`);
        if (DEBUG_MODE) console.groupEnd();
        return {mode: FRACTAL_TYPE.MANDELBROT}; // Return default if no hash is present
    }

    // Split the hash to separate the mode and parameters
    const [mode, queryString] = hash.slice(1).split('?'); // Remove the '#' and split by '?'

    if (!queryString) {
        if (DEBUG_MODE) console.log(`No query string is found in the URL, returning mode only.`);
        if (DEBUG_MODE) console.groupEnd();
        return {mode: mode === 'julia' ? FRACTAL_TYPE.JULIA : FRACTAL_TYPE.MANDELBROT};
    }

    // Parse the query parameters
    const hashParams = new URLSearchParams(queryString);
    const encodedParams = hashParams.get('view');

    try {
        // Decode the Base64 string and parse the JSON object
        const decodedParams = JSON.parse(atob(encodedParams));
        if (DEBUG_MODE) console.log(`Decoded params: ${JSON.stringify(decodedParams)}`);
        urlParamsSet = true;
        if (DEBUG_MODE) console.groupEnd();
        // Return the parsed parameters
        return {
            mode: mode === 'julia' ? FRACTAL_TYPE.JULIA : FRACTAL_TYPE.MANDELBROT,
            px: decodedParams.px != null ? parseFloat(decodedParams.px) : null,
            py: decodedParams.py != null ? parseFloat(decodedParams.py) : null,
            zoom: parseFloat(decodedParams.zoom) || null,
            r: parseFloat(decodedParams.r) || 0, // Rotation is not necessary to be defined
            cx: decodedParams.cx != null ? parseFloat(decodedParams.cx) : null,
            cy: decodedParams.cy != null ? parseFloat(decodedParams.cy) : null,
        };
    } catch (e) {
        console.error(`%c loadFractalParamsFromURL: %c Error decoding URL parameters: ${e}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        urlParamsSet = true;
        if (DEBUG_MODE) console.groupEnd();
        return {mode: mode === 'julia' ? FRACTAL_TYPE.JULIA : FRACTAL_TYPE.MANDELBROT}; // Return only the mode if no query string is found
    }
}

/** Clears browser URL, usually when it stops correspond with the position/zoom in the fractal */
export function clearURLParams() {
    if (!urlParamsSet) return;

    if (DEBUG_MODE) console.log(`%c clearURLParams: %c Clearing URL params.`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

    const hash = window.location.hash.split('?')[0]; // Keep only the hash part, discard any query parameters
    const newUrl = `${window.location.origin}/${hash}`;
    window.history.pushState({}, '', newUrl);
    urlParamsSet = false;
}

/**
 * Detects touch device
 * @returns {boolean}
 */
export function isTouchDevice() {
    return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}

/**
 * Detects mobile device
 * @returns {boolean} if the user device is mobile
 */
export function isMobileDevice() {
    const toMatch = [/Android/i, /webOS/i, /iPhone/i, /iPad/i, /iPod/i, /BlackBerry/i, /Windows Phone/i];

    return toMatch.some((toMatchItem) => {
        return navigator.userAgent.match(toMatchItem);
    });
}

/**
 * Generates string in [x, yi] format from the given complex number. Trailing zeroes are trimmed.
 * @param {COMPLEX} c
 * @param {number} precision Decimal point precision
 * @param {boolean} [withI] Append "i" to the imaginary member? Ignored if zero.
 * @return {string} [x, yi]|[x, 0]|[?, ?]
 */
export function expandComplexToString(c, precision = 6, withI = true) {
    if (DEBUG_MODE) console.groupCollapsed(`%c expandComplexToString`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    const invalidValues = [NaN, undefined, null, ''];
    const isNumber = (value) => typeof value === 'number' && isFinite(value);

    let expanded = `[`;

    if (invalidValues.some(value => value === c[0]) || invalidValues.some(value => value === c[1]) || !isNumber(c[0]) || !isNumber(c[1])) {
        expanded += `?, ?]`;
        console.warn(`%c expandComplexToString: %c Invalid complex number: ${c}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);
        console.groupEnd();
        return expanded;
    } else {
        const trimmedReal = parseFloat(c[0].toFixed(precision)).toString();
        const trimmedImag = parseFloat(c[1].toFixed(precision)).toString();
        expanded += `${trimmedReal}, ${trimmedImag}`;
    }

    if (DEBUG_MODE) console.groupEnd();
    return expanded + ((withI && c[1] !== 0) ? 'i]' : ']');
}


/**
 * Compares two complex numbers / arrays of two numbers with given precision
 * @param {COMPLEX} c1
 * @param {COMPLEX}c2
 * @param {number} [precision]
 * @return {boolean} true if numbers are equal, false if not
 */
export function compareComplex(c1, c2, precision = 12) {
    return c1[0].toFixed(precision) === c2[0].toFixed(precision) && c1[1].toFixed(precision) === c2[1].toFixed(precision);
}

/**
 * Compares two palettes / arrays of three numbers with given precision (transitively uses the compareComplex)
 * @param {PALETTE} p1
 * @param {PALETTE} p2
 * @param {number} [precision]
 * @return {boolean} true if palettes are equal, false if not
 */
export function comparePalettes(p1, p2, precision = 6) {
    return p1[0].toFixed(precision) === p2[0].toFixed(precision) &&
        p1[1].toFixed(precision) === p2[1].toFixed(precision) &&
        p1[2].toFixed(precision) === p2[2].toFixed(precision);
}

/**
 * HSB to RGB conversion helper function
 * @param h Hue
 * @param s Saturation
 * @param b Brightness
 * @returns {PALETTE} rgb
 */
export function hsbToRgb(h, s, b) {
    const i = Math.floor(h * 6);
    const f = h * 6 - i;
    const p = b * (1 - s);
    const q = b * (1 - f * s);
    const t = b * (1 - (1 - f) * s);

    let r, g, bl;
    // @formatter:off
    switch (i % 6) {
        case 0: r = b; g = t; bl = p; break;
        case 1: r = q; g = b; bl = p; break;
        case 2: r = p; g = b; bl = t; break;
        case 3: r = p; g = q; bl = b; break;
        case 4: r = t; g = p; bl = b; break;
        case 5: r = b; g = p; bl = q; break;
    }
    // @formatter:on
    return [r, g, bl];
}

/**
 * Convert HSL (h in [0,1], s in [0,1], l in [0,1]) to RGB (each channel in [0,1])
 * @param {number} h Hue
 * @param {number} s Saturation
 * @param {number} l Lightness
 * @return {PALETTE} [r, g ,b]
 */
export function hslToRgb(h, s, l) {
    let r, g, b;
    if (s === 0) {
        r = g = b = l; // achromatic
    } else {
        const hue2rgb = (p, q, t) => {
            if (t < 0) t += 1;
            if (t > 1) t -= 1;
            if (t < 1 / 6) return p + (q - p) * 6 * t;
            if (t < 1 / 2) return q;
            if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
            return p;
        };
        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        const p = 2 * l - q;
        r = hue2rgb(p, q, h + 1 / 3);
        g = hue2rgb(p, q, h);
        b = hue2rgb(p, q, h - 1 / 3);
    }
    return [r, g, b];
}

/**
 * Converts RGB (each in [0,1]) to HSL (h in [0,1], s in [0,1], l in [0,1])
 * @param {number} r Red
 * @param {number} g Green
 * @param {number} b Blue
 * @return {Array<number, number, number>} [h, s, l]
 */
export function rgbToHsl(r, g, b) {
    let max = Math.max(r, g, b), min = Math.min(r, g, b);
    let h, s, l = (max + min) / 2;
    if (max === min) {
        h = s = 0; // achromatic
    } else {
        let d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
        if (max === r) {
            h = (g - b) / d + (g < b ? 6 : 0);
        } else if (max === g) {
            h = (b - r) / d + 2;
        } else {
            h = (r - g) / d + 4;
        }
        h /= 6;
    }
    return [h, s, l];
}

/**
 * Converts HTML hex color notation to rgb object
 * @param {string} hex HTML hex color
 * @param {number} [normalize] interval to normalize onto
 * @return {{r: number, g: number, b: number}|null}
 */
export function hexToRGB(hex, normalize = 255) {
    // Ensure it's a string and remove any leading #
    hex = hex.toLowerCase().replace(/^#/, "");

    // Convert shorthand "#abc" → "aabbcc"
    if (hex.length === 3) {
        hex = hex.split("").map(char => char + char).join("");
    }

    // Validate format
    if (!/^([\da-f]{6})$/i.test(hex)) {
        return null;
    }

    // Convert HEX to RGB (0-255)
    const r = parseInt(hex.substring(0, 2), 16);
    const g = parseInt(hex.substring(2, 4), 16);
    const b = parseInt(hex.substring(4, 6), 16);

    // Normalize to 0-1 range
    return {
        r: r / normalize,
        g: g / normalize,
        b: b / normalize
    };
}

/**
 * Converts HTML hex color notation to rgb array
 * @param {string} hex HTML hex color
 * @param {number} [normalize] interval to normalize onto
 * @return {number[]|null} [r, g, b]
 */
export function hexToRGBArray(hex, normalize = 255) {
    const rgb = hexToRGB(hex, normalize);
    return [rgb.r, rgb.g, rgb.b];
}

/**
 * Helper function for ease-in-out timing. This function accelerates in the first half (using 2*t²) and decelerates in
 * the second half.
 * @param {number} time A value between 0 and 1 representing the progress.
 * @return {number} The eased value.
 */
export function easeInOut(time) {
    return time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time;
}

/**
 * Helper function for ease-in-out timing. The cubic version tends to have a smoother acceleration at the beginning and
 * a gentler deceleration at the end, to start more gradually and then slow down more smoothly toward the end.
 * @param {number} time A value between 0 and 1 representing the progress.
 * @return {number} The eased value.
 */
export function easeInOutCubic(time) {
    return time < 0.5
        ? 4 * time * time * time
        : 1 - Math.pow(-2 * time + 2, 3) / 2;
}

/**
 * Helper function for ease-in-out timing using a quintic curve.
 * This function starts gradually, accelerates, then decelerates more gently near the end.
 * @param {number} time A value between 0 and 1 representing the progress.
 * @return {number} The eased value.
 */
export function easeInOutQuint(time) {
    return time < 0.5
        ? 16 * Math.pow(time, 5)
        : 1 - Math.pow(-2 * time + 2, 5) / 2;
}

/**
 * Helper function for linear interpolation
 * @param {number} start
 * @param {number} end
 * @param {number} time A value between 0 and 1 representing the progress.
 * @return {number}
 */
export function lerp(start, end, time) {
    return start + (end - start) * time;
}

/**
 * Normalizes rotation into into [0, 2*PI] interval
 * @param {number} rotation in rad
 * @return {number} rotation in rad
 */
export function normalizeRotation(rotation) {
    const twoPi = 2 * PI;
    return (rotation % twoPi + twoPi) % twoPi;
}

/**
 * Computes a duration based on the travel distance between current and target parameters. Iterates over each property
 * in target (assuming current and target have the same structure)
 * @param {number} seed - A scaling factor to adjust the overall duration.
 * @param {object} current - An object with current values
 * @param {object} target - An object with target values
 * @param {object} [weights] - Optional weights
 * @returns {number} The computed duration.
 */
export function getAnimationDuration(seed, current, target, weights = {}) {
    let compositeDistance = 0;
    for (const key in target) {
        if (Object.prototype.hasOwnProperty.call(target, key)) {
            const w = weights[key] !== undefined ? weights[key] : 1.0;
            const currVal = current[key];
            const targVal = target[key];
            let diff = 0;

            // If the property is a number, use absolute difference.
            if (typeof targVal === 'number' && typeof currVal === 'number') {
                diff = Math.abs(targVal - currVal);
            }
            // If the property is an array, compute the Euclidean distance between the two arrays.
            else if (Array.isArray(targVal) && Array.isArray(currVal)) {
                // Assume both arrays have the same length.
                diff = Math.hypot(...targVal.map((v, i) => v - (currVal[i] || 0)));
            }

            compositeDistance += w * diff;
        }
    }
    return Math.round(seed * compositeDistance);
}

/**
 * Helper function that returns a Promise that resolves after timeout.
 * @param {number} timeout in ms
 * @return {Promise<unknown>}
 */
export async function asyncDelay(timeout) {
    return new Promise(resolve => setTimeout(resolve, timeout));
}

/**
 * Calculates the change in pan based on the movement delta, the canvas rectangle, current zoom, and rotation.
 *
 * @param {number} currentX The current X coordinate (clientX or touch.clientX).
 * @param {number} currentY The current Y coordinate.
 * @param {number} lastX The previous X coordinate.
 * @param {number} lastY The previous Y coordinate.
 * @param {DOMRect} rect The canvas bounding rectangle.
 * @param {number} rotation The current rotation (in radians).
 * @param {number} zoom The current zoom factor.
 * @returns {Array<number>} An array [deltaPanX, deltaPanY] that should be added to the current pan.
 */
export function calculatePanDelta(currentX, currentY, lastX, lastY, rect, rotation, zoom) {
    const moveX = currentX - lastX;
    const moveY = currentY - lastY;

    const ref = Math.min(rect.width, rect.height);

    // Apply inverse rotation to the movement, so it aligns with fractal pan direction.
    const cosR = Math.cos(-rotation);
    const sinR = Math.sin(-rotation);
    const rotatedMoveX = cosR * moveX - sinR * moveY;
    const rotatedMoveY = sinR * moveX + cosR * moveY;

    // Scale movement relative to canvas size and zoom.
    const deltaPanX = -(rotatedMoveX / ref) * zoom;
    const deltaPanY = +(rotatedMoveY / ref) * zoom;

    return [deltaPanX, deltaPanY];
}

/**
 * Removes array of buttons from the scene and clears it.
 * @param {Array<HTMLButtonElement>} buttons
 */
export function destroyArrayOfButtons(buttons) {
    if (Array.isArray(buttons)) {
        buttons.forEach(btn => {
            if (btn.parentNode) {
                btn.parentNode.removeChild(btn);
            }
        });
        buttons.length = 0;
    }
}