Home

20. Lissajous · 1857 · Retro techniques

XY oscilloscope

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

X is one sine, Y is another. That is the XY mode on a scope, and the picture is a Lissajous curve. Persistence is a cheap canvas fade. Move the pointer to detune the Y frequency. Ratio is the second harmonic.

Move the pointer left or right to change the frequency ratio.

Reference

J. A. Lissajous, "Mémoire sur l'étude optique des mouvements vibratoires", Annales de chimie et de physique, 1857; XY mode on an analogue oscilloscope

Browser APIs

  • Canvas 2D
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    ratio: 2,
    speed: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function css(rgb, a) {
    return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  let t = 0, last = 0, look = 0;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    look = (ev.clientX - r.left) / r.width;
    wake();
  });

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
      ctx.fillStyle = "#050806";
      ctx.fillRect(0, 0, w, h);
    }
  }

  function grid(w, h, rgb) {
    ctx.strokeStyle = css(rgb, 0.12);
    ctx.lineWidth = 1;
    const cx = w * 0.5, cy = h * 0.5;
    const R = Math.min(w, h) * 0.42;
    ctx.beginPath();
    ctx.rect(cx - R, cy - R, R * 2, R * 2);
    ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);
    ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);
    for (let i = 1; i < 4; i++) {
      const s = (i / 4) * R;
      ctx.moveTo(cx - R, cy - s); ctx.lineTo(cx + R, cy - s);
      ctx.moveTo(cx - R, cy + s); ctx.lineTo(cx + R, cy + s);
      ctx.moveTo(cx - s, cy - R); ctx.lineTo(cx - s, cy + R);
      ctx.moveTo(cx + s, cy - R); ctx.lineTo(cx + s, cy + R);
    }
    ctx.stroke();
  }

  function trace(w, h, a, b, phase, rgb, samples, thick) {
    const cx = w * 0.5, cy = h * 0.5;
    const R = Math.min(w, h) * 0.38;
    ctx.beginPath();
    for (let i = 0; i <= samples; i++) {
      const u = t + i * 0.012;
      const x = cx + Math.sin(a * u) * R;
      const y = cy + Math.sin(b * u + phase) * R;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.strokeStyle = css(rgb, 0.18);
    ctx.lineWidth = thick * 3.2;
    ctx.stroke();
    ctx.strokeStyle = css(rgb, 0.95);
    ctx.lineWidth = thick;
    ctx.stroke();
  }

  function draw(w, h) {
    ctx.fillStyle = "rgba(5,8,6,0.18)";
    ctx.fillRect(0, 0, w, h);
    const rgb = hexToRgb(PARAMS.color);
    grid(w, h, rgb);
    const a = 2;
    const b = Math.max(1, PARAMS.ratio) + look * 1.2;
    const phase = t * 0.17;
    trace(w, h, a, b, phase, rgb, 420, 1.4);
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) t += dt * 1.6 * PARAMS.speed;
    resize();
    if (reduced) {
      ctx.fillStyle = "#050806";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    }
    draw(canvas.width, canvas.height);
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();