← Mike Higgins Home
Custom Code Experiment •

Lemniscate of Bernoulli

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">
  <title>Lemniscate of Bernoulli - Neon Dreams</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      background: radial-gradient(circle at center, #0a0a1a, #050510, #000000);
      overflow: hidden;
      font-family: "Courier New", monospace;
      touch-action: manipulation;
    }

    #canvas {
      display: block;
      background: transparent;
      cursor: crosshair;
      image-rendering: pixelated;
    }

    #canvas:active {
      cursor: grabbing;
    }

    .menu-toggle {
      display: none;
      position: fixed;
      top: 15px;
      right: 15px;
      width: 36px;
      height: 36px;
      background: rgba(10, 10, 26, 0.7);
      border: 2px solid #FF4FFB;
      border-radius: 50%;
      color: #FBF368;
      font-size: 18px;
      cursor: pointer;
      z-index: 1001;
      transition: all 0.3s ease;
      box-shadow: 0 0 10px rgba(255, 79, 251, 0.5);
    }

    .menu-toggle:hover {
      background: rgba(255, 79, 251, 0.2);
      transform: scale(1.1);
    }

    .menu-toggle:active {
      transform: scale(0.95);
    }

    .control-panel {
      position: fixed;
      top: 10px;
      right: 10px;
      background: rgba(10, 10, 26, 0.95);
      border: 2px solid #FF4FFB;
      box-shadow: 0 0 20px #FF4FFB, inset 0 0 15px rgba(255, 79, 251, 0.1);
      padding: 15px;
      color: #E4B5EF;
      font-size: 11px;
      max-width: 250px;
      z-index: 1000;
      text-transform: uppercase;
      letter-spacing: 1px;
      border-radius: 10px;
      backdrop-filter: blur(5px);
      transition: transform 0.3s ease, opacity 0.3s ease;
    }

    .control-group {
      margin-bottom: 12px;
    }

    .control-group:last-child {
      margin-bottom: 0;
    }

    label {
      display: block;
      margin-bottom: 5px;
      font-weight: bold;
      color: #15B1EA;
      text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
      font-size: 10px;
    }

    label::before {
      content: "›";
      margin-right: 5px;
      color: #3BF854;
      animation: blink 1.5s infinite;
      font-size: 12px;
    }

    @keyframes blink {
      0%,
      50% {
        opacity: 1;
      }
      51%,
      100% {
        opacity: 0.3;
      }
    }

    input[type="range"] {
      width: 100%;
      height: 6px;
      background: linear-gradient(
        90deg,
        #0a1a1a,
        #153535,
        #15B1EA,
        #153535,
        #0a1a1a
      );
      outline: none;
      -webkit-appearance: none;
      appearance: none;
      border: 1px solid #15B1EA;
      box-shadow: 0 0 5px rgba(21, 177, 234, 0.3);
      border-radius: 3px;
      cursor: pointer;
    }

    input[type="range"]::-webkit-slider-thumb {
      appearance: none;
      width: 14px;
      height: 14px;
      background: radial-gradient(circle, #FF4FFB, #B815EA);
      cursor: pointer;
      border: 2px solid #000000;
      box-shadow: 0 0 8px #FF4FFB;
      border-radius: 50%;
      transition: transform 0.1s ease;
    }

    input[type="range"]::-webkit-slider-thumb:hover {
      transform: scale(1.2);
    }

    input[type="range"]::-moz-range-thumb {
      width: 14px;
      height: 14px;
      background: radial-gradient(circle, #FF4FFB, #B815EA);
      cursor: pointer;
      border: 2px solid #000000;
      box-shadow: 0 0 8px #FF4FFB;
      border-radius: 50%;
    }

    .value-display {
      font-size: 9px;
      color: #FF4FFB;
      text-align: right;
      margin-top: 3px;
      font-family: "Courier New", monospace;
      text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
    }

    .value-display::before {
      content: "[";
      color: #15B1EA;
      margin-right: 2px;
    }

    .value-display::after {
      content: "]";
      color: #15B1EA;
      margin-left: 2px;
    }

    .color-palette {
      display: flex;
      gap: 4px;
      margin-top: 8px;
      padding-top: 8px;
      border-top: 1px solid rgba(255, 79, 251, 0.2);
    }

    .color-swatch {
      flex: 1;
      height: 4px;
      border-radius: 2px;
    }

    @media (max-width: 768px) {
      .menu-toggle {
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .control-panel {
        position: fixed;
        top: 50%;
        right: -260px;
        transform: translateY(-50%);
        width: 250px;
        max-width: 250px;
        padding: 15px;
        background: rgba(10, 10, 26, 0.95);
        backdrop-filter: blur(10px);
        border: 1px solid rgba(255, 79, 251, 0.3);
        box-shadow: 0 0 30px rgba(255, 79, 251, 0.2);
        opacity: 0;
        transition: right 0.3s ease, opacity 0.3s ease;
        z-index: 1000;
      }

      .control-panel.active {
        right: 10px;
        opacity: 1;
      }

      .control-panel::after {
        content: '';
        position: absolute;
        right: -10px;
        top: 50%;
        transform: translateY(-50%);
        width: 0;
        height: 0;
        border-top: 10px solid transparent;
        border-bottom: 10px solid transparent;
        border-left: 10px solid rgba(255, 79, 251, 0.3);
        display: none;
      }

      label {
        font-size: 9px;
        margin-bottom: 4px;
      }

      input[type="range"] {
        height: 4px;
      }

      input[type="range"]::-webkit-slider-thumb {
        width: 12px;
        height: 12px;
      }

      .value-display {
        font-size: 8px;
      }

      .color-palette {
        margin-top: 6px;
        padding-top: 6px;
      }
    }

    @media (min-width: 769px) {
      .control-panel {
        transition: transform 0.3s ease, opacity 0.3s ease;
      }

      .control-panel:hover {
        transform: translateY(-2px);
        box-shadow: 0 0 30px #FF4FFB, inset 0 0 20px rgba(255, 79, 251, 0.2);
      }
    }

    @media (hover: none) and (pointer: coarse) {
      input[type="range"] {
        height: 8px;
      }

      input[type="range"]::-webkit-slider-thumb {
        width: 20px;
        height: 20px;
      }
    }
  </style>
</head>

<body>
  <canvas id="canvas"></canvas>

  <div class="menu-toggle" onclick="toggleMenu()">☰</div>

  <div class="control-panel" id="controlPanel">
    <div class="control-group">
      <label>Animation Speed</label>
      <input type="range" id="animationSpeed" min="0.1" max="3" value="1" step="0.1">
      <div class="value-display" id="speedValue">1.0</div>
    </div>

    <div class="control-group">
      <label>Curve Detail</label>
      <input type="range" id="curveDetail" min="30" max="150" value="80" step="5">
      <div class="value-display" id="detailValue">80</div>
    </div>

    <div class="control-group">
      <label>Trail Length</label>
      <input type="range" id="trailLength" min="0.5" max="0.95" value="0.75" step="0.05">
      <div class="value-display" id="trailValue">0.75</div>
    </div>

    <div class="color-palette">
      <div class="color-swatch" style="background: #FF4FFB;"></div>
      <div class="color-swatch" style="background: #15B1EA;"></div>
      <div class="color-swatch" style="background: #E4B5EF;"></div>
      <div class="color-swatch" style="background: #FBF368;"></div>
      <div class="color-swatch" style="background: #3BF854;"></div>
    </div>
  </div>

  <script>
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const controlPanel = document.getElementById("controlPanel");

    let isDragging = false;
    let lastTouchX = 0;
    let lastTouchY = 0;
    let menuOpen = false;

    let animationId;
    let time = 0;

    let animationSpeed = 1;
    let curveDetail = 80;
    let trailLength = 0.75;
    let showGrid = false;
    let gridDensity = 80;

    let gridBursts = new Map();
    const burstDuration = 40;


    let colorOffset = 0;
    const neonColors = [
      "#FF4FFB", // Pink
      "#15B1EA", // Blue
      "#E4B5EF", // Light Purple
      "#FBF368", // Yellow
      "#3BF854"  // Green
    ];

    function toggleMenu() {
      if (window.innerWidth <= 768) {
        menuOpen = !menuOpen;
        controlPanel.classList.toggle('active');

        const menuToggle = document.querySelector('.menu-toggle');
        menuToggle.textContent = menuOpen ? '✕' : '☰';
      }
    }

    document.addEventListener('click', (e) => {
      if (window.innerWidth <= 768 && menuOpen) {
        if (!controlPanel.contains(e.target) && !e.target.classList.contains('menu-toggle')) {
          toggleMenu();
        }
      }
    });

    function resizeCanvas() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;

      ctx.imageSmoothingEnabled = false;
      ctx.webkitImageSmoothingEnabled = false;
      ctx.mozImageSmoothingEnabled = false;
      ctx.msImageSmoothingEnabled = false;
    }

    function getNeonColor(index) {
      const colorIndex = Math.floor(
        (index + colorOffset * 0.3) % neonColors.length
      );
      return neonColors[colorIndex];
    }

    function drawHexagon(x, y, size) {
      ctx.beginPath();
      for (let i = 0; i < 6; i++) {
        const angle = (i * Math.PI) / 3;
        const px = x + Math.cos(angle) * size;
        const py = y + Math.sin(angle) * size;
        if (i === 0) {
          ctx.moveTo(px, py);
        } else {
          ctx.lineTo(px, py);
        }
      }
      ctx.closePath();
    }

    function getHexGridPosition(row, col) {
      const hexWidth = gridDensity;
      const hexHeight = (gridDensity * Math.sqrt(3)) / 2;
      const x = col * hexWidth * 0.75;
      const y = row * hexHeight + ((col % 2) * hexHeight) / 2;
      return { x, y };
    }

    function getNearestHexNode(x, y) {
      const hexWidth = gridDensity;
      const hexHeight = (gridDensity * Math.sqrt(3)) / 2;

      const col = Math.round(x / (hexWidth * 0.75));
      const row = Math.round((y - ((col % 2) * hexHeight) / 2) / hexHeight);

      const hexPos = getHexGridPosition(row, col);

      let closestDist = Infinity;
      let closestPos = hexPos;

      for (let dr = -1; dr <= 1; dr++) {
        for (let dc = -1; dc <= 1; dc++) {
          const testPos = getHexGridPosition(row + dr, col + dc);
          const dist = Math.sqrt((x - testPos.x) ** 2 + (y - testPos.y) ** 2);
          if (dist < closestDist) {
            closestDist = dist;
            closestPos = testPos;
          }
        }
      }

      return { pos: closestPos, distance: closestDist };
    }

    function checkNodeCollision(x, y) {
      const hexResult = getNearestHexNode(x, y);
      const distance = hexResult.distance;

      if (distance < gridDensity * 0.4) {
        const nodeKey = `${hexResult.pos.x.toFixed(0)},${hexResult.pos.y.toFixed(
          0
        )}`;
        if (!gridBursts.has(nodeKey)) {
          gridBursts.set(nodeKey, {
            x: hexResult.pos.x,
            y: hexResult.pos.y,
            frame: 0,
            intensity: 1,
            color: getNeonColor(Math.random() * neonColors.length)
          });
        }
      }
    }

    function updateBursts() {
      for (let [key, burst] of gridBursts) {
        burst.frame++;
        burst.intensity = Math.max(0, 1 - burst.frame / burstDuration);

        if (burst.frame >= burstDuration) {
          gridBursts.delete(key);
        }
      }
    }

    function drawBursts() {
      for (let [key, burst] of gridBursts) {
        const size = 12 + (1 - burst.intensity) * 25;
        const alpha = burst.intensity;

        for (let ring = 0; ring < 3; ring++) {
          const ringSize = size + ring * 8;
          const ringAlpha = alpha * (1 - ring * 0.3);

          ctx.strokeStyle = `${burst.color}${Math.floor(ringAlpha * 255)
            .toString(16)
            .padStart(2, "0")}`;
          ctx.lineWidth = 3 - ring * 0.5;
          ctx.shadowColor = burst.color;
          ctx.shadowBlur = 15;
          drawHexagon(burst.x, burst.y, ringSize);
          ctx.stroke();
          ctx.shadowBlur = 0;
        }

        const coreSize = 4 + Math.sin(time * 8) * 2;
        ctx.fillStyle = `#ffffff${Math.floor(alpha * 255)
          .toString(16)
          .padStart(2, "0")}`;
        ctx.shadowColor = burst.color;
        ctx.shadowBlur = 10;
        ctx.fillRect(
          burst.x - coreSize / 2,
          burst.y - coreSize / 2,
          coreSize,
          coreSize
        );
        ctx.shadowBlur = 0;
      }
    }

    function createLemniscatePoints(t) {
      const points = [];
      const scale = Math.min(canvas.width, canvas.height) * 0.3;
      const centerX = canvas.width / 2;
      const centerY = canvas.height / 2;
      const a = 1;

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

        const animatedParam = param + t * animationSpeed;

        const sinT = Math.sin(animatedParam);
        const cosT = Math.cos(animatedParam);
        const denominator = 1 + sinT * sinT;

        const x = (a * cosT) / denominator;
        const y = (a * sinT * cosT) / denominator;

        for (let layer = 0; layer < 3; layer++) {
          const layerScale = scale * (0.5 + layer * 0.3);
          const layerPhase = (layer * Math.PI) / 3;
          const layerT = animatedParam + layerPhase;

          const layerSinT = Math.sin(layerT);
          const layerCosT = Math.cos(layerT);
          const layerDenom = 1 + layerSinT * layerSinT;

          const layerX = (a * layerCosT) / layerDenom;
          const layerY = (a * layerSinT * layerCosT) / layerDenom;

          const rotationPhase = t * 0.5 + (layer * Math.PI) / 4;
          const rotatedX =
            layerX * Math.cos(rotationPhase) - layerY * Math.sin(rotationPhase);
          const rotatedY =
            layerX * Math.sin(rotationPhase) + layerY * Math.cos(rotationPhase);

          points.push({
            x: centerX + rotatedX * layerScale,
            y: centerY + rotatedY * layerScale,
            param: animatedParam,
            index: i,
            layer: layer
          });
        }
      }

      return points;
    }

    function drawGrid() {
      if (!showGrid) return;

      const hexSize = gridDensity * 0.2;
      const hexWidth = gridDensity;
      const hexHeight = (gridDensity * Math.sqrt(3)) / 2;

      ctx.strokeStyle = "rgba(21, 177, 234, 0.2)";
      ctx.lineWidth = 1;
      ctx.shadowColor = "#15B1EA";
      ctx.shadowBlur = 2;

      const cols = Math.ceil(canvas.width / (hexWidth * 0.75)) + 2;
      const rows = Math.ceil(canvas.height / hexHeight) + 2;

      for (let row = -1; row < rows; row++) {
        for (let col = -1; col < cols; col++) {
          const pos = getHexGridPosition(row, col);
          if (
            pos.x > -hexWidth &&
            pos.x < canvas.width + hexWidth &&
            pos.y > -hexHeight &&
            pos.y < canvas.height + hexHeight
          ) {
            drawHexagon(pos.x, pos.y, hexSize);
            ctx.stroke();
          }
        }
      }
      ctx.shadowBlur = 0;

      const nodeSize = 2 + Math.sin(time * 3) * 0.5;
      let nodeIndex = 0;

      for (let row = -1; row < rows; row++) {
        for (let col = -1; col < cols; col++) {
          const pos = getHexGridPosition(row, col);
          if (
            pos.x > -hexWidth &&
            pos.x < canvas.width + hexWidth &&
            pos.y > -hexHeight &&
            pos.y < canvas.height + hexHeight
          ) {
            const color = getNeonColor(nodeIndex * 0.1);
            ctx.fillStyle = color;
            ctx.shadowColor = color;
            ctx.shadowBlur = 5;
            ctx.fillRect(
              pos.x - nodeSize / 2,
              pos.y - nodeSize / 2,
              nodeSize,
              nodeSize
            );
            ctx.shadowBlur = 0;

            if (Math.random() < 0.05) {
              ctx.fillStyle = "#ffffff";
              ctx.shadowColor = "#ffffff";
              ctx.shadowBlur = 8;
              ctx.fillRect(pos.x - 1, pos.y - 1, 2, 2);
              ctx.shadowBlur = 0;
            }

            nodeIndex++;
          }
        }
      }
    }

    function animate() {
      time += 0.02 * animationSpeed;
      colorOffset += 0.05 * animationSpeed;

      ctx.fillStyle = `rgba(10, 10, 26, ${1 - trailLength})`;
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      updateBursts();
      drawGrid();
      drawBursts();

      const curvePoints = createLemniscatePoints(time);

      ctx.strokeStyle = "rgba(255, 79, 251, 0.1)";
      ctx.lineWidth = 1;

      for (let i = 0; i < curvePoints.length - 1; i += 3) {
        const p1 = curvePoints[i];
        const p2 = curvePoints[i + 1];

        if (p1 && p2) {
          const distance = Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
          if (distance < 40) {
            const alpha = Math.max(0, 1 - distance / 40) * 0.25;
            const color = getNeonColor(p1.layer);
            ctx.strokeStyle = `${color}${Math.floor(alpha * 255)
              .toString(16)
              .padStart(2, "0")}`;
            ctx.shadowColor = color;
            ctx.shadowBlur = 2;

            ctx.beginPath();
            ctx.moveTo(p1.x, p1.y);
            ctx.lineTo(p2.x, p2.y);
            ctx.stroke();
            ctx.shadowBlur = 0;
          }
        }
      }

      curvePoints.forEach((point) => {
        if (point) {
          checkNodeCollision(point.x, point.y);

          const size = 2 + Math.sin(point.param + time) * 0.8 + point.layer * 0.5;
          const color = getNeonColor(point.layer);

          ctx.fillStyle = color;
          ctx.shadowColor = color;
          ctx.shadowBlur = 6 + point.layer * 2;
          ctx.fillRect(point.x - size / 2, point.y - size / 2, size, size);

          ctx.fillStyle = "#ffffff";
          ctx.shadowColor = "#ffffff";
          ctx.shadowBlur = 3;
          ctx.fillRect(point.x - size / 2, point.y - size / 2, 1, 1);
          ctx.shadowBlur = 0;
        }
      });

      animationId = requestAnimationFrame(animate);
    }

    document.getElementById("animationSpeed").addEventListener("input", (e) => {
      animationSpeed = parseFloat(e.target.value);
      document.getElementById("speedValue").textContent = animationSpeed.toFixed(1);
    });

    document.getElementById("curveDetail").addEventListener("input", (e) => {
      curveDetail = parseInt(e.target.value);
      document.getElementById("detailValue").textContent = curveDetail;
    });

    document.getElementById("trailLength").addEventListener("input", (e) => {
      trailLength = parseFloat(e.target.value);
      document.getElementById("trailValue").textContent = trailLength.toFixed(2);
    });

    canvas.addEventListener("touchstart", (e) => {
      e.preventDefault();
      isDragging = true;
      const touch = e.touches[0];
      lastTouchX = touch.clientX;
      lastTouchY = touch.clientY;
    });

    canvas.addEventListener("touchmove", (e) => {
      e.preventDefault();
      if (!isDragging) return;

      const touch = e.touches[0];
      const deltaX = (touch.clientX - lastTouchX) * 0.01;
      const deltaY = (touch.clientY - lastTouchY) * 0.01;

      time += deltaX + deltaY;

      lastTouchX = touch.clientX;
      lastTouchY = touch.clientY;
    });

    canvas.addEventListener("touchend", (e) => {
      e.preventDefault();
      isDragging = false;
    });

    canvas.addEventListener("mousedown", (e) => {
      isDragging = true;
      lastTouchX = e.clientX;
      lastTouchY = e.clientY;
    });

    canvas.addEventListener("mousemove", (e) => {
      if (!isDragging) return;

      const deltaX = (e.clientX - lastTouchX) * 0.01;
      const deltaY = (e.clientY - lastTouchY) * 0.01;

      time += deltaX + deltaY;

      lastTouchX = e.clientX;
      lastTouchY = e.clientY;
    });

    canvas.addEventListener("mouseup", () => {
      isDragging = false;
    });

    window.addEventListener("resize", resizeCanvas);

    document.querySelectorAll('#controlPanel input').forEach(input => {
      input.addEventListener('change', () => {
        if (window.innerWidth <= 768 && menuOpen) {
          setTimeout(() => toggleMenu(), 1500);
        }
      });
    });

    resizeCanvas();
    animate();
  </script>
</body>

</html>