Python数式リアルタイム評価グラフ

入力した数式をPython側で許可関数に限定して安全評価し、f(x)の曲線をcanvasに即時描画。マウス追従でx・f(x)を読み取れる関数プロッタ。

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

#pyodide#math#canvas#realtime

ライブデモ

使用例(お題: カフェ 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 &nbsp; / 例: <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 &nbsp; / 例: <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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。