Pythonで計算しcanvas描画 (リサージュ曲線)

Python(math)でリサージュ曲線の座標列を計算し、canvasへネオン風にアニメーション描画。スライダーで周波数と位相をリアルタイム操作できる。

外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js

#pyodide#canvas#animation#math

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:ペンライト振り付けの軌跡をPythonで計算しcanvas描画 -->
<section class="sk-light" aria-label="Sakura ペンライト軌跡">
  <header class="sk-light__head">
    <span class="sk-light__petal">🌸</span>
    <div>
      <h1 class="sk-light__title">ペンライト 振り付けメーカー</h1>
      <p class="sk-light__sub">「桜トリック」サビの光の軌跡をつくろう</p>
    </div>
  </header>

  <div class="sk-light__stage">
    <canvas id="cv" width="240" height="240" aria-label="ペンライトの軌跡"></canvas>
    <span class="sk-light__glow" aria-hidden="true"></span>
  </div>

  <div class="sk-light__panel">
    <label class="sk-ctrl">横の振り <output id="aVal">3</output>
      <input id="a" type="range" min="1" max="7" value="3" step="1">
    </label>
    <label class="sk-ctrl">縦の振り <output id="bVal">2</output>
      <input id="b" type="range" min="1" max="7" value="2" step="1">
    </label>
    <label class="sk-ctrl">ずらし <output id="dVal">0.50</output>
      <input id="d" type="range" min="0" max="314" value="50" step="1">
    </label>
    <p class="sk-light__foot" id="foot" data-state="boot">計算エンジン準備中…</p>
  </div>
</section>
CSS
/* Sakura:ペンライト振り付けメーカー */
:root {
  --pink: #ffd1e0;
  --pink2: #ff9ec0;
  --accent: #ff5e9c;
  --gray: #f3f4f7;
  --ink: #5b4a55;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  display: grid;
  place-items: center;
  background:
    radial-gradient(140% 100% at 50% 0%, #fff 0%, var(--pink) 70%, var(--pink2) 100%);
  font-family: "Hiragino Maru Gothic ProN", "Rounded Mplus 1c", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--ink);
  overflow: hidden;
}

.sk-light {
  width: min(560px, 94vw);
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: auto 1fr;
  gap: 10px 22px;
  padding: 16px 22px;
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.72);
  box-shadow: 0 18px 44px rgba(255, 94, 156, 0.22);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

