Home

20. Vector CRT · 1970s

XY oscilloscope

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.

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. prefers-reduced-motion freezes the first still.

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;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    look = (ev.clientX - r.left) / r.width;
  });

  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();
    draw(canvas.width, canvas.height);
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) {
    resize();
    ctx.fillStyle = "#050806";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    draw(canvas.width, canvas.height);
  } else requestAnimationFrame(frame);
})();