← Mike Higgins Home
Custom Code Experiment •

Phases 1 Through 4

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
  <title>Mathematical Visualizations</title>
  <style>
    :root { color-scheme: dark; }
    html, body {
      margin: 0;
      height: 100%;
      overflow: hidden;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }

    body {
      background: #000;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
      touch-action: none;
      image-rendering: optimizeQuality;
      opacity: 0;
      transition: opacity 0.8s ease;
      position: fixed;
      top: 0;
      left: 0;
    }

    canvas.active {
      opacity: 1;
    }

    @media (prefers-reduced-motion: reduce) {
      canvas { filter: saturate(0.5) brightness(0.85); }
    }

    .controls {
      position: fixed;
      top: 20px;
      left: 20px;
      z-index: 100;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }

    .phase-btn {
      background: rgba(255, 255, 255, 0.92);
      color: #000;
      border: 1px solid rgba(0, 0, 0, 0.2);
      border-radius: 6px;
      padding: 10px 20px;
      font-size: 14px;
      font-weight: 500;
      letter-spacing: 0.5px;
      cursor: pointer;
      transition: all 0.3s ease;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
      backdrop-filter: blur(4px);
      min-width: 100px;
      text-align: center;
    }

    .phase-btn:hover {
      background: rgba(255, 255, 255, 0.98);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
    }

    .phase-btn.active {
      background: rgba(255, 255, 255, 1);
      box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8);
      font-weight: 600;
    }

    .phase-btn:active {
      transform: translateY(0);
    }

    .title {
      position: fixed;
      top: 20px;
      left: 50%;
      transform: translateX(-50%);
      color: rgba(255, 255, 255, 0.8);
      font-size: 14px;
      font-weight: 500;
      letter-spacing: 1px;
      text-transform: uppercase;
      z-index: 100;
      background: rgba(0, 0, 0, 0.3);
      padding: 8px 16px;
      border-radius: 20px;
      backdrop-filter: blur(4px);
      transition: opacity 0.5s ease;
    }
  </style>