/* ヘッダ */
.sk-light__head {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 12px;
}
.sk-light__petal { font-size: 24px; }
.sk-light__title { margin: 0; font-size: 17px; color: var(--accent); font-weight: 800; }
.sk-light__sub { margin: 2px 0 0; font-size: 11.5px; color: #a98699; }

/* ステージ:暗めの夜空にペンライトが光る */
.sk-light__stage {
  position: relative;
  align-self: center;
  justify-self: center;
  width: 240px;
  height: 240px;
  border-radius: 18px;
  background: radial-gradient(120% 120% at 50% 40%, #3a2740, #241828);
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
#cv { position: relative; z-index: 2; display: block; }
.sk-light__glow {
  position: absolute;
  inset: -30%;
  z-index: 1;
  background: radial-gradient(closest-side, rgba(255, 158, 192, 0.28), transparent 70%);
  pointer-events: none;
}

/* コントロールパネル */
.sk-light__panel { align-self: center; display: grid; gap: 12px; }
.sk-ctrl {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 4px 8px;
  font-size: 12px;
  font-weight: 700;
  color: #8a6f7d;
}
.sk-ctrl output {
  font-variant-numeric: tabular-nums;
  color: var(--accent);
  font-size: 12.5px;
}
.sk-ctrl input[type="range"] {
  grid-column: 1 / -1;
  width: 100%;
  height: 4px;
  border-radius: 4px;
  -webkit-appearance: none;
  appearance: none;
  background: linear-gradient(90deg, var(--pink2), var(--accent));
  outline: none;
}
.sk-ctrl input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 16px; height: 16px;
  border-radius: 50%;
  background: #fff;
  border: 3px solid var(--accent);
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(255, 94, 156, 0.4);
}
.sk-ctrl input[type="range"]::-moz-range-thumb {
  width: 14px; height: 14px;
  border-radius: 50%;
  background: #fff;
  border: 3px solid var(--accent);
  cursor: pointer;
}

.sk-light__foot {
  margin: 2px 0 0;
  font-size: 10.5px;
  color: #b794a6;
}
.sk-light__foot[data-state="ready"] { color: var(--accent); }
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), foot = $("foot");
const aIn = $("a"), bIn = $("b"), dIn = $("d");
const aVal = $("aVal"), bVal = $("bVal"), dVal = $("dVal");

if (cv && foot && aIn && bIn && dIn && aVal && bVal && dVal) {
  const ctx = cv.getContext("2d");
  const W = cv.width, H = cv.height;
  let pyodide = null, points = [], t0 = 0;
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  // JSでも同じ数式(Python読込前/失敗時もスライダーは効く)
  function lissajousJS(a, b, delta, n = 600) {
    const pts = [];
    for (let i = 0; i < n; i++) {
      const t = 2 * Math.PI * i / (n - 1);
      pts.push({ x: Math.sin(a * t + delta), y: Math.sin(b * t) });
    }
    return pts;
  }

  // Pythonが使えればPythonで軌跡計算。失敗時はJSへ
  function compute() {
    const a = +aIn.value, b = +bIn.value, delta = (+dIn.value) / 100;
    if (pyodide) {
      try {
        const flat = pyodide.runPython(`
import math
def trail(a, b, delta, n=600):
    pts = []
    for i in range(n):
        t = 2 * math.pi * i / (n - 1)
        pts.append(math.sin(a * t + delta)); pts.append(math.sin(b * t))
    return pts
trail(${a}, ${b}, ${delta})
`).toJs();
        points = [];
        for (let i = 0; i < flat.length; i += 2) points.push({ x: flat[i], y: flat[i + 1] });
        return;
      } catch (e) { /* JSへフォールバック */ }
    }
    points = lissajousJS(a, b, delta);
  }

  // ペンライト風に光る軌跡を描画
  function draw(time) {
    ctx.clearRect(0, 0, W, H);
    if (!points.length) { requestAnimationFrame(draw); return; }

    const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.38;
    const phase = reduce ? 0 : ((time - t0) / 26) % points.length;

    ctx.save();
    ctx.lineJoin = "round";
    ctx.shadowColor = "#ff9ec0";
    ctx.shadowBlur = 14;

    ctx.beginPath();
    points.forEach((p, i) => {
      const px = cx + p.x * R, py = cy + p.y * R;
      i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
    });
    const grad = ctx.createLinearGradient(cx - R, cy - R, cx + R, cy + R);
    grad.addColorStop(0, "#ffd1e0");
    grad.addColorStop(1, "#ff5e9c");
    ctx.strokeStyle = grad;
    ctx.lineWidth = 2.2;
    ctx.stroke();

    // 先端の光る粒(ペンライトの先)
    if (!reduce) {
      const head = points[Math.floor(phase)];
      const hx = cx + head.x * R, hy = cy + head.y * R;
      ctx.beginPath();
      ctx.arc(hx, hy, 5.5, 0, Math.PI * 2);
      ctx.fillStyle = "#fff";
      ctx.shadowBlur = 20;
      ctx.fill();
    }
    ctx.restore();
    requestAnimationFrame(draw);
  }

  // ラベル更新+再計算
  function refresh() {
    aVal.textContent = aIn.value;
    bVal.textContent = bIn.value;
    dVal.textContent = (+dIn.value / 100).toFixed(2);
    compute();
  }
  [aIn, bIn, dIn].forEach((el) => el.addEventListener("input", refresh));

  // まずJSで即描画
  foot.dataset.state = "js";
  foot.textContent = "描画中(計算エンジン読込中…)";
  refresh();
  t0 = performance.now();
  requestAnimationFrame(draw);

  // Pyodideが読めたらPython計算へ切替
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "Python が軌跡を計算中 🌸";
      refresh();
    } catch (e) {
      foot.dataset.state = "js";
      foot.textContent = "簡易モードで描画中";
    }
  })();
}

