physics

Lissajous Figures

Interactive oscillations simulator. Explore frequency ratios, phase differences, and the beautiful mathematics of harmonic motion in real-time.

Frequency Ratio
1:1
Period
1.00s
Figure Type
Circle
Phase Δ
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// Preset configurations
    const presets = [
      { a: 1, b: 1, delta: 0, name: "1:1\nCircle" },
      { a: 1, b: 2, delta: 0, name: "1:2\nFigure-8" },
      { a: 1, b: 3, delta: 0, name: "1:3\nTrefoil" },
      { a: 2, b: 3, delta: 0, name: "2:3\nPretzel" },
      { a: 3, b: 4, delta: 0, name: "3:4\nKnot" },
      { a: 3, b: 5, delta: 0, name: "3:5\nRose" },
      { a: 4, b: 5, delta: 0, name: "4:5\nComplex" },
      { a: 5, b: 6, delta: 0, name: "5:6\nStar" },
      { a: 1, b: 1, delta: 90, name: "1:1\nPhase 90°" }
    ];

    const figureNames = {
      "1:1": "Circle",
      "1:2": "Figure-8",
      "1:3": "Trefoil",
      "2:3": "Pretzel",
      "3:4": "Complex Knot",
      "3:5": "Rose",
      "4:5": "Complex",
      "5:6": "Star"
    };

    // State
    let state = {
      a: 1,
      b: 1,
      amplA: 150,
      amplB: 150,
      phase: 0,
      speed: 1,
      lineThickness: 1.5,
      trailLength: 200,
      mode: "trace", // trace or animate
      render: "2d", // 2d or 3d
      t: 0,
      isAnimating: true
    };

    // Canvas elements
    const mainCanvas = document.getElementById("mainCanvas");
    const mainCtx = mainCanvas.getContext("2d");
    const projectionXCanvas = document.getElementById("projectionX");
    const projectionXCtx = projectionXCanvas.getContext("2d");
    const projectionYCanvas = document.getElementById("projectionY");
    const projectionYCtx = projectionYCanvas.getContext("2d");

    // Helper functions
    function gcd(a, b) {
      return b === 0 ? a : gcd(b, a % b);
    }

    function lcm(a, b) {
      return (a * b) / gcd(a, b);
    }

    function getRatioString(a, b) {
      if (a % 1 !== 0 || b % 1 !== 0) {
        return `${a.toFixed(1)}:${b.toFixed(1)}`;
      }
      const g = gcd(a, b);
      return `${a / g}:${b / g}`;
    }

    function getFigureType(a, b) {
      const ratio = getRatioString(a, b);
      return figureNames[ratio] || "Custom";
    }

    function getPeriod(a, b) {
      if (a % 1 !== 0 || b % 1 !== 0) {
        return (2 * Math.PI) / Math.min(a, b);
      }
      const l = lcm(Math.round(a), Math.round(b));
      return (2 * Math.PI * l) / a;
    }

    // Color gradient
    function getGradientColor(t, maxT) {
      const ratio = t / maxT;
      const colors = [
        { pos: 0, r: 255, g: 34, b: 0 },     // #ff2200
        { pos: 0.25, r: 255, g: 136, b: 0 }, // #ff8800
        { pos: 0.5, r: 255, g: 204, b: 0 },  // #ffcc00
        { pos: 0.75, r: 0, g: 200, b: 150 }, // #00c896
        { pos: 1, r: 68, g: 136, b: 255 }    // #4488ff
      ];

      let color1, color2;
      for (let i = 0; i < colors.length - 1; i++) {
        if (ratio >= colors[i].pos && ratio <= colors[i + 1].pos) {
          color1 = colors[i];
          color2 = colors[i + 1];
          const localRatio = (ratio - color1.pos) / (color2.pos - color1.pos);
          const r = Math.round(color1.r + (color2.r - color1.r) * localRatio);
          const g = Math.round(color1.g + (color2.g - color1.g) * localRatio);
          const b = Math.round(color1.b + (color2.b - color1.b) * localRatio);
          return `rgb(${r},${g},${b})`;
        }
      }
      return "rgb(68,136,255)";
    }

    // Draw Lissajous figure
    function drawLissajous(canvas, ctx, a, b, delta, amplA, amplB, mode = "trace") {
      const w = canvas.width;
      const h = canvas.height;
      const cx = w / 2;
      const cy = h / 2;
      const pad = 40;

      ctx.fillStyle = "#0a0a0a";
      ctx.fillRect(0, 0, w, h);

      // Grid
      ctx.strokeStyle = "#1e1e1e";
      ctx.lineWidth = 1;
      for (let i = 1; i < 4; i++) {
        ctx.beginPath();
        ctx.moveTo((i * w) / 4, 0);
        ctx.lineTo((i * w) / 4, h);
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(0, (i * h) / 4);
        ctx.lineTo(w, (i * h) / 4);
        ctx.stroke();
      }

      // Calculate period
      const period = getPeriod(a, b);
      const steps = Math.max(500, Math.round(period * 100));

      if (mode === "trace") {
        // Draw complete figure
        ctx.beginPath();
        for (let i = 0; i <= steps; i++) {
          const t = (i / steps) * period;
          const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
          const y = cy + amplB * Math.sin(b * t);

          if (i === 0) {
            ctx.moveTo(x, y);
          } else {
            ctx.lineTo(x, y);
          }
        }

        // Draw with gradient
        const gradient = ctx.createLinearGradient(cx - amplA, cy, cx + amplA, cy);
        gradient.addColorStop(0, "#ff2200");
        gradient.addColorStop(0.25, "#ff8800");
        gradient.addColorStop(0.5, "#ffcc00");
        gradient.addColorStop(0.75, "#00c896");
        gradient.addColorStop(1, "#4488ff");

        ctx.strokeStyle = gradient;
        ctx.lineWidth = state.lineThickness;
        ctx.lineCap = "round";
        ctx.lineJoin = "round";
        ctx.stroke();
      } else {
        // Animate mode
        const currentStep = Math.floor((state.t % period) / period * steps);
        const trailStart = Math.max(0, currentStep - state.trailLength);

        ctx.strokeStyle = "rgba(255, 34, 0, 0.3)";
        ctx.lineWidth = state.lineThickness * 0.7;
        ctx.beginPath();
        let firstPoint = true;

        for (let i = trailStart; i <= currentStep; i++) {
          const t = (i / steps) * period;
          const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
          const y = cy + amplB * Math.sin(b * t);

          if (firstPoint) {
            ctx.moveTo(x, y);
            firstPoint = false;
          } else {
            ctx.lineTo(x, y);
          }
        }
        ctx.stroke();

        // Current point
        const t = (currentStep / steps) * period;
        const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
        const y = cy + amplB * Math.sin(b * t);

        ctx.fillStyle = "#ff2200";
        ctx.beginPath();
        ctx.arc(x, y, 6, 0, Math.PI * 2);
        ctx.fill();

        ctx.strokeStyle = "rgba(255, 34, 0, 0.6)";
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.arc(x, y, 10, 0, Math.PI * 2);
        ctx.stroke();
      }
    }

    // Draw 3D isometric Lissajous
    function drawLissajous3D(canvas, ctx, a, b, delta, amplA, amplB) {
      const w = canvas.width;
      const h = canvas.height;
      const cx = w / 2;
      const cy = h / 2;

      ctx.fillStyle = "#0a0a0a";
      ctx.fillRect(0, 0, w, h);

      const period = getPeriod(a, b);
      const steps = Math.max(500, Math.round(period * 100));

      // Isometric projection factors
      const isometricScale = 0.7;
      const zMax = 100;

      ctx.beginPath();
      for (let i = 0; i <= steps; i++) {
        const t = (i / steps) * period;
        const x = amplA * Math.sin(a * t + (delta * Math.PI) / 180);
        const y = amplB * Math.sin(b * t);
        const z = (t / period) * zMax - zMax / 2;

        // Simple isometric projection
        const screenX = cx + (x * isometricScale - z * isometricScale * 0.5);
        const screenY = cy + (y * isometricScale + z * isometricScale * 0.25);

        if (i === 0) {
          ctx.moveTo(screenX, screenY);
        } else {
          ctx.lineTo(screenX, screenY);
        }
      }

      const gradient = ctx.createLinearGradient(cx - amplA, cy, cx + amplA, cy);
      gradient.addColorStop(0, "#ff2200");
      gradient.addColorStop(0.25, "#ff8800");
      gradient.addColorStop(0.5, "#ffcc00");
      gradient.addColorStop(0.75, "#00c896");
      gradient.addColorStop(1, "#4488ff");

      ctx.strokeStyle = gradient;
      ctx.lineWidth = state.lineThickness;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.stroke();
    }

    // Draw projections
    function drawProjections(a, b, delta, amplA, amplB) {
      const period = getPeriod(a, b);
      const steps = Math.max(300, Math.round(period * 100));

      // X projection
      const wX = projectionXCanvas.width;
      const hX = projectionXCanvas.height;
      const cxX = wX / 2;
      const cyX = hX / 2;

      projectionXCtx.fillStyle = "#0a0a0a";
      projectionXCtx.fillRect(0, 0, wX, hX);

      projectionXCtx.strokeStyle = "#2a2a2a";
      projectionXCtx.lineWidth = 1;
      projectionXCtx.beginPath();
      projectionXCtx.moveTo(0, cyX);
      projectionXCtx.lineTo(wX, cyX);
      projectionXCtx.stroke();

      projectionXCtx.beginPath();
      for (let i = 0; i <= steps; i++) {
        const t = (i / steps) * period;
        const x = cxX + amplA * Math.sin(a * t + (delta * Math.PI) / 180) * 0.8;
        const y = cyX + 20;

        if (i === 0) {
          projectionXCtx.moveTo(x, y);
        } else {
          projectionXCtx.lineTo(x, y);
        }
      }
      projectionXCtx.strokeStyle = "#ff8800";
      projectionXCtx.lineWidth = state.lineThickness;
      projectionXCtx.stroke();

      // Current point on X projection
      const tX = (state.t % period) / period * steps;
      const xX = cxX + amplA * Math.sin(a * (tX / steps * period) + (delta * Math.PI) / 180) * 0.8;
      projectionXCtx.fillStyle = "#ff8800";
      projectionXCtx.beginPath();
      projectionXCtx.arc(xX, cyX + 20, 4, 0, Math.PI * 2);
      projectionXCtx.fill();

      // Y projection
      const wY = projectionYCanvas.width;
      const hY = projectionYCanvas.height;
      const cxY = wY / 2;
      const cyY = hY / 2;

      projectionYCtx.fillStyle = "#0a0a0a";
      projectionYCtx.fillRect(0, 0, wY, hY);

      projectionYCtx.strokeStyle = "#2a2a2a";
      projectionYCtx.lineWidth = 1;
      projectionYCtx.beginPath();
      projectionYCtx.moveTo(cxY, 0);
      projectionYCtx.lineTo(cxY, hY);
      projectionYCtx.stroke();

      projectionYCtx.beginPath();
      for (let i = 0; i <= steps; i++) {
        const t = (i / steps) * period;
        const x = cxY + 20;
        const y = cyY + amplB * Math.sin(b * t) * 0.8;

        if (i === 0) {
          projectionYCtx.moveTo(x, y);
        } else {
          projectionYCtx.lineTo(x, y);
        }
      }
      projectionYCtx.strokeStyle = "#4488ff";
      projectionYCtx.lineWidth = state.lineThickness;
      projectionYCtx.stroke();

      // Current point on Y projection
      const tY = (state.t % period) / period * steps;
      const yY = cyY + amplB * Math.sin(b * (tY / steps * period)) * 0.8;
      projectionYCtx.fillStyle = "#4488ff";
      projectionYCtx.beginPath();
      projectionYCtx.arc(cxY + 20, yY, 4, 0, Math.PI * 2);
      projectionYCtx.fill();
    }

    // Draw preset thumbnail
    function drawPresetThumbnail(canvas, a, b, delta) {
      const ctx = canvas.getContext("2d");
      const w = canvas.width;
      const h = canvas.height;
      const cx = w / 2;
      const cy = h / 2;
      const amplA = 30;
      const amplB = 30;

      ctx.fillStyle = "#0a0a0a";
      ctx.fillRect(0, 0, w, h);

      const period = getPeriod(a, b);
      const steps = Math.max(100, Math.round(period * 30));

      ctx.beginPath();
      for (let i = 0; i <= steps; i++) {
        const t = (i / steps) * period;
        const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
        const y = cy + amplB * Math.sin(b * t);

        if (i === 0) {
          ctx.moveTo(x, y);
        } else {
          ctx.lineTo(x, y);
        }
      }

      ctx.strokeStyle = "#ff2200";
      ctx.lineWidth = 1.5;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.stroke();
    }

    // Initialize presets gallery
    function initializePresets() {
      const gallery = document.getElementById("presetGallery");
      gallery.innerHTML = "";

      presets.forEach((preset, index) => {
        const container = document.createElement("div");
        container.className = "preset-thumbnail active";
        if (state.a === preset.a && state.b === preset.b && state.phase === preset.delta) {
          container.classList.add("active");
        }

        const canvas = document.createElement("canvas");
        canvas.width = 100;
        canvas.height = 100;
        drawPresetThumbnail(canvas, preset.a, preset.b, preset.delta);

        const label = document.createElement("div");
        label.className = "preset-label";
        label.textContent = preset.name.replace("\n", " ");

        container.appendChild(canvas);
        container.appendChild(label);

        container.addEventListener("click", () => {
          state.a = preset.a;
          state.b = preset.b;
          state.phase = preset.delta;
          state.t = 0;
          updateUI();
          render();
          updatePresetGallery();
        });

        gallery.appendChild(container);
      });
    }

    function updatePresetGallery() {
      const thumbnails = document.querySelectorAll(".preset-thumbnail");
      thumbnails.forEach((thumb) => {
        thumb.classList.remove("active");
      });
      thumbnails[0].classList.add("active");
    }

    // UI Updates
    function updateUI() {
      document.getElementById("aValue").textContent = state.a.toFixed(1);
      document.getElementById("bValue").textContent = state.b.toFixed(1);
      document.getElementById("amplAValue").textContent = Math.round(state.amplA);
      document.getElementById("amplBValue").textContent = Math.round(state.amplB);
      document.getElementById("phaseValue").textContent = Math.round(state.phase) + "°";
      document.getElementById("speedValue").textContent = state.speed.toFixed(1) + "×";
      document.getElementById("thickValue").textContent = state.lineThickness.toFixed(1);
      document.getElementById("trailValue").textContent = Math.round(state.trailLength);

      document.getElementById("aSlider").value = state.a;
      document.getElementById("aInput").value = state.a;
      document.getElementById("bSlider").value = state.b;
      document.getElementById("bInput").value = state.b;
      document.getElementById("amplASlider").value = state.amplA;
      document.getElementById("amplAInput").value = state.amplA;
      document.getElementById("amplBSlider").value = state.amplB;
      document.getElementById("amplBInput").value = state.amplB;
      document.getElementById("phaseSlider").value = state.phase;
      document.getElementById("phaseInput").value = state.phase;
      document.getElementById("speedSlider").value = state.speed;
      document.getElementById("thickSlider").value = state.lineThickness;
      document.getElementById("trailSlider").value = state.trailLength;

      // Info panel
      const ratio = getRatioString(state.a, state.b);
      document.getElementById("ratioInfo").textContent = ratio;
      document.getElementById("periodInfo").textContent = (getPeriod(state.a, state.b) / Math.PI).toFixed(2) + "π";
      document.getElementById("figureTypeInfo").textContent = getFigureType(state.a, state.b);
      document.getElementById("phaseInfo").textContent = Math.round(state.phase) + "°";
    }

    // Sync controls
    function setupControlSync() {
      // A slider <-> input
      document.getElementById("aSlider").addEventListener("input", (e) => {
        state.a = parseFloat(e.target.value);
        state.t = 0;
        updateUI();
        render();
      });

      document.getElementById("aInput").addEventListener("input", (e) => {
        state.a = parseFloat(e.target.value) || 1;
        state.t = 0;
        updateUI();
        render();
      });

      // B slider <-> input
      document.getElementById("bSlider").addEventListener("input", (e) => {
        state.b = parseFloat(e.target.value);
        state.t = 0;
        updateUI();
        render();
      });

      document.getElementById("bInput").addEventListener("input", (e) => {
        state.b = parseFloat(e.target.value) || 1;
        state.t = 0;
        updateUI();
        render();
      });

      // Amplitude A
      document.getElementById("amplASlider").addEventListener("input", (e) => {
        state.amplA = parseFloat(e.target.value);
        updateUI();
        render();
      });

      document.getElementById("amplAInput").addEventListener("input", (e) => {
        state.amplA = parseFloat(e.target.value) || 50;
        updateUI();
        render();
      });

      // Amplitude B
      document.getElementById("amplBSlider").addEventListener("input", (e) => {
        state.amplB = parseFloat(e.target.value);
        updateUI();
        render();
      });

      document.getElementById("amplBInput").addEventListener("input", (e) => {
        state.amplB = parseFloat(e.target.value) || 50;
        updateUI();
        render();
      });

      // Phase
      document.getElementById("phaseSlider").addEventListener("input", (e) => {
        state.phase = parseFloat(e.target.value);
        updateUI();
        render();
      });

      document.getElementById("phaseInput").addEventListener("input", (e) => {
        state.phase = parseFloat(e.target.value) || 0;
        updateUI();
        render();
      });

      // Speed
      document.getElementById("speedSlider").addEventListener("input", (e) => {
        state.speed = parseFloat(e.target.value);
        updateUI();
      });

      // Line thickness
      document.getElementById("thickSlider").addEventListener("input", (e) => {
        state.lineThickness = parseFloat(e.target.value);
        updateUI();
        render();
      });

      // Trail length
      document.getElementById("trailSlider").addEventListener("input", (e) => {
        state.trailLength = parseFloat(e.target.value);
        updateUI();
      });

      // Mode buttons
      document.getElementById("modeTrace").addEventListener("click", () => {
        state.mode = "trace";
        document.getElementById("modeTrace").classList.add("active");
        document.getElementById("modeAnimate").classList.remove("active");
        state.t = 0;
        render();
      });

      document.getElementById("modeAnimate").addEventListener("click", () => {
        state.mode = "animate";
        document.getElementById("modeAnimate").classList.add("active");
        document.getElementById("modeTrace").classList.remove("active");
        state.t = 0;
      });

      // Render buttons
      document.getElementById("render2D").addEventListener("click", () => {
        state.render = "2d";
        document.getElementById("render2D").classList.add("active");
        document.getElementById("render3D").classList.remove("active");
        render();
      });

      document.getElementById("render3D").addEventListener("click", () => {
        state.render = "3d";
        document.getElementById("render3D").classList.add("active");
        document.getElementById("render2D").classList.remove("active");
        render();
      });

      // Action buttons
      document.getElementById("resetBtn").addEventListener("click", () => {
        state = {
          a: 1,
          b: 1,
          amplA: 150,
          amplB: 150,
          phase: 0,
          speed: 1,
          lineThickness: 1.5,
          trailLength: 200,
          mode: "trace",
          render: "2d",
          t: 0,
          isAnimating: true
        };
        document.getElementById("modeTrace").classList.add("active");
        document.getElementById("modeAnimate").classList.remove("active");
        document.getElementById("render2D").classList.add("active");
        document.getElementById("render3D").classList.remove("active");
        updateUI();
        render();
        updatePresetGallery();
      });

      document.getElementById("clearBtn").addEventListener("click", () => {
        state.t = 0;
        render();
      });
    }

    // Main render function
    function render() {
      if (state.render === "2d") {
        drawLissajous(mainCanvas, mainCtx, state.a, state.b, state.phase, state.amplA, state.amplB, state.mode);
      } else {
        drawLissajous3D(mainCanvas, mainCtx, state.a, state.b, state.phase, state.amplA, state.amplB);
      }
      drawProjections(state.a, state.b, state.phase, state.amplA, state.amplB);
    }

    // Animation loop
    function animate() {
      if (state.mode === "animate") {
        const period = getPeriod(state.a, state.b);
        state.t += 0.016 * state.speed; // ~60fps
        if (state.t > period) {
          state.t = 0;
        }
      }

      render();
      requestAnimationFrame(animate);
    }

    // Initialize
    initializePresets();
    setupControlSync();
    updateUI();
    render();
    animate();