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,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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &nbsp;·&nbsp; Scroll to zoom &nbsp;·&nbsp; Hover for coordinates
</p>
</div>
);
}