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:
157
lib/calculate/math-engine.ts
Normal file
157
lib/calculate/math-engine.ts
Normal 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;
|
||||
}
|
||||
107
lib/calculate/store.ts
Normal file
107
lib/calculate/store.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export const FUNCTION_COLORS = [
|
||||
'#f472b6',
|
||||
'#60a5fa',
|
||||
'#4ade80',
|
||||
'#fb923c',
|
||||
'#a78bfa',
|
||||
'#22d3ee',
|
||||
'#fbbf24',
|
||||
'#f87171',
|
||||
];
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
expression: string;
|
||||
result: string;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface GraphFunction {
|
||||
id: string;
|
||||
expression: string;
|
||||
color: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface CalculateStore {
|
||||
expression: string;
|
||||
history: HistoryEntry[];
|
||||
variables: Record<string, string>;
|
||||
graphFunctions: GraphFunction[];
|
||||
setExpression: (expr: string) => void;
|
||||
addToHistory: (entry: Omit<HistoryEntry, 'id'>) => void;
|
||||
clearHistory: () => void;
|
||||
setVariable: (name: string, value: string) => void;
|
||||
removeVariable: (name: string) => void;
|
||||
addGraphFunction: () => void;
|
||||
updateGraphFunction: (
|
||||
id: string,
|
||||
updates: Partial<Pick<GraphFunction, 'expression' | 'color' | 'visible'>>
|
||||
) => void;
|
||||
removeGraphFunction: (id: string) => void;
|
||||
}
|
||||
|
||||
const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
export const useCalculateStore = create<CalculateStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
expression: '',
|
||||
history: [],
|
||||
variables: {},
|
||||
graphFunctions: [
|
||||
{ id: 'init-1', expression: 'sin(x)', color: FUNCTION_COLORS[0], visible: true },
|
||||
{ id: 'init-2', expression: 'cos(x)', color: FUNCTION_COLORS[1], visible: true },
|
||||
],
|
||||
|
||||
setExpression: (expression) => set({ expression }),
|
||||
|
||||
addToHistory: (entry) =>
|
||||
set((state) => ({
|
||||
history: [{ ...entry, id: uid() }, ...state.history].slice(0, 50),
|
||||
})),
|
||||
|
||||
clearHistory: () => set({ history: [] }),
|
||||
|
||||
setVariable: (name, value) =>
|
||||
set((state) => ({ variables: { ...state.variables, [name]: value } })),
|
||||
|
||||
removeVariable: (name) =>
|
||||
set((state) => {
|
||||
const v = { ...state.variables };
|
||||
delete v[name];
|
||||
return { variables: v };
|
||||
}),
|
||||
|
||||
addGraphFunction: () =>
|
||||
set((state) => {
|
||||
const used = new Set(state.graphFunctions.map((f) => f.color));
|
||||
const color =
|
||||
FUNCTION_COLORS.find((c) => !used.has(c)) ??
|
||||
FUNCTION_COLORS[state.graphFunctions.length % FUNCTION_COLORS.length];
|
||||
return {
|
||||
graphFunctions: [
|
||||
...state.graphFunctions,
|
||||
{ id: uid(), expression: '', color, visible: true },
|
||||
],
|
||||
};
|
||||
}),
|
||||
|
||||
updateGraphFunction: (id, updates) =>
|
||||
set((state) => ({
|
||||
graphFunctions: state.graphFunctions.map((f) =>
|
||||
f.id === id ? { ...f, ...updates } : f
|
||||
),
|
||||
})),
|
||||
|
||||
removeGraphFunction: (id) =>
|
||||
set((state) => ({
|
||||
graphFunctions: state.graphFunctions.filter((f) => f.id !== id),
|
||||
})),
|
||||
}),
|
||||
{ name: 'kit-calculate-v1' }
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user