コード

HTML
<!-- Pythonで座標計算→canvasにリサージュ曲線を描画するデモ -->
<main class="stage" aria-label="Pythonリサージュ描画">
  <canvas id="cv" class="stage__canvas" width="640" height="360"></canvas>

  <!-- 操作パネル -->
  <section class="panel" aria-label="パラメータ">
    <div class="panel__head">
      <span class="panel__title">Lissajous</span>
      <span class="panel__by">computed in Python</span>
    </div>

    <label class="ctrl">
      <span>a 周波数 <b id="aVal">3</b></span>
      <input id="a" type="range" min="1" max="9" step="1" value="3" aria-label="a周波数">
    </label>
    <label class="ctrl">
      <span>b 周波数 <b id="bVal">4</b></span>
      <input id="b" type="range" min="1" max="9" step="1" value="4" aria-label="b周波数">
    </label>
    <label class="ctrl">
      <span>位相 δ <b id="dVal">0.50</b></span>
      <input id="d" type="range" min="0" max="314" step="2" value="50" aria-label="位相">
    </label>

    <div class="panel__foot" id="foot" data-state="boot">Pyodide 起動中…</div>
  </section>
</main>
CSS
:root {
  --bg: #060814;
  --ink: #eaf0ff;
  --muted: #93a0c8;
  --accent: #56d6c4;
  --accent-2: #8a7bff;
  --panel: rgba(16, 20, 42, .72);
  --sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 14px;
  font-family: var(--sans);
  color: var(--ink);
  background:
    radial-gradient(800px 400px at 80% 0%, #182046 0%, transparent 60%),
    radial-gradient(700px 500px at 0% 100%, #1a1140 0%, transparent 55%),
    var(--bg);
}

.stage {
  position: relative;
  width: min(100%, 640px);
  aspect-ratio: 16 / 9;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 30px 70px -28px rgba(0,0,0,.8);
  border: 1px solid #232c55;
}

.stage__canvas {
  width: 100%;
  height: 100%;
  display: block;
  background:
    radial-gradient(circle at 50% 50%, #0b1130 0%, #05070f 90%);
}

/* ガラス調パネル */
.panel {
  position: absolute;
  top: 14px;
  left: 14px;
  width: 200px;
  padding: 14px;
  border-radius: 12px;
  background: var(--panel);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255,255,255,.08);
  box-shadow: 0 8px 30px -12px rgba(0,0,0,.6);
}
.panel__head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }
.panel__title {
  font-weight: 700;
  font-size: 15px;
  letter-spacing: .03em;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.panel__by { font-size: 9px; color: var(--muted); }

.ctrl { display: block; margin-bottom: 10px; font-size: 11px; color: var(--muted); }
.ctrl span { display: flex; justify-content: space-between; margin-bottom: 5px; }
.ctrl b { color: var(--accent); font-variant-numeric: tabular-nums; }

/* スライダー外観 */
input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 4px;
  border-radius: 4px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 14px; height: 14px; border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 3px rgba(86,214,196,.35);
}
input[type="range"]::-moz-range-thumb {
  width: 14px; height: 14px; border: none; border-radius: 50%;
  background: #fff; box-shadow: 0 0 0 3px rgba(86,214,196,.35);
}

.panel__foot {
  margin-top: 4px;
  font-size: 10px;
  padding: 5px 8px;
  border-radius: 7px;
  text-align: center;
}
.panel__foot[data-state="boot"]  { color: #ffd166; background: rgba(255,209,102,.1); }
.panel__foot[data-state="ready"] { color: var(--accent); background: rgba(86,214,196,.1); }
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), foot = $("foot");
const aIn = $("a"), bIn = $("b"), dIn = $("d");
const aVal = $("aVal"), bVal = $("bVal"), dVal = $("dVal");

if (cv && foot && aIn && bIn && dIn && aVal && bVal && dVal) {
  const ctx = cv.getContext("2d");
  const W = cv.width, H = cv.height;
  let pyodide = null, points = [], t0 = 0;
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  // JSでも同じ数式で計算(Python不可・読込中でもスライダーが必ず効く)
  function lissajousPts(a, b, delta, n = 600) {
    const pts = [];
    for (let i = 0; i < n; i++) {
      const t = 2 * Math.PI * i / (n - 1);
      pts.push({ x: Math.sin(a * t + delta), y: Math.sin(b * t) });
    }
    return pts;
  }

  // Pythonが使えればPythonで、失敗時はJSで計算(曲線を必ず更新する)
  function compute() {
    const a = +aIn.value, b = +bIn.value, delta = (+dIn.value) / 100;
    if (pyodide) {
      try {
        const flat = pyodide.runPython(`
import math
def lissajous(a, b, delta, n=600):
    pts = []
    for i in range(n):
        t = 2 * math.pi * i / (n - 1)
        pts.append(math.sin(a * t + delta)); pts.append(math.sin(b * t))
    return pts
lissajous(${a}, ${b}, ${delta})
`).toJs();
        points = [];
        for (let i = 0; i < flat.length; i += 2) points.push({ x: flat[i], y: flat[i + 1] });
        return;
      } catch (e) { /* Python失敗時はJSへフォールバック */ }
    }
    points = lissajousPts(a, b, delta);
  }

  // canvasへ描画(ネオン風グロー+進行ハイライト)
  function draw(time) {
    ctx.clearRect(0, 0, W, H);
    if (!points.length) { requestAnimationFrame(draw); return; }

    const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.4;
    const phase = reduce ? 0 : ((time - t0) / 30) % points.length;

    ctx.save();
    ctx.lineJoin = "round";
    ctx.shadowColor = "#56d6c4";
    ctx.shadowBlur = 12;

    // 全体パス
    ctx.beginPath();
    points.forEach((p, i) => {
      const px = cx + p.x * R, py = cy + p.y * R;
      i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
    });
    const grad = ctx.createLinearGradient(cx - R, cy - R, cx + R, cy + R);
    grad.addColorStop(0, "#56d6c4");
    grad.addColorStop(1, "#8a7bff");
    ctx.strokeStyle = grad;
    ctx.lineWidth = 2;
    ctx.stroke();

    // 進行点(光る粒)
    if (!reduce) {
      const head = points[Math.floor(phase)];
      const hx = cx + head.x * R, hy = cy + head.y * R;
      ctx.beginPath();
      ctx.arc(hx, hy, 5, 0, Math.PI * 2);
      ctx.fillStyle = "#fff";
      ctx.shadowBlur = 18;
      ctx.fill();
    }
    ctx.restore();
    requestAnimationFrame(draw);
  }

  // ラベル更新+再計算
  function refresh() {
    aVal.textContent = aIn.value;
    bVal.textContent = bIn.value;
    dVal.textContent = (+dIn.value / 100).toFixed(2);
    compute();
  }
  [aIn, bIn, dIn].forEach((el) => el.addEventListener("input", refresh));

  // まずJSで即描画(Pyodide読込を待たずにスライダーが効く)
  foot.dataset.state = "js";
  foot.textContent = "JS で描画中(Python 読込中…)";
  refresh();
  t0 = performance.now();
  requestAnimationFrame(draw);

  // Pyodideが読めたらPython計算へ切替(同じ数式)
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "Python で計算 / canvas 描画";
      refresh();
    } catch (e) {
      foot.dataset.state = "js";
      foot.textContent = "JS で描画(Python 読込不可)";
    }
  })();
}

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「Pythonで計算しcanvas描画 (リサージュ曲線)」の効果を追加してください。

