Source: ui/zetaPathOverlay.js

/**
 * @module ZetaPathOverlay
 * @description Renders the ζ(½+it) spiral curve on a canvas overlay
 * @author Radim Brnka
 * @copyright Synaptory Fractal Traveler, 2025-2026
 * @license MIT
 */

import {log} from '../global/constants';

// Canvas and state
let canvas = null;
let ctx = null;
let visible = false;
let renderer = null;

// ─────────────────────────────────────────────────────────────────────────────
// Complex number operations
// ─────────────────────────────────────────────────────────────────────────────

function cMul(a, b) {
    return [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]];
}

function cDiv(a, b) {
    const denom = b[0] * b[0] + b[1] * b[1];
    return [(a[0] * b[0] + a[1] * b[1]) / denom, (a[1] * b[0] - a[0] * b[1]) / denom];
}

function cExp(z) {
    const ea = Math.exp(z[0]);
    return [ea * Math.cos(z[1]), ea * Math.sin(z[1])];
}

// ─────────────────────────────────────────────────────────────────────────────
// Zeta function computation
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Computes the Dirichlet eta function (alternating zeta series)
 * @param {number[]} s - Complex number [re, im]
 * @param {number} terms - Number of terms to use
 * @returns {number[]} - Complex result [re, im]
 */
function eta(s, terms = 100) {
    let sum = [0, 0];
    for (let n = 1; n <= terms; n++) {
        const sign = (n % 2 === 0) ? -1 : 1;
        const scale = 1 / Math.pow(n, s[0]);
        const angle = -s[1] * Math.log(n);
        sum[0] += sign * scale * Math.cos(angle);
        sum[1] += sign * scale * Math.sin(angle);
    }
    return sum;
}

/**
 * Computes zeta via eta: ζ(s) = η(s) / (1 - 2^(1-s))
 * @param {number[]} s - Complex number [re, im]
 * @param {number} terms - Number of terms
 * @returns {number[]} - Complex result [re, im]
 */
function zeta(s, terms = 100) {
    const etaVal = eta(s, terms);
    const log2 = 0.69314718;
    const expReal = Math.exp((1 - s[0]) * log2);
    const expImag = -s[1] * log2;
    const twoPow = [expReal * Math.cos(expImag), expReal * Math.sin(expImag)];
    const denom = [1 - twoPow[0], -twoPow[1]];
    return cDiv(etaVal, denom);
}

// ─────────────────────────────────────────────────────────────────────────────
// Drawing
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Draws the zeta path curve w = ζ(½ + it) in the w-plane
 */
