Pythonで計算しcanvas描画 (リサージュ曲線)
Python(math)でリサージュ曲線の座標列を計算し、canvasへネオン風にアニメーション描画。スライダーで周波数と位相をリアルタイム操作できる。
外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。