{"slug":"scope","number":"20","title":"XY oscilloscope","era":"Vector CRT · 1970s","summary":"A Lissajous figure with phosphor persistence. Pointer changes the ratio.","description":"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.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["vector","crt"],"hasGlsl":false,"interaction":"Move the pointer left or right to change the frequency ratio.","url":"https://3d-retro.com/experiments/scope","example_url":"https://3d-retro.com/examples/scope.html","api_url":"https://3d-retro.com/api/v1/experiments/scope","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"XY oscilloscope / Lissajous. Pointer changes the ratio.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/scope\" />\n  <title>XY oscilloscope · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #050806; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #050806; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    ratio: 2,\n    speed: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function css(rgb, a) {\n    return \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + a + \")\";\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  let t = 0, last = 0, look = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    look = (ev.clientX - r.left) / r.width;\n  });\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n      ctx.fillStyle = \"#050806\";\n      ctx.fillRect(0, 0, w, h);\n    }\n  }\n\n  function grid(w, h, rgb) {\n    ctx.strokeStyle = css(rgb, 0.12);\n    ctx.lineWidth = 1;\n    const cx = w * 0.5, cy = h * 0.5;\n    const R = Math.min(w, h) * 0.42;\n    ctx.beginPath();\n    ctx.rect(cx - R, cy - R, R * 2, R * 2);\n    ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);\n    ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);\n    for (let i = 1; i < 4; i++) {\n      const s = (i / 4) * R;\n      ctx.moveTo(cx - R, cy - s); ctx.lineTo(cx + R, cy - s);\n      ctx.moveTo(cx - R, cy + s); ctx.lineTo(cx + R, cy + s);\n      ctx.moveTo(cx - s, cy - R); ctx.lineTo(cx - s, cy + R);\n      ctx.moveTo(cx + s, cy - R); ctx.lineTo(cx + s, cy + R);\n    }\n    ctx.stroke();\n  }\n\n  function trace(w, h, a, b, phase, rgb, samples, thick) {\n    const cx = w * 0.5, cy = h * 0.5;\n    const R = Math.min(w, h) * 0.38;\n    ctx.beginPath();\n    for (let i = 0; i <= samples; i++) {\n      const u = t + i * 0.012;\n      const x = cx + Math.sin(a * u) * R;\n      const y = cy + Math.sin(b * u + phase) * R;\n      if (i === 0) ctx.moveTo(x, y);\n      else ctx.lineTo(x, y);\n    }\n    ctx.strokeStyle = css(rgb, 0.18);\n    ctx.lineWidth = thick * 3.2;\n    ctx.stroke();\n    ctx.strokeStyle = css(rgb, 0.95);\n    ctx.lineWidth = thick;\n    ctx.stroke();\n  }\n\n  function draw(w, h) {\n    ctx.fillStyle = \"rgba(5,8,6,0.18)\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n    grid(w, h, rgb);\n    const a = 2;\n    const b = Math.max(1, PARAMS.ratio) + look * 1.2;\n    const phase = t * 0.17;\n    trace(w, h, a, b, phase, rgb, 420, 1.4);\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) t += dt * 1.6 * PARAMS.speed;\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    resize();\n    ctx.fillStyle = \"#050806\";\n    ctx.fillRect(0, 0, canvas.width, canvas.height);\n    draw(canvas.width, canvas.height);\n  } else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}