feat: add Calculator & Grapher tool

Adds a full-featured mathematical calculator and interactive function
grapher at /calculate. Powered by Math.js v15 with a HiDPI Canvas
renderer for the graph.

- Evaluates arbitrary math expressions (trig, log, complex, matrices,
  factorials, combinatorics, and more) with named variable scope
- Persists history (50 entries) and variables via localStorage
- 32 quick-insert buttons for constants and functions
- Interactive graph: pan (drag), zoom (scroll), crosshair tooltip
  showing cursor coords and f₁(x)…f₈(x) values simultaneously
- Up to 8 color-coded functions with inline color pickers and
  visibility toggles
- Discontinuity detection for functions like tan(x)
- Adaptive grid labels that rescale with zoom
- Responsive layout: 2/5–3/5 split on desktop, tabbed on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 20:44:53 +01:00
parent aa890a0d55
commit 9efa783ca3
11 changed files with 1249 additions and 1 deletions

View File

@@ -0,0 +1,157 @@
import { create, all, type EvalFunction } from 'mathjs';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const math = create(all, { number: 'number', precision: 14 } as any);
function buildScope(variables: Record<string, string>): Record<string, unknown> {
const scope: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(variables)) {
if (!expr.trim()) continue;
try {
scope[name] = math.evaluate(expr);
} catch {
// skip invalid variables
}
}
return scope;
}
export interface EvalResult {
result: string;
error: boolean;
assignedName?: string;
assignedValue?: string;
}
export function evaluateExpression(
expression: string,
variables: Record<string, string> = {}
): EvalResult {
const trimmed = expression.trim();
if (!trimmed) return { result: '', error: false };
try {
const scope = buildScope(variables);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = math.evaluate(trimmed, scope as any);
const formatted = formatValue(raw);
// Detect assignment: "name = expr" or "name(args) = expr"
const assignMatch = trimmed.match(/^([a-zA-Z_]\w*)\s*(?:\([^)]*\))?\s*=/);
if (assignMatch) {
return {
result: formatted,
error: false,
assignedName: assignMatch[1],
assignedValue: formatted,
};
}
return { result: formatted, error: false };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { result: msg.replace(/^Error: /, ''), error: true };
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') {
if (!isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
if (value === 0) return '0';
const abs = Math.abs(value);
if (abs >= 1e13 || (abs < 1e-7 && abs > 0)) {
return value.toExponential(6).replace(/\.?0+(e)/, '$1');
}
return parseFloat(value.toPrecision(12)).toString();
}
try {
return math.format(value as never, { precision: 10 });
} catch {
return String(value);
}
}
// Compilation cache for fast repeated graph evaluation
const compileCache = new Map<string, EvalFunction>();
function getCompiled(expr: string): EvalFunction | null {
if (!compileCache.has(expr)) {
try {
compileCache.set(expr, math.compile(expr) as EvalFunction);
} catch {
return null;
}
if (compileCache.size > 200) {
compileCache.delete(compileCache.keys().next().value!);
}
}
return compileCache.get(expr) ?? null;
}
export function evaluateAt(
expression: string,
x: number,
variables: Record<string, string>
): number {
const compiled = getCompiled(expression);
if (!compiled) return NaN;
try {
const scope = buildScope(variables);
const result = compiled.evaluate({ ...scope, x });
return typeof result === 'number' && isFinite(result) ? result : NaN;
} catch {
return NaN;
}
}
export interface GraphPoint {
x: number;
y: number;
}
export function sampleFunction(
expression: string,
xMin: number,
xMax: number,
numPoints: number,
variables: Record<string, string> = {}
): Array<GraphPoint | null> {
if (!expression.trim()) return [];
const compiled = getCompiled(expression);
if (!compiled) return [];
const scope = buildScope(variables);
const points: Array<GraphPoint | null> = [];
const step = (xMax - xMin) / numPoints;
let prevY: number | null = null;
const jumpThreshold = Math.abs(xMax - xMin) * 4;
for (let i = 0; i <= numPoints; i++) {
const x = xMin + i * step;
let y: number;
try {
const r = compiled.evaluate({ ...scope, x });
y = typeof r === 'number' ? r : NaN;
} catch {
y = NaN;
}
if (!isFinite(y) || isNaN(y)) {
if (points.length > 0 && points[points.length - 1] !== null) {
points.push(null);
}
prevY = null;
} else {
if (prevY !== null && Math.abs(y - prevY) > jumpThreshold) {
points.push(null);
}
points.push({ x, y });
prevY = y;
}
}
return points;
}