'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; } 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(null); const viewRef = useRef(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): [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) => { dragRef.current = { startX: e.clientX, startY: e.clientY, startView: { ...viewRef.current } }; }, []); const handleMouseMove = useCallback((e: React.MouseEvent) => { 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 (
); }