# 追加してほしい効果
Pythonで計算しcanvas描画 (リサージュ曲線)(Python (Pyodideブラウザ実行))
Python(math)でリサージュ曲線の座標列を計算し、canvasへネオン風にアニメーション描画。スライダーで周波数と位相をリアルタイム操作できる。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- Pythonで座標計算→canvasにリサージュ曲線を描画するデモ -->
<main class="stage" aria-label="Pythonリサージュ描画">
  <canvas id="cv" class="stage__canvas" width="640" height="360"></canvas>

  <!-- 操作パネル -->
  <section class="panel" aria-label="パラメータ">
    <div class="panel__head">
      <span class="panel__title">Lissajous</span>
      <span class="panel__by">computed in Python</span>
    </div>

    <label class="ctrl">
      <span>a 周波数 <b id="aVal">3</b></span>
      <input id="a" type="range" min="1" max="9" step="1" value="3" aria-label="a周波数">
    </label>
    <label class="ctrl">
      <span>b 周波数 <b id="bVal">4</b></span>
      <input id="b" type="range" min="1" max="9" step="1" value="4" aria-label="b周波数">
    </label>
    <label class="ctrl">
      <span>位相 δ <b id="dVal">0.50</b></span>
      <input id="d" type="range" min="0" max="314" step="2" value="50" aria-label="位相">
    </label>

    <div class="panel__foot" id="foot" data-state="boot">Pyodide 起動中…</div>
  </section>
