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:
16
app/(app)/calculate/page.tsx
Normal file
16
app/(app)/calculate/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Calculator from '@/components/calculate/Calculator';
|
||||||
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/calculate')!;
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tool.title, description: tool.summary };
|
||||||
|
|
||||||
|
export default function CalculatePage() {
|
||||||
|
return (
|
||||||
|
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}>
|
||||||
|
<Calculator />
|
||||||
|
</AppPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,3 +66,15 @@ export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|||||||
<line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} />
|
<line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{/* Y-axis */}
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 20V4" />
|
||||||
|
{/* X-axis */}
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 20h16" />
|
||||||
|
{/* Smooth curve resembling sin/cos */}
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M4 14c1.5-3 3-7 5-5s2 8 4 6 3-6 5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
62
components/calculate/Calculator.tsx
Normal file
62
components/calculate/Calculator.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ExpressionPanel } from './ExpressionPanel';
|
||||||
|
import { GraphPanel } from './GraphPanel';
|
||||||
|
|
||||||
|
type Tab = 'calc' | 'graph';
|
||||||
|
|
||||||
|
export default function Calculator() {
|
||||||
|
const [tab, setTab] = useState<Tab>('calc');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Mobile tab switcher — hidden on lg+ */}
|
||||||
|
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||||
|
{(['calc', 'graph'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||||
|
tab === t
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === 'calc' ? 'Calculator' : 'Graph'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main layout — side-by-side on lg, tabbed on mobile */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||||
|
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||||
|
>
|
||||||
|
{/* Expression panel */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'lg:col-span-2 overflow-hidden flex flex-col',
|
||||||
|
tab !== 'calc' && 'hidden lg:flex'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExpressionPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graph panel */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'lg:col-span-3 overflow-hidden flex flex-col',
|
||||||
|
tab !== 'graph' && 'hidden lg:flex'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GraphPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
components/calculate/GraphCanvas.tsx
Normal file
370
components/calculate/GraphCanvas.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import type { GraphFunction } from '@/lib/calculate/store';
|
||||||
|
import { sampleFunction, evaluateAt } from '@/lib/calculate/math-engine';
|
||||||
|
|
||||||
|
interface ViewState {
|
||||||
|
xMin: number;
|
||||||
|
xMax: number;
|
||||||
|
yMin: number;
|
||||||
|
yMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
functions: GraphFunction[];
|
||||||
|
variables: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VIEW: ViewState = { xMin: -10, xMax: 10, yMin: -6, yMax: 6 };
|
||||||
|
|
||||||
|
function niceStep(range: number): number {
|
||||||
|
if (range <= 0) return 1;
|
||||||
|
const rawStep = range / 8;
|
||||||
|
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||||
|
const n = rawStep / mag;
|
||||||
|
const nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
|
||||||
|
return nice * mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLabel(v: number): string {
|
||||||
|
if (Math.abs(v) < 1e-10) return '0';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 1e5 || (abs < 0.01 && abs > 0)) return v.toExponential(1);
|
||||||
|
return parseFloat(v.toPrecision(4)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GraphCanvas({ functions, variables }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const viewRef = useRef<ViewState>(DEFAULT_VIEW);
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
const redraw = useCallback(() => tick((n) => n + 1), []);
|
||||||
|
|
||||||
|
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const cursorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const functionsRef = useRef(functions);
|
||||||
|
const variablesRef = useRef(variables);
|
||||||
|
const dragRef = useRef<{ startX: number; startY: number; startView: ViewState } | null>(null);
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => { functionsRef.current = functions; }, [functions]);
|
||||||
|
useEffect(() => { variablesRef.current = variables; }, [variables]);
|
||||||
|
useEffect(() => { cursorRef.current = cursor; }, [cursor]);
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const W = rect.width;
|
||||||
|
const H = rect.height;
|
||||||
|
if (!W || !H) return;
|
||||||
|
|
||||||
|
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
|
||||||
|
canvas.width = Math.round(W * dpr);
|
||||||
|
canvas.height = Math.round(H * dpr);
|
||||||
|
}
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const v = viewRef.current;
|
||||||
|
const xRange = v.xMax - v.xMin;
|
||||||
|
const yRange = v.yMax - v.yMin;
|
||||||
|
const fns = functionsRef.current;
|
||||||
|
const vars = variablesRef.current;
|
||||||
|
const cur = cursorRef.current;
|
||||||
|
|
||||||
|
const toP = (mx: number, my: number): [number, number] => [
|
||||||
|
(mx - v.xMin) / xRange * W,
|
||||||
|
H - (my - v.yMin) / yRange * H,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Background ──────────────────────────────────────────────
|
||||||
|
ctx.fillStyle = '#08080f';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const radGrad = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, Math.max(W, H) * 0.7);
|
||||||
|
radGrad.addColorStop(0, 'rgba(139, 92, 246, 0.05)');
|
||||||
|
radGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||||
|
ctx.fillStyle = radGrad;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// ── Grid ─────────────────────────────────────────────────────
|
||||||
|
const xStep = niceStep(xRange);
|
||||||
|
const yStep = niceStep(yRange);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = Math.ceil(v.xMin / xStep) * xStep; x <= v.xMax + xStep * 0.01; x += xStep) {
|
||||||
|
const [px] = toP(x, 0);
|
||||||
|
ctx.strokeStyle =
|
||||||
|
Math.abs(x) < xStep * 0.01 ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.055)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = Math.ceil(v.yMin / yStep) * yStep; y <= v.yMax + yStep * 0.01; y += yStep) {
|
||||||
|
const [, py] = toP(0, y);
|
||||||
|
ctx.strokeStyle =
|
||||||
|
Math.abs(y) < yStep * 0.01 ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.055)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Axes ──────────────────────────────────────────────────────
|
||||||
|
const [ax, ay] = toP(0, 0);
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
||||||
|
if (ay >= 0 && ay <= H) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
|
||||||
|
}
|
||||||
|
if (ax >= 0 && ax <= W) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(ax, 0); ctx.lineTo(ax, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Axis arrow tips ───────────────────────────────────────────
|
||||||
|
const arrowSize = 5;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||||||
|
if (ax >= 0 && ax <= W) {
|
||||||
|
// Y-axis arrow (pointing up)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax, 4);
|
||||||
|
ctx.lineTo(ax - arrowSize, 4 + arrowSize * 1.8);
|
||||||
|
ctx.lineTo(ax + arrowSize, 4 + arrowSize * 1.8);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
|
if (ay >= 0 && ay <= H) {
|
||||||
|
// X-axis arrow (pointing right)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(W - 4, ay);
|
||||||
|
ctx.lineTo(W - 4 - arrowSize * 1.8, ay - arrowSize);
|
||||||
|
ctx.lineTo(W - 4 - arrowSize * 1.8, ay + arrowSize);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Axis labels ───────────────────────────────────────────────
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillStyle = 'rgba(161,161,170,0.6)';
|
||||||
|
const labelAY = Math.min(Math.max(ay + 5, 2), H - 14);
|
||||||
|
const labelAX = Math.min(Math.max(ax + 5, 2), W - 46);
|
||||||
|
|
||||||
|
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||||
|
for (let x = Math.ceil(v.xMin / xStep) * xStep; x <= v.xMax; x += xStep) {
|
||||||
|
if (Math.abs(x) < xStep * 0.01) continue;
|
||||||
|
const [px] = toP(x, 0);
|
||||||
|
if (px < 8 || px > W - 8) continue;
|
||||||
|
ctx.fillText(fmtLabel(x), px, labelAY);
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||||
|
for (let y = Math.ceil(v.yMin / yStep) * yStep; y <= v.yMax; y += yStep) {
|
||||||
|
if (Math.abs(y) < yStep * 0.01) continue;
|
||||||
|
const [, py] = toP(0, y);
|
||||||
|
if (py < 8 || py > H - 8) continue;
|
||||||
|
ctx.fillText(fmtLabel(y), labelAX, py);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ax >= 0 && ax <= W && ay >= 0 && ay <= H) {
|
||||||
|
ctx.fillStyle = 'rgba(161,161,170,0.35)';
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText('0', labelAX, labelAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Function curves ───────────────────────────────────────────
|
||||||
|
const numPts = Math.round(W * 1.5);
|
||||||
|
|
||||||
|
for (const fn of fns) {
|
||||||
|
if (!fn.visible || !fn.expression.trim()) continue;
|
||||||
|
const pts = sampleFunction(fn.expression, v.xMin, v.xMax, numPts, vars);
|
||||||
|
|
||||||
|
// Three render passes: wide glow → medium glow → crisp line
|
||||||
|
const passes = [
|
||||||
|
{ alpha: 0.08, width: 10 },
|
||||||
|
{ alpha: 0.28, width: 3.5 },
|
||||||
|
{ alpha: 1.0, width: 1.8 },
|
||||||
|
];
|
||||||
|
for (const { alpha, width } of passes) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = fn.color;
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
let penDown = false;
|
||||||
|
for (const pt of pts) {
|
||||||
|
if (pt === null) {
|
||||||
|
if (penDown) { ctx.stroke(); ctx.beginPath(); }
|
||||||
|
penDown = false;
|
||||||
|
} else {
|
||||||
|
const [px, py] = toP(pt.x, pt.y);
|
||||||
|
if (!penDown) { ctx.moveTo(px, py); penDown = true; }
|
||||||
|
else ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (penDown) ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cursor crosshair + tooltip ────────────────────────────────
|
||||||
|
if (cur) {
|
||||||
|
const [cx, cy] = toP(cur.x, cur.y);
|
||||||
|
|
||||||
|
ctx.setLineDash([3, 5]);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, H); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(W, cy); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Crosshair dot
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
|
||||||
|
|
||||||
|
// Function values at cursor x
|
||||||
|
type FnVal = { color: string; y: number; label: string };
|
||||||
|
const fnVals: FnVal[] = fns
|
||||||
|
.filter((f) => f.visible && f.expression.trim())
|
||||||
|
.map((f, i) => {
|
||||||
|
const y = evaluateAt(f.expression, cur.x, vars);
|
||||||
|
return isNaN(y) ? null : { color: f.color, y, label: `f${i + 1}(x)` };
|
||||||
|
})
|
||||||
|
.filter((v): v is FnVal => v !== null);
|
||||||
|
|
||||||
|
const coordLine = `x = ${cur.x.toFixed(3)} y = ${cur.y.toFixed(3)}`;
|
||||||
|
const lines: { text: string; color: string }[] = [
|
||||||
|
{ text: coordLine, color: 'rgba(200,200,215,0.85)' },
|
||||||
|
...fnVals.map((f) => ({
|
||||||
|
text: `${f.label} = ${f.y.toFixed(4)}`,
|
||||||
|
color: f.color,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const lh = 15;
|
||||||
|
const pad = 9;
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
const maxW = Math.max(...lines.map((l) => ctx.measureText(l.text).width));
|
||||||
|
const bw = maxW + pad * 2;
|
||||||
|
const bh = lines.length * lh + pad * 2;
|
||||||
|
|
||||||
|
let bx = cx + 14;
|
||||||
|
let by = cy - bh / 2;
|
||||||
|
if (bx + bw > W - 4) bx = cx - bw - 14;
|
||||||
|
if (by < 4) by = 4;
|
||||||
|
if (by + bh > H - 4) by = H - bh - 4;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(6, 6, 16, 0.92)';
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(bx, by, bw, bh, 5);
|
||||||
|
ctx.fill(); ctx.stroke();
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
ctx.fillStyle = line.color;
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(line.text, bx + pad, by + pad + i * lh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleDraw = useCallback(() => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
// Resize observer
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const obs = new ResizeObserver(scheduleDraw);
|
||||||
|
obs.observe(canvas);
|
||||||
|
scheduleDraw();
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [scheduleDraw]);
|
||||||
|
|
||||||
|
// Redraw whenever reactive state changes
|
||||||
|
useEffect(() => { scheduleDraw(); }, [functions, variables, cursor, tick, scheduleDraw]);
|
||||||
|
|
||||||
|
// Convert mouse event to math coords
|
||||||
|
const toMath = useCallback((e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const rect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - rect.left) / rect.width;
|
||||||
|
const py = (e.clientY - rect.top) / rect.height;
|
||||||
|
const v = viewRef.current;
|
||||||
|
return [v.xMin + px * (v.xMax - v.xMin), v.yMax - py * (v.yMax - v.yMin)];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
dragRef.current = { startX: e.clientX, startY: e.clientY, startView: { ...viewRef.current } };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const [mx, my] = toMath(e);
|
||||||
|
setCursor({ x: mx, y: my });
|
||||||
|
if (dragRef.current) {
|
||||||
|
const { startX, startY, startView: sv } = dragRef.current;
|
||||||
|
const rect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
const dx = (e.clientX - startX) / rect.width * (sv.xMax - sv.xMin);
|
||||||
|
const dy = (e.clientY - startY) / rect.height * (sv.yMax - sv.yMin);
|
||||||
|
viewRef.current = {
|
||||||
|
xMin: sv.xMin - dx, xMax: sv.xMax - dx,
|
||||||
|
yMin: sv.yMin + dy, yMax: sv.yMax + dy,
|
||||||
|
};
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
}, [toMath, redraw]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => { dragRef.current = null; }, []);
|
||||||
|
const handleMouseLeave = useCallback(() => { dragRef.current = null; setCursor(null); }, []);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - rect.left) / rect.width;
|
||||||
|
const py = (e.clientY - rect.top) / rect.height;
|
||||||
|
const v = viewRef.current;
|
||||||
|
const mx = v.xMin + px * (v.xMax - v.xMin);
|
||||||
|
const my = v.yMax - py * (v.yMax - v.yMin);
|
||||||
|
const factor = e.deltaY > 0 ? 1.12 : 1 / 1.12;
|
||||||
|
viewRef.current = {
|
||||||
|
xMin: mx - (mx - v.xMin) * factor,
|
||||||
|
xMax: mx + (v.xMax - mx) * factor,
|
||||||
|
yMin: my - (my - v.yMin) * factor,
|
||||||
|
yMax: my + (v.yMax - my) * factor,
|
||||||
|
};
|
||||||
|
redraw();
|
||||||
|
scheduleDraw();
|
||||||
|
}, [redraw, scheduleDraw]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => canvas.removeEventListener('wheel', handleWheel);
|
||||||
|
}, [handleWheel]);
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
viewRef.current = DEFAULT_VIEW;
|
||||||
|
redraw();
|
||||||
|
scheduleDraw();
|
||||||
|
}, [redraw, scheduleDraw]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full group">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ display: 'block', cursor: dragRef.current ? 'grabbing' : 'crosshair' }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={resetView}
|
||||||
|
className="absolute bottom-3 right-3 px-2.5 py-1 text-xs font-mono text-muted-foreground glass rounded-md opacity-0 group-hover:opacity-100 hover:text-foreground transition-all duration-200"
|
||||||
|
>
|
||||||
|
reset view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
components/calculate/GraphPanel.tsx
Normal file
117
components/calculate/GraphPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Plus, Eye, EyeOff, Trash2 } from 'lucide-react';
|
||||||
|
import { useCalculateStore } from '@/lib/calculate/store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import GraphCanvas from './GraphCanvas';
|
||||||
|
|
||||||
|
export function GraphPanel() {
|
||||||
|
const {
|
||||||
|
graphFunctions,
|
||||||
|
variables,
|
||||||
|
addGraphFunction,
|
||||||
|
updateGraphFunction,
|
||||||
|
removeGraphFunction,
|
||||||
|
} = useCalculateStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||||
|
|
||||||
|
{/* ── Function list ────────────────────────────────────── */}
|
||||||
|
<div className="glass rounded-xl p-3 shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||||
|
Functions <span className="text-muted-foreground/40 normal-case font-normal">— use x as variable</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={addGraphFunction}
|
||||||
|
disabled={graphFunctions.length >= 8}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{graphFunctions.map((fn, i) => (
|
||||||
|
<div key={fn.id} className="flex items-center gap-2">
|
||||||
|
|
||||||
|
{/* Color swatch / color picker */}
|
||||||
|
<div className="relative shrink-0 w-4 h-4">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full ring-1 ring-white/15 cursor-pointer"
|
||||||
|
style={{ background: fn.color }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={fn.color}
|
||||||
|
onChange={(e) => updateGraphFunction(fn.id, { color: e.target.value })}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||||
|
title="Change color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Index label */}
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono shrink-0 w-6"
|
||||||
|
style={{ color: fn.visible ? fn.color : 'rgba(161,161,170,0.4)' }}
|
||||||
|
>
|
||||||
|
f{i + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Expression input */}
|
||||||
|
<input
|
||||||
|
value={fn.expression}
|
||||||
|
onChange={(e) => updateGraphFunction(fn.id, { expression: e.target.value })}
|
||||||
|
placeholder={i === 0 ? 'sin(x)' : i === 1 ? 'x^2 / 4' : 'f(x)…'}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-0 bg-transparent border border-border/35 rounded px-2 py-1',
|
||||||
|
'text-sm font-mono outline-none transition-colors',
|
||||||
|
'placeholder:text-muted-foreground/30',
|
||||||
|
'focus:border-primary/50',
|
||||||
|
!fn.visible && 'opacity-40'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visibility toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => updateGraphFunction(fn.id, { visible: !fn.visible })}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 transition-colors',
|
||||||
|
fn.visible
|
||||||
|
? 'text-muted-foreground hover:text-foreground'
|
||||||
|
: 'text-muted-foreground/25 hover:text-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={fn.visible ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{fn.visible ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
{graphFunctions.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeGraphFunction(fn.id)}
|
||||||
|
className="shrink-0 text-muted-foreground/30 hover:text-destructive transition-colors"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Canvas ───────────────────────────────────────────── */}
|
||||||
|
<div className="glass rounded-xl overflow-hidden flex-1 min-h-0">
|
||||||
|
<GraphCanvas functions={graphFunctions} variables={variables} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Hint bar ─────────────────────────────────────────── */}
|
||||||
|
<p className="text-[10px] text-muted-foreground/35 text-center shrink-0 pb-1">
|
||||||
|
Drag to pan · Scroll to zoom · Hover for coordinates
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon } from '@/components/AppIcons';
|
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon, CalculateIcon } from '@/components/AppIcons';
|
||||||
|
|
||||||
export interface Tool {
|
export interface Tool {
|
||||||
/** Short display name (e.g. "Color") */
|
/** Short display name (e.g. "Color") */
|
||||||
@@ -97,6 +97,17 @@ export const tools: Tool[] = [
|
|||||||
icon: AnimateIcon,
|
icon: AnimateIcon,
|
||||||
badges: ['CSS', 'Tailwind v4', '20+ Presets'],
|
badges: ['CSS', 'Tailwind v4', '20+ Presets'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
shortTitle: 'Calculate',
|
||||||
|
title: 'Calculator & Grapher',
|
||||||
|
navTitle: 'Calculator',
|
||||||
|
href: '/calculate',
|
||||||
|
description: 'Advanced expression evaluator with interactive function graphing.',
|
||||||
|
summary:
|
||||||
|
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot multiple functions simultaneously on an interactive graph with pan and zoom.',
|
||||||
|
icon: CalculateIcon,
|
||||||
|
badges: ['Math.js', 'Graphing', 'Interactive'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Look up a tool by its href path */
|
/** Look up a tool by its href path */
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
"mathjs": "^15.1.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.575.0
|
specifier: ^0.575.0
|
||||||
version: 0.575.0(react@19.2.4)
|
version: 0.575.0(react@19.2.4)
|
||||||
|
mathjs:
|
||||||
|
specifier: ^15.1.1
|
||||||
|
version: 15.1.1
|
||||||
next:
|
next:
|
||||||
specifier: ^16.1.6
|
specifier: ^16.1.6
|
||||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -257,6 +260,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.6':
|
||||||
|
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1938,6 +1945,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
complex.js@2.4.3:
|
||||||
|
resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
@@ -2035,6 +2045,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
dedent@1.7.1:
|
dedent@1.7.1:
|
||||||
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
|
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2173,6 +2186,9 @@ packages:
|
|||||||
escape-html@1.0.3:
|
escape-html@1.0.3:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
|
escape-latex@1.2.0:
|
||||||
|
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0:
|
escape-string-regexp@4.0.0:
|
||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2416,6 +2432,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
fraction.js@5.3.4:
|
||||||
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
framer-motion@12.34.3:
|
framer-motion@12.34.3:
|
||||||
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
|
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2806,6 +2825,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
javascript-natural-sort@0.7.1:
|
||||||
|
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3051,6 +3073,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
mathjs@15.1.1:
|
||||||
|
resolution: {integrity: sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
media-typer@1.1.0:
|
media-typer@1.1.0:
|
||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3558,6 +3585,9 @@ packages:
|
|||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
seedrandom@3.0.5:
|
||||||
|
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
|
||||||
|
|
||||||
semver@6.3.1:
|
semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3773,6 +3803,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tiny-emitter@2.1.0:
|
||||||
|
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@@ -3853,6 +3886,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
typed-function@4.2.2:
|
||||||
|
resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -4218,6 +4255,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -5911,6 +5950,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
||||||
|
complex.js@2.4.3: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
content-disposition@1.0.1: {}
|
content-disposition@1.0.1: {}
|
||||||
@@ -5988,6 +6029,8 @@ snapshots:
|
|||||||
|
|
||||||
decamelize@1.2.0: {}
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
dedent@1.7.1: {}
|
dedent@1.7.1: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
@@ -6172,6 +6215,8 @@ snapshots:
|
|||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
|
escape-latex@1.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-config-next@15.1.7(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
|
eslint-config-next@15.1.7(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
|
||||||
@@ -6538,6 +6583,8 @@ snapshots:
|
|||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-dom: 12.34.3
|
motion-dom: 12.34.3
|
||||||
@@ -6883,6 +6930,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
javascript-natural-sort@0.7.1: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
jose@6.1.3: {}
|
jose@6.1.3: {}
|
||||||
@@ -7124,6 +7173,18 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
mathjs@15.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
complex.js: 2.4.3
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
escape-latex: 1.2.0
|
||||||
|
fraction.js: 5.3.4
|
||||||
|
javascript-natural-sort: 0.7.1
|
||||||
|
seedrandom: 3.0.5
|
||||||
|
tiny-emitter: 2.1.0
|
||||||
|
typed-function: 4.2.2
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
merge-descriptors@2.0.0: {}
|
merge-descriptors@2.0.0: {}
|
||||||
@@ -7704,6 +7765,8 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
seedrandom@3.0.5: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
@@ -8015,6 +8078,8 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
tiny-emitter@2.1.0: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyexec@1.0.2: {}
|
tinyexec@1.0.2: {}
|
||||||
@@ -8113,6 +8178,8 @@ snapshots:
|
|||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
reflect.getprototypeof: 1.0.10
|
reflect.getprototypeof: 1.0.10
|
||||||
|
|
||||||
|
typed-function@4.2.2: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user