Source: ui/ui.js

import {
    clearURLParams,
    destroyArrayOfButtons,
    getAnimationDuration,
    hsbToRgb,
    isTouchDevice,
    normalizeRotation,
    updateURLParams
} from '../global/utils.js';
import {initMouseHandlers, registerMouseEventHandlers, unregisterMouseEventHandlers} from "./mouseEventHandlers";
import {initTouchHandlers, registerTouchEventHandlers, unregisterTouchEventHandlers} from "./touchEventHandlers";
import {JuliaRenderer} from "../renderers/juliaRenderer";
import {takeScreenshot} from "./screenshotController";
import {
    DEBUG_MODE,
    DEFAULT_ACCENT_COLOR,
    DEFAULT_BG_COLOR,
    DEFAULT_CONSOLE_GROUP_COLOR,
    DEFAULT_JULIA_THEME_COLOR,
    DEFAULT_MANDELBROT_THEME_COLOR,
    FF_USER_INPUT_ALLOWED,
    FRACTAL_TYPE,
    PI,
    RANDOMIZE_COLOR_BUTTON_DEFAULT_TITLE
} from "../global/constants";
import {destroyHotKeys, initHotKeys} from "./hotkeyController";
import {MandelbrotRenderer} from "../renderers/mandelbrotRenderer";
import {
    destroyJuliaSliders,
    disableJuliaSliders,
    enableJuliaSliders,
    initJuliaSliders,
    resetJuliaSliders,
    updateJuliaSliders
} from "./juliaSlidersController";

/**
 * @module UI
 * @author Radim Brnka
 * @description Contains code to manage the UI (header interactions, buttons, infoText update, etc.).
 */

let canvas;
let fractalApp;

let fractalMode = FRACTAL_TYPE.MANDELBROT;

const DEMO_BUTTON_DEFAULT_TEXT = 'Demo';
const DEMO_BUTTON_STOP_TEXT = 'Stop';

let accentColor = DEFAULT_ACCENT_COLOR;
let midColor = DEFAULT_BG_COLOR; // TODO use?
let bgColor = DEFAULT_BG_COLOR;

let headerMinimizeTimeout = null;
let uiInitialized = false;
/** @type {boolean} */
let headerVisible = true;

let animationActive = false;
let activeJuliaDiveIndex = -1;
let activePresetIndex = 0;
let resizeTimeout;

// HTML elements
let header;
let mandelbrotSwitch;
let juliaSwitch;
let resetButton;
let randomizeColorsButton;
let screenshotButton;
let demoButton;
let presetButtons = [];
let diveButtons = [];
let allButtons = [];
let infoLabel;
let infoText;

let lastInfoUpdate = 0; // Tracks the last time the sliders were updated
const infoUpdateThrottleLimit = 10; // Throttle limit in milliseconds

/**
 * Switches among fractal modes
 * @param {FRACTAL_TYPE} mode
 * @param {PRESET} [preset] If present, it's set as the default state through travelToPreset
 */