</main>

【CSS】
:root {
  --bg: #060814;
  --ink: #eaf0ff;
  --muted: #93a0c8;
  --accent: #56d6c4;
  --accent-2: #8a7bff;
  --panel: rgba(16, 20, 42, .72);
  --sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 14px;
  font-family: var(--sans);
  color: var(--ink);
  background:
    radial-gradient(800px 400px at 80% 0%, #182046 0%, transparent 60%),
    radial-gradient(700px 500px at 0% 100%, #1a1140 0%, transparent 55%),
    var(--bg);
}

.stage {
  position: relative;
  width: min(100%, 640px);
  aspect-ratio: 16 / 9;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 30px 70px -28px rgba(0,0,0,.8);
  border: 1px solid #232c55;
}

.stage__canvas {
  width: 100%;
  height: 100%;
  display: block;
  background:
    radial-gradient(circle at 50% 50%, #0b1130 0%, #05070f 90%);
}

/* ガラス調パネル */
.panel {
  position: absolute;
  top: 14px;
  left: 14px;
  width: 200px;
  padding: 14px;
  border-radius: 12px;
  background: var(--panel);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255,255,255,.08);
  box-shadow: 0 8px 30px -12px rgba(0,0,0,.6);
}
.panel__head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }
.panel__title {
  font-weight: 700;
  font-size: 15px;
  letter-spacing: .03em;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.panel__by { font-size: 9px; color: var(--muted); }

.ctrl { display: block; margin-bottom: 10px; font-size: 11px; color: var(--muted); }
.ctrl span { display: flex; justify-content: space-between; margin-bottom: 5px; }
.ctrl b { color: var(--accent); font-variant-numeric: tabular-nums; }

/* スライダー外観 */
input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 4px;
  border-radius: 4px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 14px; height: 14px; border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 3px rgba(86,214,196,.35);
}
input[type="range"]::-moz-range-thumb {
  width: 14px; height: 14px; border: none; border-radius: 50%;
  background: #fff; box-shadow: 0 0 0 3px rgba(86,214,196,.35);
}

.panel__foot {
  margin-top: 4px;
  font-size: 10px;
  padding: 5px 8px;
  border-radius: 7px;
  text-align: center;
}
.panel__foot[data-state="boot"]  { color: #ffd166; background: rgba(255,209,102,.1); }
.panel__foot[data-state="ready"] { color: var(--accent); background: rgba(86,214,196,.1); }

【JavaScript】
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), foot = $("foot");
const aIn = $("a"), bIn = $("b"), dIn = $("d");
const aVal = $("aVal"), bVal = $("bVal"), dVal = $("dVal");

if (cv && foot && aIn && bIn && dIn && aVal && bVal && dVal) {
  const ctx = cv.getContext("2d");
  const W = cv.width, H = cv.height;
  let pyodide = null, points = [], t0 = 0;
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  // JSでも同じ数式で計算(Python不可・読込中でもスライダーが必ず効く)
  function lissajousPts(a, b, delta, n = 600) {
    const pts = [];
    for (let i = 0; i < n; i++) {
      const t = 2 * Math.PI * i / (n - 1);
      pts.push({ x: Math.sin(a * t + delta), y: Math.sin(b * t) });
    }
    return pts;
  }

  // Pythonが使えればPythonで、失敗時はJSで計算(曲線を必ず更新する)
  function compute() {
    const a = +aIn.value, b = +bIn.value, delta = (+dIn.value) / 100;
    if (pyodide) {
      try {
        const flat = pyodide.runPython(`
import math
def lissajous(a, b, delta, n=600):
    pts = []
    for i in range(n):
        t = 2 * math.pi * i / (n - 1)
        pts.append(math.sin(a * t + delta)); pts.append(math.sin(b * t))
    return pts
lissajous(${a}, ${b}, ${delta})
`).toJs();
        points = [];
        for (let i = 0; i < flat.length; i += 2) points.push({ x: flat[i], y: flat[i + 1] });
        return;
      } catch (e) { /* Python失敗時はJSへフォールバック */ }
    }
    points = lissajousPts(a, b, delta);
  }

  // canvasへ描画(ネオン風グロー+進行ハイライト)
  function draw(time) {
    ctx.clearRect(0, 0, W, H);
    if (!points.length) { requestAnimationFrame(draw); return; }

    const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.4;
    const phase = reduce ? 0 : ((time - t0) / 30) % points.length;

    ctx.save();
    ctx.lineJoin = "round";
    ctx.shadowColor = "#56d6c4";
    ctx.shadowBlur = 12;

    // 全体パス
    ctx.beginPath();
    points.forEach((p, i) => {
      const px = cx + p.x * R, py = cy + p.y * R;
      i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
    });
    const grad = ctx.createLinearGradient(cx - R, cy - R, cx + R, cy + R);
    grad.addColorStop(0, "#56d6c4");
    grad.addColorStop(1, "#8a7bff");
    ctx.strokeStyle = grad;
    ctx.lineWidth = 2;
    ctx.stroke();

    // 進行点(光る粒)
    if (!reduce) {
      const head = points[Math.floor(phase)];
      const hx = cx + head.x * R, hy = cy + head.y * R;
      ctx.beginPath();
      ctx.arc(hx, hy, 5, 0, Math.PI * 2);
      ctx.fillStyle = "#fff";
      ctx.shadowBlur = 18;
      ctx.fill();
    }
    ctx.restore();
    requestAnimationFrame(draw);
  }

  // ラベル更新+再計算
  function refresh() {
    aVal.textContent = aIn.value;
    bVal.textContent = bIn.value;
    dVal.textContent = (+dIn.value / 100).toFixed(2);
    compute();
  }
  [aIn, bIn, dIn].forEach((el) => el.addEventListener("input", refresh));

  // まずJSで即描画(Pyodide読込を待たずにスライダーが効く)
  foot.dataset.state = "js";
  foot.textContent = "JS で描画中(Python 読込中…)";
  refresh();
  t0 = performance.now();
  requestAnimationFrame(draw);

  // Pyodideが読めたらPython計算へ切替(同じ数式)
  (async () => {
    try {
      const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
      pyodide = await mod.loadPyodide();
      foot.dataset.state = "ready";
      foot.textContent = "Python で計算 / canvas 描画";
      refresh();
    } catch (e) {
      foot.dataset.state = "js";
      foot.textContent = "JS で描画(Python 読込不可)";
    }
  })();
}

# 外部ライブラリ
https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。