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:
328
components/calculate/ExpressionPanel.tsx
Normal file
328
components/calculate/ExpressionPanel.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Plus, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useCalculateStore } from '@/lib/calculate/store';
|
||||
import { evaluateExpression } from '@/lib/calculate/math-engine';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const QUICK_KEYS = [
|
||||
// Constants
|
||||
{ label: 'π', insert: 'pi', group: 'const' },
|
||||
{ label: 'e', insert: 'e', group: 'const' },
|
||||
{ label: 'φ', insert: '(1+sqrt(5))/2', group: 'const' },
|
||||
{ label: '∞', insert: 'Infinity', group: 'const' },
|
||||
{ label: 'i', insert: 'i', group: 'const' },
|
||||
// Ops
|
||||
{ label: '^', insert: '^', group: 'op' },
|
||||
{ label: '(', insert: '(', group: 'op' },
|
||||
{ label: ')', insert: ')', group: 'op' },
|
||||
{ label: '%', insert: ' % ', group: 'op' },
|
||||
{ label: 'mod', insert: ' mod ', group: 'op' },
|
||||
// Functions
|
||||
{ label: '√', insert: 'sqrt(', group: 'fn' },
|
||||
{ label: '∛', insert: 'cbrt(', group: 'fn' },
|
||||
{ label: '|x|', insert: 'abs(', group: 'fn' },
|
||||
{ label: 'n!', insert: '!', group: 'fn' },
|
||||
{ label: 'sin', insert: 'sin(', group: 'trig' },
|
||||
{ label: 'cos', insert: 'cos(', group: 'trig' },
|
||||
{ label: 'tan', insert: 'tan(', group: 'trig' },
|
||||
{ label: 'asin', insert: 'asin(', group: 'trig' },
|
||||
{ label: 'acos', insert: 'acos(', group: 'trig' },
|
||||
{ label: 'atan', insert: 'atan(', group: 'trig' },
|
||||
{ label: 'sinh', insert: 'sinh(', group: 'trig' },
|
||||
{ label: 'cosh', insert: 'cosh(', group: 'trig' },
|
||||
{ label: 'log', insert: 'log10(', group: 'log' },
|
||||
{ label: 'ln', insert: 'log(', group: 'log' },
|
||||
{ label: 'log₂', insert: 'log2(', group: 'log' },
|
||||
{ label: 'exp', insert: 'exp(', group: 'log' },
|
||||
{ label: 'floor', insert: 'floor(', group: 'round' },
|
||||
{ label: 'ceil', insert: 'ceil(', group: 'round' },
|
||||
{ label: 'round', insert: 'round(', group: 'round' },
|
||||
{ label: 'gcd', insert: 'gcd(', group: 'misc' },
|
||||
{ label: 'lcm', insert: 'lcm(', group: 'misc' },
|
||||
{ label: 'nCr', insert: 'combinations(', group: 'misc' },
|
||||
{ label: 'nPr', insert: 'permutations(', group: 'misc' },
|
||||
] as const;
|
||||
|
||||
export function ExpressionPanel() {
|
||||
const {
|
||||
expression, setExpression,
|
||||
history, addToHistory, clearHistory,
|
||||
variables, setVariable, removeVariable,
|
||||
} = useCalculateStore();
|
||||
|
||||
const [liveResult, setLiveResult] = useState<{ result: string; error: boolean } | null>(null);
|
||||
const [newVarName, setNewVarName] = useState('');
|
||||
const [newVarValue, setNewVarValue] = useState('');
|
||||
const [showAddVar, setShowAddVar] = useState(false);
|
||||
const [showAllKeys, setShowAllKeys] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Real-time evaluation
|
||||
useEffect(() => {
|
||||
if (!expression.trim()) { setLiveResult(null); return; }
|
||||
const r = evaluateExpression(expression, variables);
|
||||
setLiveResult(r.result ? { result: r.result, error: r.error } : null);
|
||||
}, [expression, variables]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!expression.trim()) return;
|
||||
const r = evaluateExpression(expression, variables);
|
||||
if (!r.result) return;
|
||||
addToHistory({ expression: expression.trim(), result: r.result, error: r.error });
|
||||
if (!r.error) {
|
||||
if (r.assignedName && r.assignedValue) {
|
||||
setVariable(r.assignedName, r.assignedValue);
|
||||
}
|
||||
setExpression('');
|
||||
}
|
||||
}, [expression, variables, addToHistory, setExpression, setVariable]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
const insertAtCursor = useCallback((text: string) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) { setExpression(expression + text); return; }
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const next = expression.slice(0, start) + text + expression.slice(end);
|
||||
setExpression(next);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const pos = start + text.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
});
|
||||
}, [expression, setExpression]);
|
||||
|
||||
const addVar = useCallback(() => {
|
||||
if (!newVarName.trim() || !newVarValue.trim()) return;
|
||||
setVariable(newVarName.trim(), newVarValue.trim());
|
||||
setNewVarName(''); setNewVarValue(''); setShowAddVar(false);
|
||||
}, [newVarName, newVarValue, setVariable]);
|
||||
|
||||
const visibleKeys = showAllKeys ? QUICK_KEYS : QUICK_KEYS.slice(0, 16);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full overflow-hidden">
|
||||
|
||||
{/* ── Expression input ──────────────────────────────────── */}
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Expression
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
Enter to evaluate · Shift+Enter for newline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g. sin(pi/4) * sqrt(2)"
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full bg-transparent resize-none font-mono text-sm outline-none',
|
||||
'text-foreground placeholder:text-muted-foreground/35',
|
||||
'border border-border/40 rounded-lg px-3 py-2.5',
|
||||
'focus:border-primary/50 transition-colors'
|
||||
)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Result display */}
|
||||
<div className="mt-3 flex items-baseline gap-2 min-h-[2rem]">
|
||||
{liveResult && (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground shrink-0">=</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-semibold break-all',
|
||||
liveResult.error
|
||||
? 'text-sm text-destructive/90'
|
||||
: 'text-2xl bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent'
|
||||
)}
|
||||
>
|
||||
{liveResult.result}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!expression.trim()}
|
||||
className={cn(
|
||||
'mt-2 w-full py-2 rounded-lg text-sm font-medium transition-all',
|
||||
'bg-primary/90 text-primary-foreground hover:bg-primary',
|
||||
'disabled:opacity-30 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Evaluate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Quick insert keys ─────────────────────────────────── */}
|
||||
<div className="glass rounded-xl p-3 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Insert
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAllKeys((v) => !v)}
|
||||
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{showAllKeys ? (
|
||||
<><ChevronUp className="w-3 h-3" /> less</>
|
||||
) : (
|
||||
<><ChevronDown className="w-3 h-3" /> more</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visibleKeys.map((k) => (
|
||||
<button
|
||||
key={k.label}
|
||||
onClick={() => insertAtCursor(k.insert)}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs font-mono rounded-md transition-all',
|
||||
'glass border border-transparent',
|
||||
'hover:border-primary/30 hover:bg-primary/10 hover:text-primary',
|
||||
'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Variables ─────────────────────────────────────────── */}
|
||||
<div className="glass rounded-xl p-3 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Variables
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAddVar((v) => !v)}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Add variable"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{Object.keys(variables).length === 0 && !showAddVar && (
|
||||
<p className="text-xs text-muted-foreground/40 italic">
|
||||
Define variables like <span className="font-mono not-italic">x = 5</span> by evaluating assignments
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{Object.entries(variables).map(([name, val]) => (
|
||||
<div key={name} className="flex items-center gap-2 group">
|
||||
<span
|
||||
className="font-mono text-sm text-primary cursor-pointer hover:underline"
|
||||
onClick={() => insertAtCursor(name)}
|
||||
title="Insert into expression"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-xs">=</span>
|
||||
<span className="font-mono text-sm text-foreground/80 flex-1 truncate">{val}</span>
|
||||
<button
|
||||
onClick={() => removeVariable(name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground/50 hover:text-destructive transition-all"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAddVar && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<input
|
||||
value={newVarName}
|
||||
onChange={(e) => setNewVarName(e.target.value)}
|
||||
placeholder="name"
|
||||
className="w-16 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
<span className="text-muted-foreground/50 text-xs">=</span>
|
||||
<input
|
||||
value={newVarValue}
|
||||
onChange={(e) => setNewVarValue(e.target.value)}
|
||||
placeholder="value"
|
||||
onKeyDown={(e) => e.key === 'Enter' && addVar()}
|
||||
className="flex-1 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={addVar}
|
||||
className="text-primary hover:text-primary/70 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddVar(false)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── History ───────────────────────────────────────────── */}
|
||||
<div className="glass rounded-xl p-3 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-2 shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
History
|
||||
</span>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
title="Clear history"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/40 italic">No calculations yet</p>
|
||||
) : (
|
||||
<div className="overflow-y-auto flex-1 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
||||
{history.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => setExpression(entry.expression)}
|
||||
className="w-full text-left px-2 py-2 rounded-lg hover:bg-primary/8 group transition-colors"
|
||||
>
|
||||
<div className="font-mono text-[11px] text-muted-foreground/70 truncate group-hover:text-muted-foreground transition-colors">
|
||||
{entry.expression}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'font-mono text-sm font-medium mt-0.5',
|
||||
entry.error ? 'text-destructive/80' : 'text-foreground/90'
|
||||
)}>
|
||||
= {entry.result}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user