export async function switchFractalMode(mode, preset = null) {
    console.groupCollapsed(`%c switchFractalMode`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (mode === fractalMode) {
        console.warn(`Switching to the same mode? Why?`);
        console.groupEnd();
        return;
    }

    exitAnimationMode();

    fractalApp.destroy();

    switch (mode) {
        case FRACTAL_TYPE.MANDELBROT:
            enableMandelbrotMode();
            break;

        case FRACTAL_TYPE.JULIA:
            enableJuliaMode();
            break;

        default:
            console.error(`Unknown fractal mode "${mode}"!`);
            console.groupEnd();
            return;
    }

    // Register control events
    if (isTouchDevice()) {
        unregisterTouchEventHandlers();
        initTouchHandlers(fractalApp);
    } else {
        destroyHotKeys();
        initHotKeys(fractalApp);

        unregisterMouseEventHandlers();
        initMouseHandlers(fractalApp);
    }

    fractalApp.reset();

    if (preset) {
        console.log(`Preset found, setting: ${JSON.stringify(preset)}`)
        if (isJuliaMode()) {
            await fractalApp.animateTravelToPreset({
                pan: [0, 0], c: preset.c, zoom: 0.5, rotation: 0
            }, 1000);
        } else {
            await fractalApp.animateTravelToPreset({
                pan: preset.pan, zoom: 0.0005, rotation: 0
            }, 500, 1000);
        }
    }

    console.log(`Switched to ${mode === FRACTAL_TYPE.MANDELBROT ? 'Mandelbrot' : 'Julia'}`);
    console.groupEnd();
}

/**
 * Switches among fractal modes but keeps the c/pan settings so the fractals match each other.
 * @param {FRACTAL_TYPE} mode
 * @return {Promise<void>}
 */
export async function switchFractalModeWithPersistence(mode) {
    console.groupCollapsed(`%c switchFractalModeWithPersistence`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (mode === fractalMode) {
        console.warn(`Switching to the same mode? Why?`);
        console.groupEnd();
        return;
    }

    if (mode === FRACTAL_TYPE.MANDELBROT) {
        console.log('MAND')
        await switchFractalMode(FRACTAL_TYPE.MANDELBROT, {
            pan: fractalApp.c.slice(), zoom: 0.00005, rotation: 0
        });
    } else {
        console.log('JUL')
        await switchFractalMode(FRACTAL_TYPE.JULIA, {
            pan: [0, 0], c: fractalApp.pan.slice(), zoom: 0.5, rotation: 0
        });
    }

    console.groupEnd();
}

export function isJuliaMode() {
    return fractalMode === FRACTAL_TYPE.JULIA;
}

/**
 * Implemented in a way it's not needed to be called at the first render. Everything should be pre-initialized
 * for Mandelbrot mode.
 */
export function enableMandelbrotMode() {
    juliaSwitch.classList.remove('active');
    mandelbrotSwitch.classList.add('active');

    destroyArrayOfButtons(diveButtons);
    const diveBlock = document.getElementById('dives');
    diveBlock.style.display = 'none';

    destroyJuliaSliders();

    fractalApp = new MandelbrotRenderer(canvas);
    fractalMode = FRACTAL_TYPE.MANDELBROT;

    // Remove each button from the DOM and reinitialize
    destroyArrayOfButtons(presetButtons);
    if (activePresetIndex !== 0) resetActivePresetIndex();
    initPresetButtonEvents();

    updateColorTheme(DEFAULT_MANDELBROT_THEME_COLOR);

    updateRecolorButtonTitle();

    window.location.hash = ''; // Update URL hash
}

export function enableJuliaMode() {
    fractalApp = new JuliaRenderer(canvas);
    fractalMode = FRACTAL_TYPE.JULIA;

    juliaSwitch.classList.add('active');
    mandelbrotSwitch.classList.remove('active');

    // Remove each button from the DOM and reinitialize
    destroyArrayOfButtons(presetButtons);
    initPresetButtonEvents();

    initJuliaSliders(fractalApp);
    updateJuliaSliders();

    destroyArrayOfButtons(diveButtons);
    initDiveButtons();

    if (activePresetIndex !== 0) resetActivePresetIndex();

    updateColorTheme(DEFAULT_JULIA_THEME_COLOR);
    // Darker backgrounds for Julia as it renders on white
    header.style.background = 'rgba(20, 20, 20, 0.8)';
    infoLabel.style.background = 'rgba(20, 20, 20, 0.8)';

    updateRecolorButtonTitle();

    window.location.hash = '#julia'; // Update URL hash
}

export function updateRecolorButtonTitle() {
    if (isJuliaMode()) {
        let nextTheme = fractalApp.getNextColorThemeId();
        if (nextTheme) randomizeColorsButton.title = 'Next theme: "' + nextTheme + '" (T)';
    } else {
        randomizeColorsButton.title = RANDOMIZE_COLOR_BUTTON_DEFAULT_TITLE;
    }
}

/**
 * Updates color scheme
 * @param {PALETTE} [palette] defaults to the fractal palette
 */
export function updateColorTheme(palette) {
    palette ||= [...fractalApp.colorPalette];

    const adjustChannel = (value, brightnessFactor = 1.9) => Math.min(255, Math.floor(value * 255 * brightnessFactor));

    accentColor = `rgba(${adjustChannel(palette[0])}, ${adjustChannel(palette[1])}, ${adjustChannel(palette[2])}, 1)`;
    midColor = `rgba(${adjustChannel(palette[0], .5)}, ${adjustChannel(palette[1], .5)}, ${adjustChannel(palette[2], .5)}, 0.2)`;
    bgColor = `rgba(${Math.floor(palette[0] * 200)}, ${Math.floor(palette[1] * 200)}, ${Math.floor(palette[2] * 200)}, 0.1)`; // Slightly dimmed for borders

    let root = document.querySelector(':root');
    root.style.setProperty('--bg-color', bgColor);
    root.style.setProperty('--mid-color', midColor);
    root.style.setProperty('--accent-color', accentColor);
}

/** Resets buttons, active presets and URL */
export function resetAppState() {
    resetPresetAndDiveButtonStates();
    resetActivePresetIndex();
    clearURLParams();
}

/**
 * Updates the bottom info bar
 */
export function updateInfo() {
    const now = performance.now();
    const timeSinceLastUpdate = now - lastInfoUpdate;

    if (timeSinceLastUpdate < infoUpdateThrottleLimit) {
        return; // Skip update if called too soon
    }

    // Update the last update time
    lastInfoUpdate = now;

    if (!canvas || !fractalApp) {
        return;
    }

    let text = (animationActive ? ` [AUTO] ` : ``);

    const panX = fractalApp.pan[0] ?? 0;
    const panY = fractalApp.pan[1] ?? 0;

    text += `p = [${panX.toFixed(DEBUG_MODE ? 12 : 6)}, ${panY.toFixed(DEBUG_MODE ? 12 : 6)}i] · `;

    if (fractalMode === FRACTAL_TYPE.JULIA) {
        const cx = fractalApp.c[0] ?? 0;
        const cy = fractalApp.c[1] ?? 0;

        text += `c = [${cx.toFixed(DEBUG_MODE ? 12 : 2)}, ${cy.toFixed(DEBUG_MODE ? 12 : 2)}i] · `;
    }

    const currentZoom = fractalApp.zoom ?? 0;
    const currentRotation = (fractalApp.rotation * 180 / PI) % 360;
    const normalizedRotation = currentRotation < 0 ? currentRotation + 360 : currentRotation;
    text += `r = ${normalizedRotation.toFixed(0)}° · zoom = ${currentZoom.toFixed(6)}`;

    if (animationActive) {
        infoText.classList.add('animationActive');
    } else {
        infoText.classList.remove('animationActive');
    }

    if (FF_USER_INPUT_ALLOWED) {
        if (document.activeElement !== infoText) {
            infoText.innerHTML = text;
        }
    } else {
        infoText.innerHTML = text;
    }
}

export function isAnimationActive() {
    return animationActive;
}

/** Enables controls, resets demo button */
function exitAnimationMode() {
    console.groupCollapsed(`%c exitAnimationMode`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (!animationActive) {
        console.groupEnd();
        return;
    }

    animationActive = false;
    infoText.classList.remove('animation');

    fractalApp.stopAllNonColorAnimations();

    demoButton.innerText = DEMO_BUTTON_DEFAULT_TEXT;
    demoButton.classList.remove('active');

    if (isTouchDevice()) {
        registerTouchEventHandlers();
    } else {
        registerMouseEventHandlers();
    }

    if (isJuliaMode()) {
        enableJuliaSliders();
    }

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

    console.groupEnd();
}

/** Disables controls, activates demo button */
function initAnimationMode() {
    console.groupCollapsed(`%c initAnimationMode`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (animationActive) {
        console.groupEnd();
        return;
    }

    animationActive = true;
    infoText.classList.add('animation');

    // resetPresetAndDiveButtons();
    demoButton.innerText = DEMO_BUTTON_STOP_TEXT;
    demoButton.classList.add('active');

    // Unregister control events
    if (isTouchDevice()) {
        unregisterTouchEventHandlers();
    } else {
        unregisterMouseEventHandlers();
    }

    if (isJuliaMode()) {
        disableJuliaSliders();
    }

    clearURLParams();

    console.groupEnd();
}

/**
 * Turns demo on/off and/or stops current animation
 * @return {Promise<void>}
 */
export async function toggleDemo() {
    console.groupCollapsed(`%c toggleDemo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (animationActive) {
        resetPresetAndDiveButtonStates();
        activeJuliaDiveIndex = -1;
        fractalApp.stopDemo();
        exitAnimationMode();
        console.groupEnd();
        return;
    }

    resetPresetAndDiveButtonStates();
    initAnimationMode();

    switch (fractalMode) {
        // @formatter:off
        case FRACTAL_TYPE.MANDELBROT: await startMandelbrotDemo(); break;

        case FRACTAL_TYPE.JULIA: await startJuliaDemo(); break;

        default:
            console.warn(`No demo defined for mode ${fractalMode}`);
            exitAnimationMode();
            break;
        // @formatter:on
    }

    console.groupEnd();
}

/** Starts the Mandelbrot demo */
async function startMandelbrotDemo() {
    console.groupCollapsed(`%c startMandelbrotDemo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    await fractalApp.animateDemo();

    console.log("Demo ended");
    console.groupEnd();
}

/**
 * Starts the Julia dive infinite animation
 * @param {Array<DIVE>} dives
 * @param {number} index Index of the dive
 * @return {Promise<void>}
 */
export async function startJuliaDive(dives, index) {
    if (animationActive && index === activeJuliaDiveIndex) {
        console.log(`%c startJuliaDive: %c Dive ${index} already in progress. Skipping.`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        return;
    }

    if (animationActive) {
        exitAnimationMode();
    }

    resetPresetAndDiveButtonStates();
    activeJuliaDiveIndex = index;
    diveButtons[index].classList.add('active');

    const dive = dives[index];

    // Validate configuration:
    if (dive.cxDirection < 0 && dive.endC[0] >= dive.startC[0]) {
        console.error("For negative cxDirection, endC[0] must be lower than startC[0].");
        return;
    } else if (dive.cxDirection > 0 && dive.endC[0] <= dive.startC[0]) {
        console.error("For positive cxDirection, endC[0] must be higher than startC[0].");
        return;
    }
    if (dive.cyDirection < 0 && dive.endC[1] >= dive.startC[1]) {
        console.error("For negative cyDirection, endC[1] must be lower than startC[1].");
        return;
    } else if (dive.cyDirection > 0 && dive.endC[1] <= dive.startC[1]) {
        console.error("For positive cyDirection, endC[1] must be higher than startC[1].");
        return;
    }

    initAnimationMode();

    //if (DEBUG_MODE) dive.step *= 10;

    // Transition to the initial preset first.
    const duration = getAnimationDuration(500, fractalApp, {c: dive.startC, pan: dive.pan, zoom: dive.zoom},
        {pan: 0.5, zoom: 2, c: 1});
    console.log(duration);

    // Phase 1: Set initial state
    // await fractalApp.animateTravelToPreset({
    //     pan: dive.pan, c: dive.startC.slice(), // copy initial c
    //     zoom: dive.zoom, rotation: dive.rotation
    // }, 1500, EASE_TYPE.QUINT);
    // Alternatively:
    await fractalApp.animateToZoomAndC(fractalApp.DEFAULT_ZOOM, dive.startC, 1500);

    // Phase 2: Enter dive mode
    await Promise.all([
        fractalApp.animateDive(dive),
        fractalApp.animatePanZoomRotationTo(dive.pan, dive.zoom, dive.rotation, 1500)
    ]);
}

/** Starts the Julia demo */
async function startJuliaDemo() {
    console.groupCollapsed(`%c startJuliaDemo`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`);

    if (animationActive) {
        console.log(`Animation already in progress. Stopping.`);
        exitAnimationMode();
    }

    initAnimationMode();

    resetPresetAndDiveButtonStates();

    // Initialize or continue the demo?
    if (fractalApp.demoTime === 0) {
        await fractalApp.animateTravelToPreset({
            pan: fractalApp.DEFAULT_PAN, c: [-0.25, 0.7], // For smooth c transition
            zoom: fractalApp.DEFAULT_ZOOM, rotation: fractalApp.DEFAULT_ROTATION
        }, 1000);
    }

    await fractalApp.animateDemo();

    exitAnimationMode();

    console.groupEnd();
}

/**
 * Travels to preset at given index
 * @param {Array<PRESET>} presets
 * @param {number} index Preset array index
 * @return {Promise<void>}
 */
export async function travelToPreset(presets, index) {
    if (animationActive) {
        console.log(`%c travelToPreset: %c Travel to preset ${index} requested, active animation in progress, interrupting...`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        exitAnimationMode();
    }

    if (index === activePresetIndex) {
        console.log(`%c travelToPreset: %c Already on preset ${index}, skipping.`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
        return;
    }

    console.log(`%c travelToPreset: %c Executing travel to preset ${index}`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');

    resetPresetAndDiveButtonStates();
    initAnimationMode();

    presetButtons[index].classList.add('active');

    if (isJuliaMode()) {
        fractalApp.demoTime = 0;
        await fractalApp.animateTravelToPreset(presets[index], 1500);
    } else {
        await fractalApp.animateTravelToPreset(presets[index]);
    }
    activePresetIndex = index;

    exitAnimationMode();
    updateURLParams(fractalMode, fractalApp.pan[0], fractalApp.pan[1], fractalApp.zoom, fractalApp.rotation, fractalApp.c ? fractalApp.c[0] : null, fractalApp.c ? fractalApp.c[1] : null);
}

/** Inits debug bar with various information permanently shown on the screen */
function initDebugMode() {
    infoLabel.style.height = '80px';

    const debugInfo = document.getElementById('debugInfo');
    debugInfo.style.display = 'block';
    const dpr = window.devicePixelRatio;

    const {width, height} = canvas.getBoundingClientRect();
    const displayWidth = Math.round(width * dpr);
    const displayHeight = Math.round(height * dpr);
    (function update() {
        debugInfo.innerText = `WINDOW: ${window.innerWidth}x${window.innerHeight} (dpr: ${window.devicePixelRatio})
        CANVAS: ${canvas.width}x${canvas.height}, aspect: ${(canvas.width / canvas.height).toFixed(2)} 
        BoundingRect: ${width}x${height}, display W/H: ${displayWidth}x${displayHeight}`;
        requestAnimationFrame(update);
    })();

    debugInfo.addEventListener('click', () => {
        console.log(debugInfo.innerText);
    });

    toggleDebugLines();
}

/** Toggles x/y axes */
export function toggleDebugLines() {
    const verticalLine = document.getElementById('verticalLine');
    const horizontalLine = document.getElementById('horizontalLine');

    if (verticalLine.style.display === 'block' && horizontalLine.style.display === 'block') {
        verticalLine.style.display = 'none';
        horizontalLine.style.display = 'none';
    } else {
        verticalLine.style.display = 'block';
        horizontalLine.style.display = 'block';
    }
}

export function resetPresetAndDiveButtonStates() {
    if (DEBUG_MODE) console.log(`%c resetPresetAndDiveButtonStates: %c Button states reset.`, `color: ${DEFAULT_CONSOLE_GROUP_COLOR}`, 'color: #fff');
    presetButtons.concat(diveButtons).forEach(b => b.classList.remove('active'));
}

/**
 * This needs to happen on any fractal change
 */
export function resetActivePresetIndex() {
    activePresetIndex = -1;
}

export async function randomizeColors() {
    if (isJuliaMode()) {
        await fractalApp.animateColorPaletteTransition(250, updateColorTheme);
        updateRecolorButtonTitle();
    } else {
        // Generate a bright random color palette
        // Generate colors with better separation and higher brightness
        const hue = Math.random(); // Hue determines the "base color" (red, green, blue, etc.)
        const saturation = Math.random() * 0.5 + 0.5; // Ensure higher saturation (more vivid colors)
        const brightness = Math.random() * 0.5 + 0.5; // Ensure higher brightness

        // Convert HSB/HSV to RGB
        const newPalette = hsbToRgb(hue, saturation, brightness);

        await fractalApp.animateColorPaletteTransition(newPalette, 250, () => {
            updateColorTheme(newPalette);
        }); // Update app colors
    }
}

export function captureScreenshot() {
    takeScreenshot(canvas, fractalApp, accentColor);
}

/**
 * Shows/hides/toggles header.
 * @param {boolean|null} show Show header? If null, then toggles current state
 */
export function toggleHeader(show = null) {
    let header = document.getElementById('headerContainer');

    if (show === null) show = !headerVisible;

    if (show) {
        header.classList.remove('minimized');
    } else {
        header.classList.add('minimized');
    }

    headerVisible = show;
}

export async function reset() {
    updateColorTheme(isJuliaMode() ? DEFAULT_JULIA_THEME_COLOR : DEFAULT_MANDELBROT_THEME_COLOR);

    exitAnimationMode();

    fractalApp.reset();
    if (isJuliaMode()) {
        resetJuliaSliders();
    }
    resetAppState();
    updateRecolorButtonTitle();
    presetButtons[0].classList.add('active');
}

// region > INITIALIZERS -----------------------------------------------------------------------------------------------

function initHeaderEvents() {

    document.getElementById('logo').addEventListener('pointerenter', () => {
        if (headerMinimizeTimeout) {
            clearTimeout(headerMinimizeTimeout);
            headerMinimizeTimeout = null;
        }
        toggleHeader(true);
    });

    header.addEventListener('pointerleave', async () => {
        // Only minimize if it hasn't been toggled manually
        if (headerVisible && !DEBUG_MODE) {
            headerMinimizeTimeout = setTimeout(() => {
                toggleHeader(false);
                headerMinimizeTimeout = null;
            }, 3000);
        }
    });

    // When user clicks/taps outside of the header
    canvas.addEventListener('pointerdown', () => {
        if (DEBUG_MODE) return;
        toggleHeader(false);
    });
}

function initControlButtonEvents() {
    resetButton.addEventListener('click', async () => {
        await reset();
    });

    randomizeColorsButton.addEventListener('click', randomizeColors);

    demoButton.addEventListener('click', toggleDemo);

    screenshotButton.addEventListener('click', captureScreenshot);
}

function initPresetButtonEvents() {
    const presetBlock = document.getElementById('presets');
    // presetBlock.innerHTML = 'Presets: ';
    //
    presetButtons = [];

    const presets = [...fractalApp.PRESETS];
    presets.forEach((preset, index) => {
        const btn = document.createElement('button');
        btn.id = 'preset' + (index);
        btn.className = 'preset';
        btn.title = (preset.title || ('Preset ' + index)) + ` (Num ${index})`;
        btn.textContent = (index).toString();
        btn.addEventListener('click', async () => {
            await travelToPreset(presets, index);
        });

        presetBlock.appendChild(btn);
        presetButtons.push(btn);
        presetButtons[0].classList.add('active');
    });
}

/**
 * Inits behavior common for all buttons
 */
function initCommonButtonEvents() {
    allButtons = diveButtons.concat(presetButtons);
    allButtons.push(resetButton, randomizeColorsButton, screenshotButton, demoButton);

    allButtons.forEach((btn) => {
        btn.addEventListener('mouseleave', () => {
            btn.blur();
        });

        btn.addEventListener('mouseup', () => {
            btn.blur();
        });
    });
}

function initDiveButtons() {
    if (isJuliaMode()) {
        const diveBlock = document.getElementById('dives');
        //diveBlock.innerHTML = 'Dives: ';

        diveButtons = [];

        const dives = [...fractalApp.DIVES];
        dives.forEach((dive, index) => {
            const btn = document.createElement('button');
            btn.id = 'dive' + (index);
            btn.className = 'dive';
            btn.title = (dive.title || ('Preset ' + index)) + ` (Shift+${index})`;
            btn.textContent = (index).toString();
            btn.addEventListener('click', async () => {
                await startJuliaDive(dives, index);
            });

            diveBlock.appendChild(btn);
            diveButtons.push(btn);
        });

        diveBlock.style.display = 'block';
    }
}

function initFractalSwitchButtons() {
    mandelbrotSwitch.addEventListener('click', async (e) => {
        if (e.ctrlKey) {
            console.log('mandelbrotSwitch clicked (with persistence).');
            await switchFractalModeWithPersistence(FRACTAL_TYPE.MANDELBROT);
        } else {
            console.log('mandelbrotSwitch clicked.');
            await switchFractalMode(FRACTAL_TYPE.MANDELBROT);
        }
    });

    juliaSwitch.addEventListener('click', async (e) => {
        if (e.ctrlKey) {
            await switchFractalModeWithPersistence(FRACTAL_TYPE.JULIA);
        } else {
            await switchFractalMode(FRACTAL_TYPE.JULIA);
        }
    });
}

function initWindowEvents() {
    window.addEventListener('resize', () => {
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
            fractalApp.resizeCanvas(); // Adjust canvas dimensions
        }, 200); // Adjust delay as needed
    });
}

/**
 * Parses a string of the form:
 *   p = [<panX>, <panY>i] c = [<cX>, <cY>i] zoom = <zoom> r = <rotation>
 * and returns an object with the parsed numbers.
 * If a part is invalid or missing, an error message is returned.
 *
 * @param {string} input The input string.
 * @returns {PRESET|Object}
 */
export function parseUserInput(input) {

    // https://regex101.com/
    const panRegex = /\s*p\s*=\s*[\[\(]?\s*(-?[\d.]+)\s*[,|\s+]\s*(-?[\d.]+)\s*i?\s*[\]\)]?/i;
    const cRegex = /\s*c\s*=\s*[\[\(]?\s*(-?[\d.]+)\s*[,|\s+]\s*(-?[\d.]+)\s*i?\s*[\]\)]?/i;
    const zoomRegex = /\s*zoom\s*=\s*([\d.]+)/i;
    const rotationRegex = /\s*r\s*=\s*([\d.]+)/i;

    let errors = [];

    // Validate and extract pan.
    const panMatch = input.match(panRegex);
    if (!panMatch) {
        errors.push(`Pan coordinates (p) are missing or invalid.`);
    }
    // Validate and extract Julia c in Julia mode
    const cMatch = input.match(cRegex);
    if (!cMatch && isJuliaMode()) {
        errors.push(`Julia constant (c) is missing or invalid.`);
    }
    // Validate and extract zoom.
    const zoomMatch = input.match(zoomRegex);
    if (!zoomMatch) {
        errors.push(`Zoom (zoom) value is missing or invalid.`);
    }
    // Validate and extract rotation.
    const rotationMatch = input.match(rotationRegex);
    if (!rotationMatch) {
        errors.push(`Rotation (r) value is missing or invalid.`);
    }

    // If any errors, return error object.
    if (errors.length > 0) {
        return {error: errors.join(" ")};
    }

    // Otherwise, parse values.
    const panX = parseFloat(panMatch[1]);
    const panY = parseFloat(panMatch[2]);
    const z = parseFloat(zoomMatch[1]);
    const r = normalizeRotation(parseFloat(rotationMatch[1]) * Math.PI / 180);

    // Optionally check for isNaN here too.
    // if ([panX, panY, cX, cY, z, r].some(v => isNaN(v))) {
    //     return { error: "One or more numeric values could not be parsed." };
    // }

    if (isJuliaMode()) {
        const cX = parseFloat(cMatch[1]);
        const cY = parseFloat(cMatch[2]);

        return {pan: [panX, panY], c: [cX, cY], zoom: z, rotation: r};
    } else {
        return {pan: [panX, panY], zoom: z, rotation: r};
    }

}

function initInfoText() {
    if (!FF_USER_INPUT_ALLOWED) {
        infoText.addEventListener('click', () => {
            let text = `{pan: [${fractalApp.pan}], rotation: ${fractalApp.rotation}, zoom: ${fractalApp.zoom}`
                + (isJuliaMode() ? `, c: [${fractalApp.c}]}` : `}`);

            navigator.clipboard.writeText(text).then(function () {
                infoText.innerHTML = 'Copied to clipboard!';
            }, function (err) {
                console.error('Not copied to clipboard! ' + err.toString());
            });
        });
    } else {
        infoText.setAttribute("contenteditable", true);
        infoText.style.cursor = 'auto';
        infoText.removeAttribute("readonly");

        infoText.addEventListener('focus', () => {
            infoLabel.style.zoom = '200%';
        })

        infoText.addEventListener('blur', () => {
            infoLabel.style.zoom = '120%';
        })

        infoText.addEventListener("keydown", function (e) {
            e.stopPropagation();

            if (e.key === "Enter") {
                e.preventDefault(); // Prevent a newline

                //const regex = /\s*p\s*=\s*\[(-?[\d.]+)\s*[,|\s+]\s*(-?[\d.]+)\s*i?\s*\]\s*·?\s*r\s*=\s*(-?[\d.]+)\s*°?\s*·?\s*zoom\s*=\s*(-?[\d.]+)/;
                /* for C capture the same as for P
                    Allowed complex notations:
                    p = [ -0.500000000000, 0.000000000000i] · r = 0° · zoom = 3.000000
                    p = [-0.500000000000 + 2i] · r = 0° · zoom = 3.000000
                    p = [ 0.500000000000, 0.000000000000] · r = 0° · zoom = 3.000000
                    p = [0.500000000000 + 0.000000000000] · r = 0° · zoom = 3.000000
                    p = (0.500000000000 + 0.000000000001) · r = 0° · zoom = 3.000000
                    p = (0.500000000000 - 0.000000000001) · r = 0° · zoom = 3.000000
                    p = (0.500000000000 - 0.000000000001i) · r = 0 · zoom = -3.000000 (No match)
                 */

                const result = parseUserInput(infoText.innerHTML);
                if (result.error) {
                    infoLabel.style.color = '#f00';
                    alert(result.error);
                } else {
                    infoLabel.style.color = '#fff';
                    fractalApp.animateTravelToPreset(result);
                }
            }
        });
    }
}

/**
 * Initializes the UI and registers UI event handlers
 * @param fractalRenderer
 */
export async function initUI(fractalRenderer) {
    if (uiInitialized) {
        console.warn("UI already initialized!");
        return;
    }

    fractalApp = fractalRenderer;
    canvas = fractalApp.canvas;

    // Element binding
    mandelbrotSwitch = document.getElementById('mandelbrotSwitch');
    juliaSwitch = document.getElementById('juliaSwitch');
    header = document.getElementById('headerContainer');
    infoLabel = document.getElementById('infoLabel');
    infoText = document.getElementById('infoText');
    resetButton = document.getElementById('reset');
    randomizeColorsButton = document.getElementById('randomize');
    screenshotButton = document.getElementById('screenshot');
    demoButton = document.getElementById('demo');

    if (fractalRenderer instanceof JuliaRenderer) {
        fractalMode = FRACTAL_TYPE.JULIA;
        juliaSwitch.classList.add('active');
        mandelbrotSwitch.classList.remove('active');

        initJuliaSliders(fractalApp);
        updateJuliaSliders();
        initDiveButtons();

        updateColorTheme(DEFAULT_JULIA_THEME_COLOR);
        // Darker backgrounds for Julia as it renders on white
        header.style.background = 'rgba(20, 20, 20, 0.8)';
        infoLabel.style.background = 'rgba(20, 20, 20, 0.8)';

        window.location.hash = '#julia'; // Update URL hash
    }
    initPresetButtonEvents();
    initWindowEvents();
    initHeaderEvents();
    initControlButtonEvents();
    initInfoText();
    initFractalSwitchButtons();
    initCommonButtonEvents(); // After all dynamic buttons are set

    // Register control events
    if (isTouchDevice()) {
        initTouchHandlers(fractalApp);
    } else {
        initHotKeys(fractalApp);
        initMouseHandlers(fractalApp);
    }

    updateRecolorButtonTitle();

    if (DEBUG_MODE) {
        initDebugMode();
    }

    uiInitialized = true;
}

// endregion------------------------------------------------------------------------------------------------------------