</head>
<body>
  <canvas id="hopfCanvas"></canvas>
  <canvas id="kleinCanvas"></canvas>
  <canvas id="lorenzCanvas"></canvas>
  <canvas id="mandelbulbCanvas"></canvas>

  <div class="controls">
    <button class="phase-btn active" id="phase1Btn">Phase 1</button>
    <button class="phase-btn" id="phase2Btn">Phase 2</button>
    <button class="phase-btn" id="phase3Btn">Phase 3</button>
    <button class="phase-btn" id="phase4Btn">Phase 4</button>
  </div>

  <div class="title" id="title">Hopf Fibration</div>

  <script>
    let currentAnimation = 'hopf';
    let hopfAnimationId = null;
    let kleinAnimationId = null;
    let lorenzAnimationId = null;
    let mandelbulbAnimationId = null;
    let hopfInitialized = false;
    let kleinInitialized = false;
    let lorenzInitialized = false;
    let mandelbulbInitialized = false;

    const hopfCanvas = document.getElementById('hopfCanvas');
    const kleinCanvas = document.getElementById('kleinCanvas');
    const lorenzCanvas = document.getElementById('lorenzCanvas');
    const mandelbulbCanvas = document.getElementById('mandelbulbCanvas');
    const phase1Btn = document.getElementById('phase1Btn');
    const phase2Btn = document.getElementById('phase2Btn');
    const phase3Btn = document.getElementById('phase3Btn');
    const phase4Btn = document.getElementById('phase4Btn');
    const title = document.getElementById('title');

    let hopfAnimation = null;
    let kleinAnimation = null;
    let lorenzAnimation = null;
    let mandelbulbAnimation = null;

    function switchAnimation(animation) {
      if (animation === currentAnimation) return;

      phase1Btn.classList.remove('active');
      phase2Btn.classList.remove('active');
      phase3Btn.classList.remove('active');
      phase4Btn.classList.remove('active');

      if (animation === 'hopf') {
        phase1Btn.classList.add('active');
        title.textContent = 'Hopf Fibration';
        document.body.style.background = 'radial-gradient(1300px 850px at 50% 42%, #0a0d14, #030508 62%, #000 100%)';
      } else if (animation === 'klein') {
        phase2Btn.classList.add('active');
        title.textContent = 'Klein Bottle';
        document.body.style.background = 'radial-gradient(1400px 900px at 50% 45%, #120a14, #080208 55%, #000 100%)';
      } else if (animation === 'lorenz') {
        phase3Btn.classList.add('active');
        title.textContent = 'Lorenz Attractor';
        document.body.style.background = 'radial-gradient(1200px 900px at 55% 40%, #0d0a10, #040306 60%, #000 100%)';
      } else if (animation === 'mandelbulb') {
        phase4Btn.classList.add('active');
        title.textContent = 'Mandelbulb';
        document.body.style.background = 'radial-gradient(1100px 850px at 48% 45%, #14080a, #06020a 58%, #000 100%)';
      }

      hopfCanvas.classList.remove('active');
      kleinCanvas.classList.remove('active');
      lorenzCanvas.classList.remove('active');
      mandelbulbCanvas.classList.remove('active');

      if (hopfAnimationId !== null) {
        cancelAnimationFrame(hopfAnimationId);
        hopfAnimationId = null;
      }
      if (kleinAnimationId !== null) {
        cancelAnimationFrame(kleinAnimationId);
        kleinAnimationId = null;
      }
      if (lorenzAnimationId !== null) {
        cancelAnimationFrame(lorenzAnimationId);
        lorenzAnimationId = null;
      }
      if (mandelbulbAnimationId !== null) {
        cancelAnimationFrame(mandelbulbAnimationId);
        mandelbulbAnimationId = null;
      }

      setTimeout(() => {
        if (animation === 'hopf') {
          if (!hopfInitialized) {
            initHopfAnimation();
            hopfInitialized = true;
          }
          hopfCanvas.classList.add('active');
          if (hopfAnimation) {
            hopfAnimationId = requestAnimationFrame(hopfAnimation.frame);
          }
        } else if (animation === 'klein') {
          if (!kleinInitialized) {
            initKleinAnimation();
            kleinInitialized = true;
          }
          kleinCanvas.classList.add('active');
          if (kleinAnimation) {
            kleinAnimationId = requestAnimationFrame(kleinAnimation.frame);
          }
        } else if (animation === 'lorenz') {
          if (!lorenzInitialized) {
            initLorenzAnimation();
            lorenzInitialized = true;
          }
          lorenzCanvas.classList.add('active');
          if (lorenzAnimation) {
            lorenzAnimationId = requestAnimationFrame(lorenzAnimation.frame);
          }
        } else if (animation === 'mandelbulb') {
          if (!mandelbulbInitialized) {
            initMandelbulbAnimation();
            mandelbulbInitialized = true;
          }
          mandelbulbCanvas.classList.add('active');
          if (mandelbulbAnimation) {
            mandelbulbAnimationId = requestAnimationFrame(mandelbulbAnimation.frame);
          }
        }
      }, 50);

      currentAnimation = animation;
    }

    phase1Btn.addEventListener('click', () => switchAnimation('hopf'));
    phase2Btn.addEventListener('click', () => switchAnimation('klein'));
    phase3Btn.addEventListener('click', () => switchAnimation('lorenz'));
    phase4Btn.addEventListener('click', () => switchAnimation('mandelbulb'));

    function initHopfAnimation() {
      const canvas = hopfCanvas;
      const ctx = canvas.getContext('2d', { alpha: false });

      let cx = 0, cy = 0, baseScale = 180;
      const setSize = () => {
        const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
        const w = Math.floor(canvas.clientWidth || window.innerWidth);
        const h = Math.floor(canvas.clientHeight || window.innerHeight);
        canvas.width = Math.floor(w * dpr);
        canvas.height = Math.floor(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        cx = w / 2;
        cy = h / 2;
        baseScale = Math.min(w, h) * 0.38;
      };

      const MAX_FIBERS_DESKTOP = 45;
      const MAX_FIBERS_MOBILE = 22;
      const POINTS_PER_FIBER = 120;
      const TRAIL_FADE = 0.032;
      const REDUCED_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;

      const isMobile = /Mobi|Android/i.test(navigator.userAgent) ||
                       Math.min(window.innerWidth, window.innerHeight) < 768;
      const FIBER_COUNT = REDUCED_MOTION ? 12 : (isMobile ? MAX_FIBERS_MOBILE : MAX_FIBERS_DESKTOP);

      const GLOW_COUNT = isMobile ? 200 : 400;
      const glows = new Array(GLOW_COUNT).fill(0).map(() => ({
        x: Math.random(),
        y: Math.random(),
        r: Math.random() * 1.6 + 0.4,
        phase: Math.random() * Math.PI * 2,
        speed: 0.4 + Math.random() * 0.8
      }));

      function drawGlows(time) {
        const w = cx * 2, h = cy * 2;
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        for (let i = 0; i < GLOW_COUNT; i++) {
          const g = glows[i];
          const pulse = 0.35 + 0.65 * Math.sin(time * 0.0012 * g.speed + g.phase);
          ctx.globalAlpha = 0.12 + 0.28 * pulse;
          ctx.beginPath();
          ctx.arc(g.x * w, g.y * h, g.r, 0, Math.PI * 2);
          const hue = (190 + 100 * Math.sin(g.phase + time * 0.0004)) % 360;
          ctx.fillStyle = `hsla(${hue}, 90%, 68%, 1)`;
          ctx.fill();
        }
        ctx.restore();
      }

      function quaternionToS3(theta, phi, psi) {
        const a = Math.cos(theta / 2) * Math.cos(psi);
        const b = Math.cos(theta / 2) * Math.sin(psi);
        const c = Math.sin(theta / 2) * Math.cos(phi + psi);
        const d = Math.sin(theta / 2) * Math.sin(phi + psi);
        return [a, b, c, d];
      }

      function hopfMap(q) {
        const [a, b, c, d] = q;
        const denom = 1 - d;
        if (Math.abs(denom) < 0.001) return [0, 0, 100];

        const x = a / denom;
        const y = b / denom;
        const z = c / denom;
        return [x, y, z];
      }

      function generateFiber(baseTheta, basePhi, samples) {
        const points = [];
        for (let i = 0; i < samples; i++) {
          const psi = (i / samples) * Math.PI * 2;
          const q = quaternionToS3(baseTheta, basePhi, psi);
          const p = hopfMap(q);
          points.push(p);
        }
        return points;
      }

      function generateVillarceauCircle(u, v, samples) {
        const points = [];
        const R = 2.2;
        const r = 0.8;

        for (let i = 0; i < samples; i++) {
          const t = (i / samples) * Math.PI * 2;

          const a = (R + r * Math.cos(v)) * Math.cos(u + t);
          const b = (R + r * Math.cos(v)) * Math.sin(u + t);
          const c = r * Math.sin(v) * Math.cos(t);
          const d = r * Math.sin(v) * Math.sin(t);

          const norm = Math.sqrt(a*a + b*b + c*c + d*d) || 1;
          const q = [a/norm, b/norm, c/norm, d/norm];
          points.push(hopfMap(q));
        }
        return points;
      }

      const fibers = [];
      const fiberColors = [];
      const fiberEnergies = new Float32Array(FIBER_COUNT);

      for (let i = 0; i < FIBER_COUNT; i++) {
        const u = (i / FIBER_COUNT) * Math.PI * 2;
        const v = Math.sin(u * 3) * 0.6;

        const useVillarceau = i % 3 === 0;

        if (useVillarceau) {
          fibers.push(generateVillarceauCircle(u, v, POINTS_PER_FIBER));
        } else {
          const theta = Math.PI * 0.3 + (i / FIBER_COUNT) * Math.PI * 0.6;
          const phi = u;
          fibers.push(generateFiber(theta, phi, POINTS_PER_FIBER));
        }

        const hue = (u / (Math.PI * 2)) * 280 + 160;
        fiberColors.push(hue);
        fiberEnergies[i] = Math.random();
      }

      const prevPositions = fibers.map(f => f.map(() => [0, 0]));
      const specialPoints = new Array(Math.floor(FIBER_COUNT * 0.25)).fill(0).map(() => ({
        fiberIdx: Math.floor(Math.random() * FIBER_COUNT),
        pointIdx: Math.floor(Math.random() * POINTS_PER_FIBER),
        phase: Math.random() * Math.PI * 2
      }));

      let t0 = performance.now();
      let mouseX = 0, mouseY = 0;

      function handlePointerMove(e) {
        const w = canvas.clientWidth || window.innerWidth;
        const h = canvas.clientHeight || window.innerHeight;
        mouseX = (e.clientX / w) * 2 - 1;
        mouseY = (e.clientY / h) * 2 - 1;
      }

      function handleResize() {
        setSize();
      }

      window.addEventListener('pointermove', handlePointerMove, { passive: true });
      window.addEventListener('resize', handleResize, { passive: true });
      setSize();

      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      function fadeFrame() {
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = `rgba(0,0,0,${TRAIL_FADE})`;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.restore();
      }

      function rotate3D(p, rx, ry, rz) {
        let x = p[0], y = p[1], z = p[2];

        let c = Math.cos(rx), s = Math.sin(rx);
        let y1 = c * y - s * z, z1 = s * y + c * z;
        y = y1; z = z1;

        c = Math.cos(ry); s = Math.sin(ry);
        let x1 = c * x + s * z;
        z1 = -s * x + c * z;
        x = x1; z = z1;

        c = Math.cos(rz); s = Math.sin(rz);
        x1 = c * x - s * y;
        y1 = s * x + c * y;

        return [x1, y1, z1];
      }

      function project(p, breathing) {
        const scale = baseScale * breathing;
        const perspective = 4.5;
        const depth = perspective + p[2];
        const factor = scale / (depth < 0.1 ? 0.1 : depth);

        return [
          cx + p[0] * factor,
          cy + p[1] * factor,
          factor
        ];
      }

      function hopfFrame(now) {
        if (currentAnimation !== 'hopf') return;

        const dt = Math.max(0.001, Math.min(0.033, (now - t0) / 1000));
        t0 = now;

        fadeFrame();
        drawGlows(now);

        const T = now * 0.001;
        const breathing = 1.0 + 0.14 * Math.sin(T * 0.65) + 0.07 * Math.cos(T * 0.38 + 1.1);

        const rx = 0.35 * T + 0.45 * Math.sin(T * 0.7) + mouseY * 0.5;
        const ry = 0.5 * T + 0.35 * Math.cos(T * 0.85) + mouseX * 0.6;
        const rz = 0.25 * T + 0.25 * Math.sin(T * 0.55 + 0.8);

        ctx.save();

        for (let i = 0; i < FIBER_COUNT; i++) {
          const fiber = fibers[i];
          const prev = prevPositions[i];
          const hue = fiberColors[i];

          fiberEnergies[i] += dt * 0.5;
          const energy = 0.6 + 0.4 * Math.sin(fiberEnergies[i]);

          for (let j = 0; j < POINTS_PER_FIBER; j++) {
            let p = fiber[j];
            p = rotate3D(p, rx, ry, rz);

            const [sx, sy, sc] = project(p, breathing);
            const [px, py] = prev[j];

            const loopPos = j / POINTS_PER_FIBER;
            const loopAlpha = 0.5 + 0.5 * Math.sin(loopPos * Math.PI);
            const depthAlpha = 0.7 * loopAlpha * (0.55 + 0.45 * Math.min(1, sc / (baseScale * 0.024)));

            ctx.globalAlpha = depthAlpha * energy;
            ctx.lineWidth = Math.max(0.9, Math.min(4.5, sc * 0.014));
            ctx.strokeStyle = `hsla(${hue}, 92%, 62%, 1)`;

            ctx.beginPath();
            ctx.moveTo(px || sx, py || sy);
            ctx.lineTo(sx, sy);
            ctx.stroke();

            prev[j] = [sx, sy];
          }
        }

        ctx.globalAlpha = 0.88;
        for (let i = 0; i < specialPoints.length; i++) {
          const sp = specialPoints[i];
          const fiber = fibers[sp.fiberIdx];
          let p = fiber[sp.pointIdx];

          p = rotate3D(p, rx, ry, rz);
          const [sx, sy, sc] = project(p, breathing);

          const pulse = 0.65 + 0.35 * Math.sin(T * 1.8 + sp.phase);
          const size = Math.max(2.2, Math.min(5.5, sc * 0.018 * pulse));

          ctx.beginPath();
          ctx.arc(sx, sy, size, 0, Math.PI * 2);
          const hue = fiberColors[sp.fiberIdx];
          ctx.fillStyle = `hsla(${hue}, 100%, 72%, ${0.85 * pulse})`;
          ctx.fill();

          ctx.globalAlpha = 0.4 * pulse;
          ctx.beginPath();
          ctx.arc(sx, sy, size * 1.8, 0, Math.PI * 2);
          ctx.strokeStyle = `hsla(${hue}, 100%, 75%, 0.6)`;
          ctx.lineWidth = 1;
          ctx.stroke();
        }

        ctx.restore();

        if (currentAnimation === 'hopf') {
          hopfAnimationId = requestAnimationFrame(hopfFrame);
        }
      }

      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      hopfAnimation = {
        frame: hopfFrame,
        canvas: canvas,
        ctx: ctx
      };
    }

    function initKleinAnimation() {
      const canvas = kleinCanvas;
      const ctx = canvas.getContext('2d', { alpha: false });

      let cx = 0, cy = 0, baseScale = 300, breathScale = 1;
      const setSize = () => {
        const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
        const w = Math.floor(canvas.clientWidth || window.innerWidth);
        const h = Math.floor(canvas.clientHeight || window.innerHeight);
        canvas.width = Math.floor(w * dpr);
        canvas.height = Math.floor(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        cx = w / 2;
        cy = h / 2;
        baseScale = Math.min(w, h) * 1.12;
      };

      const MAX_CURVES_DESKTOP = 140;
      const MAX_CURVES_MOBILE = 70;
      const POINTS_PER_CURVE = 42;
      const TRAIL_FADE = 0.055;
      const LINE_ALPHA = 0.72;
      const REDUCED_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;

      const isMobile = /Mobi|Android/i.test(navigator.userAgent) ||
                       Math.min(window.innerWidth, window.innerHeight) < 768;
      const CURVE_COUNT = REDUCED_MOTION ? 35 : (isMobile ? MAX_CURVES_MOBILE : MAX_CURVES_DESKTOP);

      const NEBULA_COUNT = (isMobile ? 180 : 360);
      const nebula = new Array(NEBULA_COUNT).fill(0).map(() => ({
        x: Math.random(),
        y: Math.random(),
        r: Math.random() * 1.8 + 0.4,
        p: Math.random() * Math.PI * 2,
        spd: 0.3 + Math.random() * 0.7
      }));

      function drawNebula(time) {
        const w = cx * 2, h = cy * 2;
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        for (let i = 0; i < NEBULA_COUNT; i++) {
          const n = nebula[i];
          const pulse = 0.4 + 0.6 * Math.sin(time * 0.001 * n.spd + n.p);
          ctx.globalAlpha = 0.1 + 0.35 * pulse;
          ctx.beginPath();
          ctx.arc(n.x * w, n.y * h, n.r, 0, Math.PI * 2);
          const hue = (280 + 80 * Math.sin(n.p + time * 0.0003)) % 360;
          ctx.fillStyle = `hsla(${hue}, 85%, 65%, 1)`;
          ctx.fill();
        }
        ctx.restore();
      }

      function kleinBottle(u, v) {
        const r = 4 * (1 - Math.cos(u) / 2);

        if (u < Math.PI) {
          const x = 6 * Math.cos(u) * (1 + Math.sin(u)) + r * Math.cos(u) * Math.cos(v);
          const y = 16 * Math.sin(u) + r * Math.sin(u) * Math.cos(v);
          const z = r * Math.sin(v);
          return [x, y, z];
        } else {
          const x = 6 * Math.cos(u) * (1 + Math.sin(u)) - r * Math.cos(v);
          const y = 16 * Math.sin(u);
          const z = r * Math.sin(v);
          return [x, y, z];
        }
      }

      function torusKnot(t, p, q) {
        const r = 2 + Math.cos(q * t);
        const x = r * Math.cos(p * t);
        const y = r * Math.sin(p * t);
        const z = Math.sin(q * t);
        return [x, y, z];
      }

      const curves = [];
      const curveColors = [];
      const curveTypes = new Uint8Array(CURVE_COUNT);

      for (let i = 0; i < CURVE_COUNT; i++) {
        const points = [];
        const isKlein = i < CURVE_COUNT * 0.6;
        curveTypes[i] = isKlein ? 0 : 1;

        if (isKlein) {
          const u_offset = (i / (CURVE_COUNT * 0.6)) * Math.PI * 2;
          for (let j = 0; j < POINTS_PER_CURVE; j++) {
            const t = j / (POINTS_PER_CURVE - 1);
            const v = t * Math.PI * 2;
            const u = u_offset + t * Math.PI * 0.3;
            points.push(kleinBottle(u, v));
          }
          const hue = (u_offset / (Math.PI * 2)) * 180 + 180;
          curveColors.push(hue);
        } else {
          const p = 2 + Math.floor(Math.random() * 3);
          const q = 3 + Math.floor(Math.random() * 4);
          const phase = Math.random() * Math.PI * 2;
          for (let j = 0; j < POINTS_PER_CURVE; j++) {
            const t = (j / (POINTS_PER_CURVE - 1)) * Math.PI * 2 + phase;
            points.push(torusKnot(t, p, q));
          }
          curveColors.push(Math.random() * 360);
        }
        curves.push(points);
      }

      const prevPositions = curves.map(c => c.map(() => [0, 0]));
      const glowParticles = new Array(Math.floor(CURVE_COUNT * 0.15)).fill(0).map(() => ({
        curveIdx: Math.floor(Math.random() * CURVE_COUNT),
        pointIdx: Math.floor(Math.random() * POINTS_PER_CURVE),
        phase: Math.random() * Math.PI * 2
      }));

      let t0 = performance.now();
      let pointerX = 0, pointerY = 0;

      function handlePointerMove(e) {
        const w = canvas.clientWidth || window.innerWidth;
        const h = canvas.clientHeight || window.innerHeight;
        pointerX = (e.clientX / w) * 2 - 1;
        pointerY = (e.clientY / h) * 2 - 1;
      }

      function handleResize() {
        setSize();
      }

      window.addEventListener('pointermove', handlePointerMove, { passive: true });
      window.addEventListener('resize', handleResize, { passive: true });
      setSize();

      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      function fadeFrame() {
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = `rgba(0,0,0,${TRAIL_FADE})`;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.restore();
      }

      function rotateX(p, angle) {
        const c = Math.cos(angle), s = Math.sin(angle);
        return [p[0], c * p[1] - s * p[2], s * p[1] + c * p[2]];
      }

      function rotateY(p, angle) {
        const c = Math.cos(angle), s = Math.sin(angle);
        return [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]];
      }

      function rotateZ(p, angle) {
        const c = Math.cos(angle), s = Math.sin(angle);
        return [c * p[0] - s * p[1], s * p[0] + c * p[1], p[2]];
      }

      function project3D(p) {
        const zCam = 35;
        const focalLength = baseScale * breathScale * 0.85;
        const denom = zCam - p[2];
        const scale = focalLength / (denom <= 0.1 ? 0.1 : denom);

        const sx = cx + p[0] * scale;
        const sy = cy + p[1] * scale;
        return [sx, sy, scale];
      }

      function kleinFrame(now) {
        if (currentAnimation !== 'klein') return;

        const dt = Math.max(0.001, Math.min(0.033, (now - t0) / 1000));
        t0 = now;

        fadeFrame();
        drawNebula(now);

        const T = now * 0.001;
        breathScale = 1.0 + 0.15 * Math.sin(T * 0.7) + 0.08 * Math.cos(T * 0.4 + 0.9);

        const rx = 0.4 * T + 0.5 * Math.sin(T * 0.6) + pointerY * 0.4;
        const ry = 0.55 * T + 0.4 * Math.cos(T * 0.8) + pointerX * 0.5;
        const rz = 0.3 * T + 0.3 * Math.sin(T * 0.5 + 1.2);

        ctx.save();

        for (let i = 0; i < CURVE_COUNT; i++) {
          const curve = curves[i];
          const prev = prevPositions[i];
          const hue = curveColors[i];
          const isKlein = curveTypes[i] === 0;

          ctx.strokeStyle = `hsla(${hue}, ${isKlein ? 95 : 85}%, ${isKlein ? 58 : 65}%, ${LINE_ALPHA})`;

          for (let j = 0; j < POINTS_PER_CURVE - 1; j++) {
            let p = curve[j];
            p = rotateX(p, rx);
            p = rotateY(p, ry);
            p = rotateZ(p, rz);

            const [sx, sy, sc] = project3D(p);
            const [px, py] = prev[j];

            const depthAlpha = LINE_ALPHA * (0.5 + 0.5 * Math.min(1, sc / (baseScale * 0.02)));
            ctx.globalAlpha = depthAlpha;
            ctx.lineWidth = Math.max(0.8, Math.min(5, sc * 0.012));

            ctx.beginPath();
            ctx.moveTo(px || sx, py || sy);
            ctx.lineTo(sx, sy);
            ctx.stroke();

            prev[j] = [sx, sy];
          }
        }

        ctx.globalAlpha = 0.85;
        for (let i = 0; i < glowParticles.length; i++) {
          const gp = glowParticles[i];
          const curve = curves[gp.curveIdx];
          let p = curve[gp.pointIdx];

          p = rotateX(p, rx);
          p = rotateY(p, ry);
          p = rotateZ(p, rz);

          const [sx, sy, sc] = project3D(p);
          const pulse = 0.6 + 0.4 * Math.sin(T * 2 + gp.phase);
          const size = Math.max(2, Math.min(6, sc * 0.015 * pulse));

          ctx.beginPath();
          ctx.arc(sx, sy, size, 0, Math.PI * 2);
          const hue = curveColors[gp.curveIdx];
          ctx.fillStyle = `hsla(${hue}, 100%, 70%, ${0.8 * pulse})`;
          ctx.fill();
        }

        ctx.restore();

        if (currentAnimation === 'klein') {
          kleinAnimationId = requestAnimationFrame(kleinFrame);
        }
      }

      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      kleinAnimation = {
        frame: kleinFrame,
        canvas: canvas,
        ctx: ctx
      };
    }

    function initLorenzAnimation() {
      const canvas = lorenzCanvas;
      const ctx = canvas.getContext('2d', { alpha: false });

      let cx = 0, cy = 0, baseScale = 8;
      const setSize = () => {
        const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
        const w = Math.floor(canvas.clientWidth || window.innerWidth);
        const h = Math.floor(canvas.clientHeight || window.innerHeight);
        canvas.width = Math.floor(w * dpr);
        canvas.height = Math.floor(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        cx = w / 2;
        cy = h / 2;
        baseScale = Math.min(w, h) * 0.045;
      };

      const MAX_ATTRACTORS_DESKTOP = 18;
      const MAX_ATTRACTORS_MOBILE = 10;
      const HISTORY_LENGTH = 700;
      const TRAIL_FADE = 0.038;
      const REDUCED_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;

      const isMobile = /Mobi|Android/i.test(navigator.userAgent) ||
                       Math.min(window.innerWidth, window.innerHeight) < 768;
      const ATTRACTOR_COUNT = REDUCED_MOTION ? 6 : (isMobile ? MAX_ATTRACTORS_MOBILE : MAX_ATTRACTORS_DESKTOP);

      const DUST_COUNT = isMobile ? 150 : 300;
      const dust = new Array(DUST_COUNT).fill(0).map(() => ({
        x: Math.random(),
        y: Math.random(),
        z: Math.random(),
        r: Math.random() * 1.2 + 0.2,
        vx: (Math.random() - 0.5) * 0.0002,
        vy: (Math.random() - 0.5) * 0.0002,
        phase: Math.random() * Math.PI * 2
      }));

      function drawDust(time) {
        const w = cx * 2, h = cy * 2;
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        for (let i = 0; i < DUST_COUNT; i++) {
          const d = dust[i];
          d.x = (d.x + d.vx + 1) % 1;
          d.y = (d.y + d.vy + 1) % 1;

          const twinkle = 0.4 + 0.6 * Math.sin(time * 0.001 + d.phase);
          ctx.globalAlpha = 0.05 + 0.15 * twinkle;
          ctx.beginPath();
          ctx.arc(d.x * w, d.y * h, d.r, 0, Math.PI * 2);
          const hue = (30 + 30 * d.z) % 360;
          ctx.fillStyle = `hsla(${hue}, 50%, 50%, 1)`;
          ctx.fill();
        }
        ctx.restore();
      }

      class LorenzAttractor {
        constructor(x0, y0, z0, sigma, rho, beta, hue) {
          this.x = x0;
          this.y = y0;
          this.z = z0;
          this.sigma = sigma;
          this.rho = rho;
          this.beta = beta;
          this.hue = hue;
          this.history = [];
          this.energy = 0;
        }

        step(dt) {
          const dx = this.sigma * (this.y - this.x);
          const dy = this.x * (this.rho - this.z) - this.y;
          const dz = this.x * this.y - this.beta * this.z;

          this.x += dx * dt;
          this.y += dy * dt;
          this.z += dz * dt;

          this.energy = Math.sqrt(dx*dx + dy*dy + dz*dz);

          this.history.push([this.x, this.y, this.z]);
          if (this.history.length > HISTORY_LENGTH) {
            this.history.shift();
          }
        }
      }

      const attractors = [];
      const baseParams = { sigma: 10, rho: 28, beta: 8/3 };

      for (let i = 0; i < ATTRACTOR_COUNT; i++) {
        const angle = (i / ATTRACTOR_COUNT) * Math.PI * 2;
        const radius = 0.1 + Math.random() * 0.4;

        const x0 = radius * Math.cos(angle);
        const y0 = radius * Math.sin(angle);
        const z0 = 20 + Math.random() * 8;

        const sigma = baseParams.sigma + (Math.random() - 0.5) * 1.0;
        const rho = baseParams.rho + (Math.random() - 0.5) * 2;
        const beta = baseParams.beta + (Math.random() - 0.5) * 0.3;

        const hue = (i / ATTRACTOR_COUNT) * 360;

        attractors.push(new LorenzAttractor(x0, y0, z0, sigma, rho, beta, hue));
      }

      let t0 = performance.now();
      let rotX = 0, rotY = 0, rotZ = 0;
      let targetRotX = 0, targetRotY = 0;

      function handlePointerMove(e) {
        const w = canvas.clientWidth || window.innerWidth;
        const h = canvas.clientHeight || window.innerHeight;
        targetRotX = ((e.clientY / h) * 2 - 1) * 0.4;
        targetRotY = ((e.clientX / w) * 2 - 1) * 0.5;
      }

      function handleResize() {
        setSize();
      }

      window.addEventListener('pointermove', handlePointerMove, { passive: true });
      window.addEventListener('resize', handleResize, { passive: true });
      setSize();

      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      function fadeFrame() {
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = `rgba(0,0,0,${TRAIL_FADE})`;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.restore();
      }

      function rotatePoint(p, rx, ry, rz) {
        let x = p[0], y = p[1], z = p[2];

        let c = Math.cos(rx), s = Math.sin(rx);
        let y1 = c * y - s * z;
        let z1 = s * y + c * z;
        y = y1; z = z1;

        c = Math.cos(ry); s = Math.sin(ry);
        let x1 = c * x + s * z;
        z1 = -s * x + c * z;
        x = x1; z = z1;

        c = Math.cos(rz); s = Math.sin(rz);
        x1 = c * x - s * y;
        y1 = s * x + c * y;

        return [x1, y1, z1];
      }

      function project(p) {
        const perspective = 280;
        const scale = baseScale * (perspective / (perspective + p[2]));
        return [
          cx + p[0] * scale,
          cy - p[1] * scale,
          scale
        ];
      }

      function lorenzFrame(now) {
        if (currentAnimation !== 'lorenz') return;

        const dt = Math.max(0.001, Math.min(0.033, (now - t0) / 1000));
        t0 = now;

        fadeFrame();
        drawDust(now);

        const T = now * 0.001;

        rotX += (targetRotX - rotX) * 0.03;
        rotY += (targetRotY - rotY) * 0.03;
        rotZ = T * 0.12;

        ctx.save();

        for (let i = 0; i < ATTRACTOR_COUNT; i++) {
          const attr = attractors[i];

          const steps = 2;
          for (let s = 0; s < steps; s++) {
            attr.step(dt / steps);
          }

          const history = attr.history;
          if (history.length < 2) continue;

          for (let j = 1; j < history.length; j++) {
            const p1 = rotatePoint(history[j-1], rotX, rotY, rotZ);
            const p2 = rotatePoint(history[j], rotY, rotY, rotZ);

            const [x1, y1, s1] = project(p1);
            const [x2, y2, s2] = project(p2);

            const age = j / history.length;
            const alpha = 0.5 * age * age;

            const hueShift = (history[j][2] / 50) * 40;
            const hue = (attr.hue + hueShift) % 360;

            ctx.strokeStyle = `hsla(${hue}, 70%, 55%, ${alpha})`;
            ctx.lineWidth = Math.max(0.3, Math.min(2.5, s2 * 0.02 * (0.7 + 0.3 * age)));

            ctx.beginPath();
            ctx.moveTo(x1, y1);
            ctx.lineTo(x2, y2);
            ctx.stroke();
          }

          const current = rotatePoint([attr.x, attr.y, attr.z], rotX, rotY, rotZ);
          const [cx_pt, cy_pt, sc] = project(current);

          ctx.save();
          ctx.globalAlpha = 0.7;
          ctx.beginPath();
          const energyFactor = Math.min(1, attr.energy / 40);
          ctx.arc(cx_pt, cy_pt, Math.max(1.0, Math.min(3.0, sc * 0.025 * (1 + energyFactor))), 0, Math.PI * 2);
          ctx.fillStyle = `hsla(${attr.hue}, 90%, 65%, 0.7)`;
          ctx.fill();
          ctx.restore();
        }

        ctx.globalAlpha = 0.05;
        ctx.lineWidth = 0.6;
        for (let i = 0; i < ATTRACTOR_COUNT; i++) {
          const a1 = attractors[i];
          const p1 = rotatePoint([a1.x, a1.y, a1.z], rotX, rotY, rotZ);
          const [x1, y1] = project(p1);

          for (let j = i + 1; j < Math.min(i + 3, ATTRACTOR_COUNT); j++) {
            const a2 = attractors[j];
            const dx = a1.x - a2.x;
            const dy = a1.y - a2.y;
            const dz = a1.z - a2.z;
            const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);

            if (dist < 20) {
              const p2 = rotatePoint([a2.x, a2.y, a2.z], rotX, rotY, rotZ);
              const [x2, y2] = project(p2);

              const hueMix = (a1.hue + a2.hue) / 2;
              ctx.strokeStyle = `hsla(${hueMix}, 60%, 50%, ${0.05 * (1 - dist/20)})`;
              ctx.beginPath();
              ctx.moveTo(x1, y1);
              ctx.lineTo(x2, y2);
              ctx.stroke();
            }
          }
        }

        ctx.restore();

        if (currentAnimation === 'lorenz') {
          lorenzAnimationId = requestAnimationFrame(lorenzFrame);
        }
      }

      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      lorenzAnimation = {
        frame: lorenzFrame,
        canvas: canvas,
        ctx: ctx
      };
    }

    function initMandelbulbAnimation() {
      const canvas = mandelbulbCanvas;
      const ctx = canvas.getContext('2d', { alpha: false });

      let cx = 0, cy = 0, baseScale = 160;
      const setSize = () => {
        const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
        const w = Math.floor(canvas.clientWidth || window.innerWidth);
        const h = Math.floor(canvas.clientHeight || window.innerHeight);
        canvas.width = Math.floor(w * dpr);
        canvas.height = Math.floor(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        cx = w / 2;
        cy = h / 2;
        baseScale = Math.min(w, h) * 0.32;
      };

      const MAX_RAYS_DESKTOP = 3200;
      const MAX_RAYS_MOBILE = 1600;
      const MAX_STEPS = 48;
      const TRAIL_FADE = 0.038;
      const REDUCED_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;

      const isMobile = /Mobi|Android/i.test(navigator.userAgent) ||
                       Math.min(window.innerWidth, window.innerHeight) < 768;
      const RAY_COUNT = REDUCED_MOTION ? 800 : (isMobile ? MAX_RAYS_MOBILE : MAX_RAYS_DESKTOP);

      const FOG_COUNT = isMobile ? 160 : 320;
      const fog = new Array(FOG_COUNT).fill(0).map(() => ({
        x: Math.random(),
        y: Math.random(),
        r: Math.random() * 2.2 + 0.5,
        vx: (Math.random() - 0.5) * 0.00015,
        vy: (Math.random() - 0.5) * 0.00015,
        phase: Math.random() * Math.PI * 2
      }));

      function drawFog(time) {
        const w = cx * 2, h = cy * 2;
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        for (let i = 0; i < FOG_COUNT; i++) {
          const f = fog[i];
          f.x = (f.x + f.vx + 1) % 1;
          f.y = (f.y + f.vy + 1) % 1;

          const drift = 0.25 + 0.75 * Math.sin(time * 0.0018 + f.phase);
          ctx.globalAlpha = 0.06 + 0.2 * drift;
          ctx.beginPath();
          ctx.arc(f.x * w, f.y * h, f.r, 0, Math.PI * 2);
          const hue = (320 + 50 * Math.sin(f.phase)) % 360;
          ctx.fillStyle = `hsla(${hue}, 75%, 55%, 1)`;
          ctx.fill();
        }
        ctx.restore();
      }

      function mandelbulbDistance(pos, power, maxIter) {
        let x = pos[0], y = pos[1], z = pos[2];
        const cx = x, cy = y, cz = z;

        let dr = 1.0;
        let r = 0.0;

        for (let i = 0; i < maxIter; i++) {
          r = Math.sqrt(x*x + y*y + z*z);

          if (r > 2.0) break;

          let theta = Math.atan2(Math.sqrt(x*x + y*y), z);
          let phi = Math.atan2(y, x);

          dr = Math.pow(r, power - 1) * power * dr + 1.0;

          const zr = Math.pow(r, power);
          theta = theta * power;
          phi = phi * power;

          x = zr * Math.sin(theta) * Math.cos(phi) + cx;
          y = zr * Math.sin(theta) * Math.sin(phi) + cy;
          z = zr * Math.cos(theta) + cz;
        }

        return 0.5 * Math.log(r) * r / dr;
      }

      function julia3D(pos, c, power, maxIter) {
        let x = pos[0], y = pos[1], z = pos[2];
        const [cx, cy, cz] = c;

        let r = 0.0;

        for (let i = 0; i < maxIter; i++) {
          r = Math.sqrt(x*x + y*y + z*z);
          if (r > 2.0) return i / maxIter;

          let theta = Math.atan2(Math.sqrt(x*x + y*y), z);
          let phi = Math.atan2(y, x);

          const zr = Math.pow(r, power);
          theta = theta * power;
          phi = phi * power;

          x = zr * Math.sin(theta) * Math.cos(phi) + cx;
          y = zr * Math.sin(theta) * Math.sin(phi) + cy;
          z = zr * Math.cos(theta) + cz;
        }

        return 0.0;
      }

      class RayMarcher {
        constructor(origin, direction, hue, power, useJulia) {
          this.origin = origin;
          this.direction = direction;
          this.hue = hue;
          this.power = power;
          this.useJulia = useJulia;
          this.juliaC = [
            Math.sin(hue * 0.02) * 0.3,
            Math.cos(hue * 0.02) * 0.3,
            Math.sin(hue * 0.03 + 1) * 0.2
          ];
          this.path = [];
          this.hit = false;
          this.brightness = 0;
        }

        march(maxDist) {
          let t = 0;
          const pos = [...this.origin];

          for (let step = 0; step < MAX_STEPS; step++) {
            pos[0] = this.origin[0] + this.direction[0] * t;
            pos[1] = this.origin[1] + this.direction[1] * t;
            pos[2] = this.origin[2] + this.direction[2] * t;

            let dist;
            if (this.useJulia) {
              const juliaVal = julia3D(pos, this.juliaC, this.power, 8);
              dist = 0.1 + juliaVal * 0.5;
            } else {
              dist = mandelbulbDistance(pos, this.power, 12);
            }

            this.path.push([...pos, dist]);

            if (dist < 0.005) {
              this.hit = true;
              this.brightness = 1.0 - (step / MAX_STEPS);
              break;
            }

            if (t > maxDist) break;

            t += dist * 0.8;
          }
        }
      }

      const rays = [];
      const goldenRatio = (1 + Math.sqrt(5)) / 2;

      for (let i = 0; i < RAY_COUNT; i++) {
        const theta = 2 * Math.PI * i / goldenRatio;
        const phi = Math.acos(1 - 2 * (i + 0.5) / RAY_COUNT);

        const direction = [
          Math.sin(phi) * Math.cos(theta),
          Math.sin(phi) * Math.sin(theta),
          Math.cos(phi)
        ];

        const origin = [-3, 0, 0];
        const hue = (i / RAY_COUNT) * 360;
        const power = 8 + Math.sin(i * 0.1) * 1.5;
        const useJulia = i % 5 === 0;

        rays.push(new RayMarcher(origin, direction, hue, power, useJulia));
      }

      let t0 = performance.now();
      let needsReMarch = true;
      let camAngleX = 0, camAngleY = 0;
      let targetAngleX = 0, targetAngleY = 0;

      function handlePointerMove(e) {
        const w = canvas.clientWidth || window.innerWidth;
        const h = canvas.clientHeight || window.innerHeight;
        targetAngleX = ((e.clientY / h) * 2 - 1) * 0.8;
        targetAngleY = ((e.clientX / w) * 2 - 1) * 1.2;
      }

      function handleResize() {
        setSize();
      }

      window.addEventListener('pointermove', handlePointerMove, { passive: true });
      window.addEventListener('resize', handleResize, { passive: true });
      setSize();

      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      function fadeFrame() {
        ctx.save();
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = `rgba(0,0,0,${TRAIL_FADE})`;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.restore();
      }

      function rotateY3D(p, angle) {
        const c = Math.cos(angle), s = Math.sin(angle);
        return [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]];
      }

      function rotateX3D(p, angle) {
        const c = Math.cos(angle), s = Math.sin(angle);
        return [p[0], c * p[1] - s * p[2], s * p[1] + c * p[2]];
      }

      function project(p, breathing) {
        const scale = baseScale * breathing;
        const perspective = 3.8;
        const depth = perspective + p[2];
        const factor = scale / (depth < 0.1 ? 0.1 : depth);

        return [
          cx + p[0] * factor,
          cy - p[1] * factor,
          factor
        ];
      }

      function mandelbulbFrame(now) {
        if (currentAnimation !== 'mandelbulb') return;

        const dt = Math.max(0.001, Math.min(0.033, (now - t0) / 1000));
        t0 = now;

        fadeFrame();
        drawFog(now);

        const T = now * 0.001;
        const breathing = 1.0 + 0.12 * Math.sin(T * 0.6) + 0.06 * Math.cos(T * 0.35 + 1.3);

        camAngleX += (targetAngleX - camAngleX) * 0.04;
        camAngleY += (targetAngleY - camAngleY) * 0.04;

        const autoRotY = T * 0.2;
        const totalRotY = autoRotY + camAngleY;

        if (Math.floor(T * 2) % 3 === 0 && needsReMarch) {
          for (let i = 0; i < RAY_COUNT; i++) {
            rays[i].power = 8 + Math.sin(T * 0.5 + i * 0.1) * 1.5;
            rays[i].path = [];
            rays[i].march(5.0);
          }
          needsReMarch = false;
        } else if (Math.floor(T * 2) % 3 !== 0) {
          needsReMarch = true;
        }

        ctx.save();

        for (let i = 0; i < RAY_COUNT; i++) {
          const ray = rays[i];
          if (ray.path.length < 2) continue;

          const baseHue = ray.hue;
          const isJulia = ray.useJulia;

          for (let j = 1; j < ray.path.length; j++) {
            let p1 = ray.path[j-1].slice(0, 3);
            let p2 = ray.path[j].slice(0, 3);

            p1 = rotateY3D(p1, totalRotY);
            p1 = rotateX3D(p1, camAngleX);
            p2 = rotateY3D(p2, totalRotY);
            p2 = rotateX3D(p2, camAngleX);

            const [x1, y1, s1] = project(p1, breathing);
            const [x2, y2, s2] = project(p2, breathing);

            const progress = j / ray.path.length;
            const distToSurface = ray.path[j][3];
            const proximity = Math.max(0, 1 - distToSurface * 20);

            let alpha = 0.4 * progress * (0.7 + 0.3 * proximity);
            if (ray.hit && j > ray.path.length - 5) {
              alpha = ray.brightness * (1 + progress);
            }

            const hueShift = proximity * 80 + progress * 30;
            const hue = (baseHue + hueShift) % 360;

            ctx.globalAlpha = alpha;
            ctx.lineWidth = Math.max(0.4, Math.min(3.5, s2 * 0.011 * (1 + proximity * 2)));
            ctx.strokeStyle = `hsla(${hue}, ${isJulia ? 85 : 95}%, ${60 + proximity * 15}%, 1)`;

            ctx.beginPath();
            ctx.moveTo(x1, y1);
            ctx.lineTo(x2, y2);
            ctx.stroke();
          }

          if (ray.hit) {
            let hitPos = ray.path[ray.path.length - 1].slice(0, 3);
            hitPos = rotateY3D(hitPos, totalRotY);
            hitPos = rotateX3D(hitPos, camAngleX);

            const [hx, hy, hs] = project(hitPos, breathing);

            ctx.save();
            ctx.globalAlpha = ray.brightness * 0.9;
            const glowSize = Math.max(1.8, Math.min(5, hs * 0.02));

            ctx.beginPath();
            ctx.arc(hx, hy, glowSize, 0, Math.PI * 2);
            ctx.fillStyle = `hsla(${ray.hue}, 100%, 75%, 0.95)`;
            ctx.fill();

            ctx.globalAlpha = ray.brightness * 0.4;
            ctx.beginPath();
            ctx.arc(hx, hy, glowSize * 2.2, 0, Math.PI * 2);
            ctx.strokeStyle = `hsla(${ray.hue}, 100%, 80%, 0.5)`;
            ctx.lineWidth = 1.2;
            ctx.stroke();
            ctx.restore();
          }
        }

        ctx.restore();

        if (currentAnimation === 'mandelbulb') {
          mandelbulbAnimationId = requestAnimationFrame(mandelbulbFrame);
        }
      }

      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      for (let i = 0; i < RAY_COUNT; i++) {
        rays[i].march(5.0);
      }

      mandelbulbAnimation = {
        frame: mandelbulbFrame,
        canvas: canvas,
        ctx: ctx
      };
    }

    initHopfAnimation();
    hopfInitialized = true;
    hopfCanvas.classList.add('active');
    hopfAnimationId = requestAnimationFrame(hopfAnimation.frame);

    initKleinAnimation();
    kleinInitialized = true;

    initLorenzAnimation();
    lorenzInitialized = true;

    initMandelbulbAnimation();
    mandelbulbInitialized = true;
  </script>
</body>
</html>