function draw() {
    if (!ctx || !canvas || !renderer) return;

    const width = canvas.width;
    const height = canvas.height;
    const centerX = width / 2;
    const centerY = height / 2;

    ctx.clearRect(0, 0, width, height);

    const pan = renderer.pan;
    const zoom = renderer.zoom;
    const scale = height / zoom;

    // Determine t range based on current view
    const tCenter = Math.max(0, pan[1]);
    const tRange = Math.max(50, zoom * 2);
    const tMin = Math.max(0, tCenter - tRange);
    const tMax = tCenter + tRange;

    // Adaptive step size based on zoom
    const numPoints = Math.min(5000, Math.max(500, Math.floor(tRange * 20)));
    const dt = (tMax - tMin) / numPoints;

    const terms = renderer.seriesTerms || 500;

    // Draw the spiral path
    ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();

    let firstPoint = true;
    let prevW = null;

    for (let t = tMin; t <= tMax; t += dt) {
        const w = zeta([0.5, t], terms);

        const screenX = centerX + (w[0] - pan[0]) * scale;
        const screenY = centerY - (w[1] - pan[1]) * scale;

        if (isNaN(screenX) || isNaN(screenY)) continue;
        if (screenX < -width || screenX > 2 * width || screenY < -height || screenY > 2 * height) {
            firstPoint = true;
            continue;
        }

        // Detect discontinuities
        if (prevW) {
            const jump = Math.hypot(w[0] - prevW[0], w[1] - prevW[1]);
            if (jump > zoom * 0.5) {
                firstPoint = true;
            }
        }

        if (firstPoint) {
            ctx.moveTo(screenX, screenY);
            firstPoint = false;
        } else {
            ctx.lineTo(screenX, screenY);
        }
        prevW = w;
    }
    ctx.stroke();

    // Draw origin marker (zeros cross here)
    // const originX = centerX + (0 - pan[0]) * scale;
    // const originY = centerY - (0 - pan[1]) * scale;

    // if (originX > -20 && originX < width + 20 && originY > -20 && originY < height + 20) {
    // ctx.beginPath();
    // ctx.arc(originX, originY, 6, 0, 2 * Math.PI);
    // ctx.strokeStyle = 'rgba(255, 100, 100, 0.9)';
    // ctx.lineWidth = 2;
    // ctx.stroke();
    //
    // ctx.beginPath();
    // ctx.moveTo(originX - 8, originY);
    // ctx.lineTo(originX + 8, originY);
    // ctx.moveTo(originX, originY - 8);
    // ctx.lineTo(originX, originY + 8);
    // ctx.strokeStyle = 'rgba(255, 100, 100, 0.6)';
    // ctx.lineWidth = 1;
    // ctx.stroke();
    // }

    // Draw t-value labels
    ctx.font = '11px monospace';
    ctx.fillStyle = 'rgba(0, 255, 200, 0.7)';
    const labelInterval = Math.max(1, Math.ceil(tRange / 20));
    for (let t = Math.ceil(tMin / labelInterval) * labelInterval; t <= tMax; t += labelInterval) {
        if (t < 0.1) continue;
        const w = zeta([0.5, t], terms);
        const screenX = centerX + (w[0] - pan[0]) * scale;
        const screenY = centerY - (w[1] - pan[1]) * scale;
        if (screenX > 30 && screenX < width - 30 && screenY > 20 && screenY < height - 20) {
            ctx.fillText(`t=${t}`, screenX + 5, screenY - 5);
        }
    }

    // Legend
    ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
    ctx.font = '12px monospace';
    ctx.fillText('ζ(½ + it) spiral', 10, height - 30);
    ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
    ctx.fillText('Zeros = origin crossings', 10, height - 15);
}

// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Initializes the zeta path overlay
 * @param {HTMLCanvasElement} canvasElement - The canvas element
 * @param {Object} fractalRenderer - The renderer instance (for pan/zoom/terms)
 */
export function init(canvasElement, fractalRenderer) {
    canvas = canvasElement;
    renderer = fractalRenderer;
    if (canvas) {
        ctx = canvas.getContext('2d');
    }
}

/**
 * Shows the overlay
 */
export function show() {
    if (!canvas || !ctx) return;

    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    canvas.classList.remove('zeta-path-hidden');
    visible = true;

    draw();
}

/**
 * Hides the overlay
 */
export function hide() {
    if (canvas) {
        canvas.classList.add('zeta-path-hidden');
    }
    visible = false;
}

/**
 * Toggles the overlay visibility
 * @returns {boolean} New visibility state
 */
export function toggle() {
    if (visible) {
        hide();
    } else {
        show();
    }
    log(`Zeta Path: ${visible ? 'ON' : 'OFF'}`);
    return visible;
}

/**
 * Updates the overlay (redraws if visible)
 */
export function update() {
    if (!visible) return;
    draw();
}

/**
 * Resizes the canvas and redraws
 */
export function resize() {
    if (!visible || !canvas) return;
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    draw();
}

/**
 * @returns {boolean}  current visibility state
 */
export function isVisible() {
    return visible;
}

/**
 * Sets the renderer reference (for when renderer changes)
 * @param {Object} fractalRenderer
 */
export function setRenderer(fractalRenderer) {
    renderer = fractalRenderer;
}