AI Dev Lab
PlotPad

PlotPad ができるまで — ブラウザで動く関数グラフ電卓

関数グラフ描画ツールの開発ログ。 式の解析と記号微分は mathjs に任せ、 1 ピクセル刻みのサンプリング描画、 発散点での線の切り方、 ドラッグ移動とカーソル基準のズームを自前で実装した記録です。

数式を入れたら、その場でグラフになってほしい。それだけのことなのに、意外と手頃な選択肢がありません。

高機能なグラフ電卓サービスは重かったり、アカウントが要ったり。表計算ソフトで無理やり描くと、範囲や刻みの設定に手間がかかる。「y = sin(x) を見たい」の 1 分の作業に、そこまでかけたくない。

PlotPad は、数式を打つと即座にグラフを描く関数グラフ電卓です。複数の式を重ねたり、ドラッグで動かしたり、導関数を並べたり。全部ブラウザの中で完結します。


#🎯 「式 → グラフ」を最短にする

やりたいことは単純です。x^2 - 3 と打ったら放物線が出る。sin(x) と打ったら波が出る。この最短経路を、余計な設定なしで通したい。

そのために、式の入力欄に打った瞬間から描画が更新される作りにしました。範囲や刻みは自動で「きりの良い」値が選ばれ、必要なら後から動かせる。まず絵が出て、それから調整する、という順番です。


#🏗️ 式の解釈は mathjs に任せる

数式を解釈して計算する部分——sin(x^2) + 1 のような文字列を「x を入れたら値が返る関数」に変える——は、ゼロから書くと構文解析器を実装することになります。ここは車輪の再発明をせず、mathjs に委譲しました。

ts
import { compile, derivative } from "mathjs"

export function compileExpr(expr: string): CompiledExpr {
 const trimmed = expr.trim
 if (!trimmed) return { expr, eval: => NaN, error: null }
 try {
 const c = compile(trimmed)
 c.evaluate({ x: 1 }) // 1 点評価して実行時に壊れないか確認
 const fn = (x: number): number => {
 try {
 const v = c.evaluate({ x })
 return typeof v === "number" && Number.isFinite(v) ? v : NaN
 } catch {
 return NaN
 }
 }
 return { expr, eval: fn, error: null }
 } catch (e) {
 return { expr, eval: => NaN, error: (e as Error).message || "式を解釈できません" }
 }
}

compile で式を一度コンパイルしておき、以降は evaluate({ x }) を呼ぶだけ。サンプリングでは同じ式を数百回評価するので、毎回パースし直さずコンパイル済みを使い回すのが効きます。

ポイントは 2 段のエラー処理です。まず compile の時点で構文エラーを捕まえ、さらに各点の評価でも try/catch して、log(-1) のような定義域の外では NaN を返す。これで「式は正しいが、この x では値がない」を描画側が扱えるようになります。


#📉 導関数:記号微分できなければ数値微分

PlotPad は元の関数に加えて、その導関数(傾きの変化)も重ねて描けます。これが地味に便利で、「どこで増減が切り替わるか」が視覚的に分かります。

導関数の求め方は 2 段構えにしました。

ts
export function derivativeOf(expr: string): CompiledExpr {
 try {
 const d = derivative(expr.trim, "x").toString // 記号微分
 return compileExpr(d)
 } catch {
 // 記号微分できなければ数値微分(中心差分)
 const base = compileExpr(expr.trim)
 if (base.error) return { expr: "", eval: => NaN, error: base.error }
 const h = 1e-5
 return {
 expr: "d/dx",
 eval: (x: number) => {
 const a = base.eval(x + h)
 const b = base.eval(x - h)
 if (!Number.isFinite(a) || !Number.isFinite(b)) return NaN
 return (a - b) / (2 * h) // 中心差分
 },
 error: null,
 }
 }
}

まず mathjs の記号微分を試します。x^2 なら 2*x という式が返り、それをコンパイルすれば正確な導関数が得られる。しかも導関数の「式そのもの」を画面に表示できます。

記号微分が効かない複雑な式では、数値微分(中心差分 (f(x+h) − f(x−h)) / 2h)にフォールバックします。式は出せませんが、グラフは描ける。「できるなら正確に、無理でも近似で必ず描く」という二段構えです。


#📏 目盛りを「きりの良い」値にする

グラフの軸の目盛りが 0.37, 0.74, 1.11... のような半端な数だと、途端に読みにくくなります。目盛りは 1, 2, 5, 10 のような切りの良い間隔であってほしい。

ts
export function niceStep(rawStep: number): number {
 if (rawStep <= 0 || !Number.isFinite(rawStep)) return 1
 const mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
 const norm = rawStep / mag
 const nice = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10
 return nice * mag
}

「理想の目盛り間隔」を計算したら、それを一番近い 1 / 2 / 5 × 10ⁿ に丸めます。桁(mag)を対数で求め、その中での位置(norm)を見て 1・2・5・10 のどれかに寄せる。この小さな関数があるだけで、どんなズーム倍率でも軸が読みやすく保たれます。グラフツールの「見やすさ」は、こういう地味な整形の積み重ねでできています。


#😓 苦労したところ

#不連続・発散をどう描くか

1/xtan(x) は、ある点で値が無限に飛びます。素直に点を線でつなぐと、飛んだ箇所に縦線が突き抜けて、まるでそこに値があるように見えてしまう。

対策は、NaN や急激に飛んだ点で線を切ること。評価が NaN を返した区間は描かず、値が不連続に大きくジャンプした箇所も分割する。「つながっていないものを、つなげて描かない」——正しいグラフのために、あえて線を引かない判断が要りました。

#入力途中のエラーで画面を荒らさない

式を打っている途中は、sin( のような未完成の状態が必ず発生します。ここで毎回赤いエラーを出すと、入力体験が最悪になる。

そこで、構文エラーは静かに保持して描画を止めるだけにし、式が有効になった瞬間に描く。エラーメッセージは、明らかに間違ったまま手が止まったときにだけ控えめに出す。打っている最中は邪魔をしないのが、入力ツールの基本だと考えています。


#🔭 今後の拡張

  • 交点・極値の自動表示 — 2 式の交わる点や、山・谷の座標を出す
  • 積分(面積)の可視化 — 指定区間の面積を塗る
  • 媒介変数・極座標x = cos(t), y = sin(t) のような表現
  • アニメーション — パラメータをスライダーで動かして曲線が変わる様子を見る

#💡 このサービスから言えること

車輪の再発明をしない判断が、質を上げることもある。 数式の構文解析は難しく、自前で書けば必ずバグが残ります。mathjs のような枯れたライブラリに任せることで、その労力を「不連続の描画」「目盛りの整形」「入力体験」といった、実際に使い心地を左右する部分に回せました。何を作り、何を借りるかの見極めが、限られた時間での質を決めます。

もうひとつは、正しく描くために「描かない」判断です。不連続点で線を切る、入力途中はエラーを出さない——どちらも「余計なことをしない」ことで正しさと使いやすさを得ています。グラフツールは足し算より引き算で良くなる場面が多い、というのがこのサービスからの実感です。

[ ./next_action ]

読んだら、 PlotPad を実際に動かす。

この開発ログは PlotPad をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →