Python数式リアルタイム評価グラフ
入力した数式をPython側で許可関数に限定して安全評価し、f(x)の曲線をcanvasに即時描画。マウス追従でx・f(x)を読み取れる関数プロッタ。
外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW:焙煎カーブシミュレータ。数式をPythonで安全評価しcanvas描画 -->
<section class="mb-roast" aria-label="MOON BREW 焙煎カーブ">
<header class="mb-roast__head">
<span class="mb-roast__icon">☕</span>
<div>
<h1 class="mb-roast__title">焙煎カーブ シミュレータ</h1>
<p class="mb-roast__sub">温度プロファイル f(x) を入力して焼き上がりを予測</p>
</div>
<span class="mb-roast__badge" id="badge" data-state="boot">…</span>
</header>
<div class="mb-roast__plot">
<canvas id="cv" width="380" height="190" aria-label="焙煎カーブ"></canvas>
</div>
<div class="mb-roast__panel">
<label class="mb-roast__field">
<span>温度式 f(x)</span>
<input id="expr" type="text" value="sin(x) + 0.4*sin(3*x)"
spellcheck="false" disabled aria-label="温度の数式">
</label>
<div class="mb-roast__read">
<span>焙煎時間 <b id="rx">0.00</b> 分</span>
<span>温度指数 <b id="ry">—</b></span>
</div>
<p class="mb-roast__hint">sin / cos / exp / sqrt などが使えます。グラフ上をなぞると値を表示。</p>
</div>
</section>
CSS
/* MOON BREW:焙煎カーブ シミュレータ */
:root {
--cream: #f5ede1;
--brown: #2b1d12;
--brown2: #4a3422;
--amber: #c98a3b;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
display: grid;
place-items: center;
background: linear-gradient(160deg, #efe2cf, var(--cream));
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
color: var(--brown);
overflow: hidden;
}
.mb-roast {
width: min(540px, 94vw);
padding: 18px 22px;
border-radius: 18px;
background: #fffaf3;
box-shadow: 0 18px 44px rgba(43, 29, 18, 0.16);
}
/* ヘッダ */
.mb-roast__head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.mb-roast__icon { font-size: 22px; }
.mb-roast__title { margin: 0; font-size: 17px; font-weight: 700; font-family: "Hiragino Mincho ProN", serif; }
.mb-roast__sub { margin: 2px 0 0; font-size: 11px; color: #8a755f; }
.mb-roast__badge {
margin-left: auto;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
background: #ece0cf;
color: #a8957f;
}
.mb-roast__badge[data-state="ok"] { background: rgba(201, 138, 59, 0.18); color: var(--amber); }
.mb-roast__badge[data-state="err"] { background: rgba(180, 70, 50, 0.16); color: #b44632; }
/* プロット */
.mb-roast__plot {
border-radius: 12px;
background: linear-gradient(180deg, #2b1d12, #3a2718);
padding: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
#cv { display: block; width: 100%; height: auto; border-radius: 8px; }
/* パネル */
.mb-roast__panel { margin-top: 14px; display: grid; gap: 10px; }
.mb-roast__field { display: grid; gap: 4px; }
.mb-roast__field span { font-size: 11px; font-weight: 700; color: #8a755f; }
.mb-roast__field input {
font: inherit;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 13px;
padding: 9px 12px;
border: 1px solid #e1d3bf;
border-radius: 9px;
background: var(--cream);
color: var(--brown);
outline: none;
transition: border-color 0.2s ease;
}
.mb-roast__field input:focus { border-color: var(--amber); }
.mb-roast__field input:disabled { opacity: 0.55; }
.mb-roast__read {
display: flex;
gap: 18px;
font-size: 12px;
color: #6d5b49;
}
.mb-roast__read b { color: var(--amber); font-variant-numeric: tabular-nums; font-size: 13px; }
.mb-roast__hint { margin: 0; font-size: 10.5px; color: #a8957f; line-height: 1.6; }
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const exprIn = $("expr"), badge = $("badge"), cv = $("cv"), rx = $("rx"), ry = $("ry");
if (exprIn && badge && cv && rx && ry) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
const X_MIN = 0, X_MAX = 12, Y_MIN = -1.6, Y_MAX = 1.6, N = 220;
let pyodide = null, pts = [], debounce = 0;
// 座標変換(数学座標→ピクセル)
const sx = (x) => (x - X_MIN) / (X_MAX - X_MIN) * W;
const sy = (y) => H - (y - Y_MIN) / (Y_MAX - Y_MIN) * H;
// Python側で許可関数のみに限定して数式を安全評価
function evalExpr(expr) {
pyodide.globals.set("EXPR", expr);
pyodide.globals.set("N", N);
pyodide.globals.set("XMIN", X_MIN);
pyodide.globals.set("XMAX", X_MAX);
return pyodide.runPython(`
import math
# 許可する関数・定数のみ名前空間に用意(evalの安全対策)
allowed = {k: getattr(math, k) for k in
("sin","cos","tan","exp","log","sqrt","fabs","pi","e","floor","ceil","atan","sinh","cosh","tanh")}
allowed["abs"] = abs
code = compile(EXPR, "<expr>", "eval")
for name in code.co_names:
if name not in allowed:
raise ValueError(f"使用不可: {name}")
ys = []
for i in range(N):
x = XMIN + (XMAX - XMIN) * i / (N - 1)
try:
y = eval(code, {"__builtins__": {}}, {**allowed, "x": x})
ys.append(float(y))
except Exception:
ys.append(float("nan"))
ys
`).toJs();
}
// 焙煎チャンバー風グリッド
function drawGrid() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = "rgba(245, 237, 225, 0.07)";
ctx.lineWidth = 1;
for (let gx = X_MIN + 2; gx < X_MAX; gx += 2) {
ctx.beginPath(); ctx.moveTo(sx(gx), 0); ctx.lineTo(sx(gx), H); ctx.stroke();
}
for (let gy = -1; gy <= 1; gy += 0.5) {
ctx.beginPath(); ctx.moveTo(0, sy(gy)); ctx.lineTo(W, sy(gy)); ctx.stroke();
}
// 基準線
ctx.strokeStyle = "rgba(201, 138, 59, 0.3)";
ctx.beginPath(); ctx.moveTo(0, sy(0)); ctx.lineTo(W, sy(0)); ctx.stroke();
}
// 焙煎カーブを琥珀グラデで描画
function drawCurve() {
drawGrid();
if (!pts.length) return;
ctx.save();
ctx.lineWidth = 2.6;
ctx.lineJoin = "round";
ctx.shadowColor = "#c98a3b";
ctx.shadowBlur = 8;
const grad = ctx.createLinearGradient(0, 0, W, 0);
grad.addColorStop(0, "#e7b76b");
grad.addColorStop(1, "#c98a3b");
ctx.strokeStyle = grad;
ctx.beginPath();
let pen = false;
pts.forEach((y, i) => {
const x = X_MIN + (X_MAX - X_MIN) * i / (N - 1);
if (!Number.isFinite(y)) { pen = false; return; } // 不連続でペンを上げる
const px = sx(x), py = sy(Math.max(Y_MIN, Math.min(Y_MAX, y)));
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
});
ctx.stroke();
ctx.restore();
// 終端の値を表示
const last = pts[pts.length - 1];
rx.textContent = X_MAX.toFixed(2);
ry.textContent = Number.isFinite(last) ? last.toFixed(2) : "—";
}
// 入力評価(デバウンス)
function handle() {
if (!pyodide) return;
clearTimeout(debounce);
debounce = setTimeout(() => {
try {
pts = Array.from(evalExpr(exprIn.value.trim() || "0"));
badge.dataset.state = "ok"; badge.textContent = "OK";
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "式エラー";
pts = [];
}
drawCurve();
}, 120);
}
exprIn.addEventListener("input", handle);
// なぞって(時間, 温度指数)を読み取り
cv.addEventListener("pointermove", (e) => {
if (!pts.length) return;
const rect = cv.getBoundingClientRect();
const x = X_MIN + (e.clientX - rect.left) / rect.width * (X_MAX - X_MIN);
const idx = Math.round((x - X_MIN) / (X_MAX - X_MIN) * (N - 1));
const y = pts[Math.max(0, Math.min(N - 1, idx))];
rx.textContent = x.toFixed(2);
ry.textContent = Number.isFinite(y) ? y.toFixed(2) : "—";
});
// Pyodide起動
(async () => {
drawGrid();
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
badge.dataset.state = "ok"; badge.textContent = "OK";
exprIn.disabled = false;
handle();
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "オフライン";
}
})();
}
コード
HTML
<!-- Pythonで数式を安全評価しリアルタイムにグラフ描画するデモ -->
<main class="calc" aria-label="Python数式評価">
<div class="calc__left">
<label class="calc__label" for="expr">f(x) = </label>
<div class="calc__field">
<input id="expr" class="calc__input" type="text" value="sin(x) * exp(-x*x/8)"
spellcheck="false" autocomplete="off" disabled aria-label="数式入力">
<span class="calc__badge" id="badge" data-state="boot">…</span>
</div>
<p class="calc__hint">使える関数: sin cos tan exp log sqrt abs pi e / 例: <code>cos(x)*x/3</code></p>
<div class="calc__readout">
<span>x = <b id="rx">0.00</b></span>
<span>f(x) = <b id="ry">0.00</b></span>
</div>
</div>
<!-- グラフ -->
<canvas id="cv" class="calc__canvas" width="380" height="300" aria-label="グラフ"></canvas>
</main>
CSS
:root {
--bg: #0a0e1a;
--panel: #121829;
--ink: #e9eeff;
--muted: #8b96bd;
--accent: #f472b6;
--accent-2: #38bdf8;
--line: #232b45;
--ok: #5ee6a8;
--err: #ff6b81;
--mono: ui-monospace, "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;
--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(700px 360px at 10% 0%, #1a1a3a 0%, transparent 55%),
radial-gradient(600px 400px at 100% 100%, #102433 0%, transparent 55%),
var(--bg);
}
.calc {
width: min(100%, 660px);
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
box-shadow: 0 26px 60px -26px rgba(0,0,0,.8);
}
@media (max-width: 560px) { .calc { grid-template-columns: 1fr; } }
.calc__label { font-family: var(--mono); font-size: 14px; color: var(--accent); }
.calc__field {
position: relative;
margin: 8px 0 10px;
}
.calc__input {
width: 100%;
font-family: var(--mono);
font-size: 15px;
color: var(--ink);
background: #0c1120;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 44px 12px 14px;
transition: border-color .15s ease, box-shadow .15s ease;
}
.calc__input:focus { outline: none; border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(56,189,248,.18); }
.calc__input:disabled { opacity: .55; }
/* 評価状態バッジ */
.calc__badge {
position: absolute;
right: 10px; top: 50%; transform: translateY(-50%);
font-size: 10px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid currentColor;
}
.calc__badge[data-state="boot"] { color: #ffd166; }
.calc__badge[data-state="ok"] { color: var(--ok); }
.calc__badge[data-state="err"] { color: var(--err); }
.calc__hint { margin: 0 0 14px; font-size: 11px; color: var(--muted); line-height: 1.6; }
.calc__hint code { background: #0c1120; padding: 1px 6px; border-radius: 5px; font-family: var(--mono); color: var(--accent-2); }
.calc__readout {
display: flex;
gap: 18px;
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
}
.calc__readout b { color: var(--ink); font-variant-numeric: tabular-nums; }
.calc__canvas {
width: 100%;
height: auto;
aspect-ratio: 19 / 15;
border-radius: 12px;
background:
linear-gradient(180deg, #0d1326, #0a0f1f);
border: 1px solid var(--line);
display: block;
}
@media (prefers-reduced-motion: reduce) {
.calc__input { transition: none; }
}
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const exprIn = $("expr"), badge = $("badge"), cv = $("cv"), rx = $("rx"), ry = $("ry");
if (exprIn && badge && cv && rx && ry) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
const X_MIN = -8, X_MAX = 8, Y_MIN = -1.6, Y_MAX = 1.6, N = 240;
let pyodide = null, pts = [], debounce = 0;
// 座標変換(数学座標→ピクセル)
const sx = (x) => (x - X_MIN) / (X_MAX - X_MIN) * W;
const sy = (y) => H - (y - Y_MIN) / (Y_MAX - Y_MIN) * H;
// Python側で安全な名前空間に限定して数式を評価
function evalExpr(expr) {
pyodide.globals.set("EXPR", expr);
pyodide.globals.set("N", N);
pyodide.globals.set("XMIN", X_MIN);
pyodide.globals.set("XMAX", X_MAX);
return pyodide.runPython(`
import math
# 許可する関数・定数だけを名前空間に用意(evalの安全対策)
allowed = {k: getattr(math, k) for k in
("sin","cos","tan","exp","log","sqrt","fabs","pi","e","floor","ceil","atan","asin","acos","sinh","cosh","tanh")}
allowed["abs"] = abs
code = compile(EXPR, "<expr>", "eval")
# 危険な名前が含まれていないか軽くチェック
for name in code.co_names:
if name not in allowed:
raise ValueError(f"使用不可: {name}")
ys = []
for i in range(N):
x = XMIN + (XMAX - XMIN) * i / (N - 1)
try:
y = eval(code, {"__builtins__": {}}, {**allowed, "x": x})
ys.append(float(y))
except Exception:
ys.append(float("nan"))
ys
`).toJs();
}
// グリッド+軸を描く
function drawGrid() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = "rgba(255,255,255,.06)";
ctx.lineWidth = 1;
for (let gx = X_MIN; gx <= X_MAX; gx += 2) {
ctx.beginPath(); ctx.moveTo(sx(gx), 0); ctx.lineTo(sx(gx), H); ctx.stroke();
}
for (let gy = -1; gy <= 1; gy += 0.5) {
ctx.beginPath(); ctx.moveTo(0, sy(gy)); ctx.lineTo(W, sy(gy)); ctx.stroke();
}
// 軸
ctx.strokeStyle = "rgba(255,255,255,.22)";
ctx.beginPath(); ctx.moveTo(0, sy(0)); ctx.lineTo(W, sy(0)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx(0), 0); ctx.lineTo(sx(0), H); ctx.stroke();
}
// 曲線を描く
function drawCurve() {
drawGrid();
if (!pts.length) return;
ctx.save();
ctx.lineWidth = 2.4;
ctx.lineJoin = "round";
ctx.shadowColor = "#f472b6";
ctx.shadowBlur = 8;
const grad = ctx.createLinearGradient(0, 0, W, 0);
grad.addColorStop(0, "#38bdf8");
grad.addColorStop(1, "#f472b6");
ctx.strokeStyle = grad;
ctx.beginPath();
let pen = false;
pts.forEach((y, i) => {
const x = X_MIN + (X_MAX - X_MIN) * i / (N - 1);
if (!Number.isFinite(y)) { pen = false; return; } // 不連続点でペンを上げる
const px = sx(x), py = sy(Math.max(Y_MIN, Math.min(Y_MAX, y)));
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
});
ctx.stroke();
ctx.restore();
// 中央 x=0 付近の値を読み出し表示
const mid = pts[Math.round((0 - X_MIN) / (X_MAX - X_MIN) * (N - 1))];
rx.textContent = "0.00";
ry.textContent = Number.isFinite(mid) ? mid.toFixed(2) : "NaN";
}
// 入力評価(デバウンス)
function handle() {
if (!pyodide) return;
clearTimeout(debounce);
debounce = setTimeout(() => {
try {
pts = Array.from(evalExpr(exprIn.value.trim() || "0"));
badge.dataset.state = "ok"; badge.textContent = "OK";
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "ERR";
pts = [];
}
drawCurve();
}, 120);
}
exprIn.addEventListener("input", handle);
// マウス追従で(x, f(x))を読み取り
cv.addEventListener("pointermove", (e) => {
if (!pts.length) return;
const rect = cv.getBoundingClientRect();
const x = X_MIN + (e.clientX - rect.left) / rect.width * (X_MAX - X_MIN);
const idx = Math.round((x - X_MIN) / (X_MAX - X_MIN) * (N - 1));
const y = pts[Math.max(0, Math.min(N - 1, idx))];
rx.textContent = x.toFixed(2);
ry.textContent = Number.isFinite(y) ? y.toFixed(2) : "NaN";
});
// Pyodide起動
(async () => {
drawGrid();
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
badge.dataset.state = "ok"; badge.textContent = "OK";
exprIn.disabled = false;
handle();
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "ERR";
}
})();
}
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「Python数式リアルタイム評価グラフ」の効果を追加してください。
# 追加してほしい効果
Python数式リアルタイム評価グラフ(Python (Pyodideブラウザ実行))
入力した数式をPython側で許可関数に限定して安全評価し、f(x)の曲線をcanvasに即時描画。マウス追従でx・f(x)を読み取れる関数プロッタ。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- Pythonで数式を安全評価しリアルタイムにグラフ描画するデモ -->
<main class="calc" aria-label="Python数式評価">
<div class="calc__left">
<label class="calc__label" for="expr">f(x) = </label>
<div class="calc__field">
<input id="expr" class="calc__input" type="text" value="sin(x) * exp(-x*x/8)"
spellcheck="false" autocomplete="off" disabled aria-label="数式入力">
<span class="calc__badge" id="badge" data-state="boot">…</span>
</div>
<p class="calc__hint">使える関数: sin cos tan exp log sqrt abs pi e / 例: <code>cos(x)*x/3</code></p>
<div class="calc__readout">
<span>x = <b id="rx">0.00</b></span>
<span>f(x) = <b id="ry">0.00</b></span>
</div>
</div>
<!-- グラフ -->
<canvas id="cv" class="calc__canvas" width="380" height="300" aria-label="グラフ"></canvas>
</main>
【CSS】
:root {
--bg: #0a0e1a;
--panel: #121829;
--ink: #e9eeff;
--muted: #8b96bd;
--accent: #f472b6;
--accent-2: #38bdf8;
--line: #232b45;
--ok: #5ee6a8;
--err: #ff6b81;
--mono: ui-monospace, "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;
--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(700px 360px at 10% 0%, #1a1a3a 0%, transparent 55%),
radial-gradient(600px 400px at 100% 100%, #102433 0%, transparent 55%),
var(--bg);
}
.calc {
width: min(100%, 660px);
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
box-shadow: 0 26px 60px -26px rgba(0,0,0,.8);
}
@media (max-width: 560px) { .calc { grid-template-columns: 1fr; } }
.calc__label { font-family: var(--mono); font-size: 14px; color: var(--accent); }
.calc__field {
position: relative;
margin: 8px 0 10px;
}
.calc__input {
width: 100%;
font-family: var(--mono);
font-size: 15px;
color: var(--ink);
background: #0c1120;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 44px 12px 14px;
transition: border-color .15s ease, box-shadow .15s ease;
}
.calc__input:focus { outline: none; border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(56,189,248,.18); }
.calc__input:disabled { opacity: .55; }
/* 評価状態バッジ */
.calc__badge {
position: absolute;
right: 10px; top: 50%; transform: translateY(-50%);
font-size: 10px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid currentColor;
}
.calc__badge[data-state="boot"] { color: #ffd166; }
.calc__badge[data-state="ok"] { color: var(--ok); }
.calc__badge[data-state="err"] { color: var(--err); }
.calc__hint { margin: 0 0 14px; font-size: 11px; color: var(--muted); line-height: 1.6; }
.calc__hint code { background: #0c1120; padding: 1px 6px; border-radius: 5px; font-family: var(--mono); color: var(--accent-2); }
.calc__readout {
display: flex;
gap: 18px;
font-family: var(--mono);
font-size: 13px;
color: var(--muted);
}
.calc__readout b { color: var(--ink); font-variant-numeric: tabular-nums; }
.calc__canvas {
width: 100%;
height: auto;
aspect-ratio: 19 / 15;
border-radius: 12px;
background:
linear-gradient(180deg, #0d1326, #0a0f1f);
border: 1px solid var(--line);
display: block;
}
@media (prefers-reduced-motion: reduce) {
.calc__input { transition: none; }
}
【JavaScript】
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const exprIn = $("expr"), badge = $("badge"), cv = $("cv"), rx = $("rx"), ry = $("ry");
if (exprIn && badge && cv && rx && ry) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
const X_MIN = -8, X_MAX = 8, Y_MIN = -1.6, Y_MAX = 1.6, N = 240;
let pyodide = null, pts = [], debounce = 0;
// 座標変換(数学座標→ピクセル)
const sx = (x) => (x - X_MIN) / (X_MAX - X_MIN) * W;
const sy = (y) => H - (y - Y_MIN) / (Y_MAX - Y_MIN) * H;
// Python側で安全な名前空間に限定して数式を評価
function evalExpr(expr) {
pyodide.globals.set("EXPR", expr);
pyodide.globals.set("N", N);
pyodide.globals.set("XMIN", X_MIN);
pyodide.globals.set("XMAX", X_MAX);
return pyodide.runPython(`
import math
# 許可する関数・定数だけを名前空間に用意(evalの安全対策)
allowed = {k: getattr(math, k) for k in
("sin","cos","tan","exp","log","sqrt","fabs","pi","e","floor","ceil","atan","asin","acos","sinh","cosh","tanh")}
allowed["abs"] = abs
code = compile(EXPR, "<expr>", "eval")
# 危険な名前が含まれていないか軽くチェック
for name in code.co_names:
if name not in allowed:
raise ValueError(f"使用不可: {name}")
ys = []
for i in range(N):
x = XMIN + (XMAX - XMIN) * i / (N - 1)
try:
y = eval(code, {"__builtins__": {}}, {**allowed, "x": x})
ys.append(float(y))
except Exception:
ys.append(float("nan"))
ys
`).toJs();
}
// グリッド+軸を描く
function drawGrid() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = "rgba(255,255,255,.06)";
ctx.lineWidth = 1;
for (let gx = X_MIN; gx <= X_MAX; gx += 2) {
ctx.beginPath(); ctx.moveTo(sx(gx), 0); ctx.lineTo(sx(gx), H); ctx.stroke();
}
for (let gy = -1; gy <= 1; gy += 0.5) {
ctx.beginPath(); ctx.moveTo(0, sy(gy)); ctx.lineTo(W, sy(gy)); ctx.stroke();
}
// 軸
ctx.strokeStyle = "rgba(255,255,255,.22)";
ctx.beginPath(); ctx.moveTo(0, sy(0)); ctx.lineTo(W, sy(0)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx(0), 0); ctx.lineTo(sx(0), H); ctx.stroke();
}
// 曲線を描く
function drawCurve() {
drawGrid();
if (!pts.length) return;
ctx.save();
ctx.lineWidth = 2.4;
ctx.lineJoin = "round";
ctx.shadowColor = "#f472b6";
ctx.shadowBlur = 8;
const grad = ctx.createLinearGradient(0, 0, W, 0);
grad.addColorStop(0, "#38bdf8");
grad.addColorStop(1, "#f472b6");
ctx.strokeStyle = grad;
ctx.beginPath();
let pen = false;
pts.forEach((y, i) => {
const x = X_MIN + (X_MAX - X_MIN) * i / (N - 1);
if (!Number.isFinite(y)) { pen = false; return; } // 不連続点でペンを上げる
const px = sx(x), py = sy(Math.max(Y_MIN, Math.min(Y_MAX, y)));
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
});
ctx.stroke();
ctx.restore();
// 中央 x=0 付近の値を読み出し表示
const mid = pts[Math.round((0 - X_MIN) / (X_MAX - X_MIN) * (N - 1))];
rx.textContent = "0.00";
ry.textContent = Number.isFinite(mid) ? mid.toFixed(2) : "NaN";
}
// 入力評価(デバウンス)
function handle() {
if (!pyodide) return;
clearTimeout(debounce);
debounce = setTimeout(() => {
try {
pts = Array.from(evalExpr(exprIn.value.trim() || "0"));
badge.dataset.state = "ok"; badge.textContent = "OK";
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "ERR";
pts = [];
}
drawCurve();
}, 120);
}
exprIn.addEventListener("input", handle);
// マウス追従で(x, f(x))を読み取り
cv.addEventListener("pointermove", (e) => {
if (!pts.length) return;
const rect = cv.getBoundingClientRect();
const x = X_MIN + (e.clientX - rect.left) / rect.width * (X_MAX - X_MIN);
const idx = Math.round((x - X_MIN) / (X_MAX - X_MIN) * (N - 1));
const y = pts[Math.max(0, Math.min(N - 1, idx))];
rx.textContent = x.toFixed(2);
ry.textContent = Number.isFinite(y) ? y.toFixed(2) : "NaN";
});
// Pyodide起動
(async () => {
drawGrid();
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
badge.dataset.state = "ok"; badge.textContent = "OK";
exprIn.disabled = false;
handle();
} catch (e) {
badge.dataset.state = "err"; badge.textContent = "ERR";
}
})();
}
# 外部ライブラリ
https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。