export type VisualizerColors = { text: string; operator: string; carry: string; borrow: string; result: string; line: string; highlight: string; highlightBorder: string; error: string; }; type CellData = { text?: string; color?: string; crossedOut?: boolean; smallText?: string; smallColor?: string; }; type Frame = { cells: Record; lines: Array<{ y: number; startX: number; endX: number }>; highlights: Array<{ x: number; y: number; w: number; h: number }>; message: string; }; export class MathVisualizer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private wrapper: HTMLElement; private themeRoot: HTMLElement; frames: Frame[] = []; currentFrame = 0; isPlaying = false; private playInterval: ReturnType | null = null; speed = 800; operation: "+" | "-" | "*" | "/" = "+"; private colors: VisualizerColors = { text: "#1e293b", operator: "#64748b", carry: "#ef4444", borrow: "#9a6b2e", result: "#16a34a", line: "#cbd5e1", highlight: "rgba(59, 130, 246, 0.15)", highlightBorder: "rgba(59, 130, 246, 0.4)", error: "#ef4444", }; onMessage?: (msg: string, isError?: boolean) => void; onStep?: (current: number, total: number) => void; onPlayState?: (playing: boolean) => void; constructor(opts: { canvas: HTMLCanvasElement; wrapper: HTMLElement; themeRoot: HTMLElement; }) { this.canvas = opts.canvas; const ctx = opts.canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D not available"); this.ctx = ctx; this.wrapper = opts.wrapper; this.themeRoot = opts.themeRoot; this.refreshColors(); } refreshColors() { const s = getComputedStyle(this.themeRoot); const get = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback; this.colors = { text: get("--va-text", "#1a1a1a"), operator: get("--va-operator", "#767676"), carry: get("--va-carry", "#dc2626"), borrow: get("--va-borrow", "#9a6b2e"), result: get("--va-result", "#2a7d52"), line: get("--va-line", "#e8e8e8"), highlight: get("--va-highlight", "rgba(154, 107, 46, 0.15)"), highlightBorder: get("--va-highlight-border", "rgba(154, 107, 46, 0.4)"), error: get("--va-error", "#dc2626"), }; } resizeCanvas() { const rect = this.wrapper.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this.canvas.width = rect.width * dpr; this.canvas.height = rect.height * dpr; this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(dpr, dpr); this.canvas.style.width = `${rect.width}px`; this.canvas.style.height = `${rect.height}px`; this.render(); } setSpeed(ms: number) { this.speed = ms; if (this.isPlaying) { this.pause(); this.play(); } } setOperation(op: "+" | "-" | "*" | "/") { this.operation = op; } generateFromInput(raw: string) { const lines = raw .split("\n") .map((s) => s.trim()) .filter((s) => /^\d+$/.test(s)); if (lines.length < 2) { this.showMessage("Please enter at least two numbers.", true); return; } this.frames = []; this.currentFrame = 0; this.pause(); try { if (this.operation === "+") { this.generateAddition(lines); } else { let current = lines[0]; const finalStepFrames: Frame[] = []; for (let i = 1; i < lines.length; i++) { const next = lines[i]; if (i > 1) { const transFrame = this.createEmptyFrame(); transFrame.message = `Intermediate result: ${current}. Preparing next operation...`; for (let k = 0; k < current.length; k++) { this.setCell(transFrame, k, 1, { text: current[k], color: this.colors.result, }); } this.frames.push(transFrame); } if (this.operation === "-") { this.generateSubtraction(current, next); const diff = BigInt(current) - BigInt(next); if (diff < 0n) { throw new Error( "Result dropped below zero. Negative visualization not supported." ); } current = diff.toString(); } else if (this.operation === "*") { this.generateMultiplication(current, next); current = (BigInt(current) * BigInt(next)).toString(); } else if (this.operation === "/") { if (BigInt(next) === 0n) throw new Error("Cannot divide by zero."); this.generateDivision(current, next); current = (BigInt(current) / BigInt(next)).toString(); } finalStepFrames.push(this.cloneGrid(this.frames[this.frames.length - 1])); } if (finalStepFrames.length > 1) { this.frames.push(this.mergeFramesToSummary(finalStepFrames)); } } if (this.frames.length > 0) { this.render(); this.play(); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.showMessage(`Error: ${msg}`, true); } } private showMessage(msg: string, isError = false) { this.onMessage?.(msg, isError); } private cloneGrid(grid: Frame): Frame { return JSON.parse(JSON.stringify(grid)) as Frame; } private createEmptyFrame(): Frame { return { cells: {}, lines: [], highlights: [], message: "" }; } private setCell(frame: Frame, x: number, y: number, data: CellData) { frame.cells[`${x},${y}`] = { ...frame.cells[`${x},${y}`], ...data }; } private getCell(frame: Frame, x: number, y: number): CellData | null { return frame.cells[`${x},${y}`] ?? null; } private mergeFramesToSummary(finalFrames: Frame[]): Frame { const summaryFrame = this.createEmptyFrame(); summaryFrame.message = "Final Summary of All Steps"; let currentOffsetX = 0; for (let i = 0; i < finalFrames.length; i++) { const f = finalFrames[i]; let minX = Infinity; let maxX = -Infinity; let hasCells = false; Object.keys(f.cells).forEach((key) => { const [x] = key.split(",").map(Number); minX = Math.min(minX, x); maxX = Math.max(maxX, x); hasCells = true; }); if (!hasCells) continue; const shiftX = currentOffsetX - minX; Object.entries(f.cells).forEach(([key, cell]) => { const [x, y] = key.split(",").map(Number); summaryFrame.cells[`${x + shiftX},${y}`] = { ...cell }; }); f.lines.forEach((line) => { summaryFrame.lines.push({ y: line.y, startX: line.startX + shiftX, endX: line.endX + shiftX, }); }); if (i > 0) { summaryFrame.cells[`${currentOffsetX - 2},2`] = { text: "→", color: this.colors.operator, }; } currentOffsetX += maxX - minX + 1 + 3; } return summaryFrame; } private generateAddition(numbers: string[]) { const maxLen = Math.max(...numbers.map((s) => s.length)); const padded = numbers.map((s) => s.padStart(maxLen, "0")); const frame = this.createEmptyFrame(); frame.message = `Setting up addition for ${numbers.join(" + ")}`; const numCount = numbers.length; const width = maxLen + 2; this.setCell(frame, 0, numCount, { text: "+", color: this.colors.operator }); for (let r = 0; r < numCount; r++) { const pStr = padded[r]; for (let i = 0; i < maxLen; i++) { if (i >= maxLen - numbers[r].length) { this.setCell(frame, i + 2, r + 1, { text: pStr[i], color: this.colors.text }); } } } const lineY = numCount + 1; frame.lines.push({ y: lineY, startX: 0, endX: width }); this.frames.push(this.cloneGrid(frame)); let carry = 0; for (let i = maxLen - 1; i >= 0; i--) { let sum = carry; const columnDigits: number[] = []; for (let r = 0; r < numCount; r++) { const d = parseInt(padded[r][i], 10); sum += d; columnDigits.push(d); } const nextCarry = Math.floor(sum / 10); const digit = sum % 10; const col = i + 2; const stepFrame = this.cloneGrid(this.frames[this.frames.length - 1]); stepFrame.highlights.push({ x: col, y: carry > 0 ? 0 : 1, w: 1, h: carry > 0 ? numCount + 1 : numCount, }); let msg = `Add column: ${columnDigits.join(" + ")}`; if (carry > 0) msg += ` + ${carry} (carry) = ${sum}.`; else msg += ` = ${sum}.`; if (nextCarry > 0) msg += ` Write ${digit}, carry ${nextCarry}.`; else msg += ` Write ${digit}.`; stepFrame.message = msg; this.frames.push(stepFrame); const resultFrame = this.cloneGrid(this.frames[this.frames.length - 1]); resultFrame.highlights = []; this.setCell(resultFrame, col, lineY + 1, { text: digit.toString(), color: this.colors.result, }); if (nextCarry > 0) { this.setCell(resultFrame, col - 1, 0, { text: nextCarry.toString(), color: this.colors.carry, }); } resultFrame.message = msg; this.frames.push(resultFrame); carry = nextCarry; } if (carry > 0) { const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]); const carryStr = carry.toString(); for (let c = 0; c < carryStr.length; c++) { this.setCell(finalFrame, 1 - (carryStr.length - 1 - c), lineY + 1, { text: carryStr[c], color: this.colors.result, }); } finalFrame.message = "Write final carry down."; this.frames.push(finalFrame); } } private generateSubtraction(s1: string, s2: string) { const n1 = BigInt(s1); const n2 = BigInt(s2); if (n1 < n2) { throw new Error( "First number must be greater than or equal to second number for visualizer." ); } const maxLen = Math.max(s1.length, s2.length); const p1 = s1.padStart(maxLen, "0").split("").map(Number); const p2 = s2.padStart(maxLen, "0").split("").map(Number); const frame = this.createEmptyFrame(); frame.message = `Setting up subtraction for ${s1} - ${s2}`; const width = maxLen + 2; this.setCell(frame, 0, 2, { text: "−", color: this.colors.operator }); for (let i = 0; i < maxLen; i++) { if (i >= maxLen - s1.length) { this.setCell(frame, i + 2, 1, { text: p1[i].toString(), color: this.colors.text }); } if (i >= maxLen - s2.length) { this.setCell(frame, i + 2, 2, { text: p2[i].toString(), color: this.colors.text }); } } frame.lines.push({ y: 3, startX: 0, endX: width }); this.frames.push(this.cloneGrid(frame)); for (let i = maxLen - 1; i >= 0; i--) { let d1 = p1[i]; const d2 = p2[i]; const col = i + 2; if (d1 < d2) { let borrowIdx = i - 1; while (borrowIdx >= 0 && p1[borrowIdx] === 0) borrowIdx--; for (let j = borrowIdx; j < i; j++) { const bFrame = this.cloneGrid(this.frames[this.frames.length - 1]); const cellToCross = this.getCell(bFrame, j + 2, 1); this.setCell(bFrame, j + 2, 1, { crossedOut: true }); const newVal = p1[j] === 0 ? 9 : p1[j] - 1; p1[j] = newVal; this.setCell(bFrame, j + 2, 0, { text: newVal.toString(), color: this.colors.borrow, }); if (j < i - 1) { bFrame.message = "Borrowing across zero. Cross out 0, make it 9."; } else { bFrame.message = `Borrow 1 from ${cellToCross?.text ?? ""}, leaving ${newVal}.`; } bFrame.highlights = [{ x: j + 2, y: 0, w: 1, h: 2 }]; this.frames.push(bFrame); } p1[i] += 10; d1 = p1[i]; const addFrame = this.cloneGrid(this.frames[this.frames.length - 1]); this.setCell(addFrame, col, 1, { crossedOut: true }); this.setCell(addFrame, col, 0, { text: d1.toString(), color: this.colors.borrow }); addFrame.message = `Add 10 to current column, making it ${d1}.`; addFrame.highlights = [{ x: col, y: 0, w: 1, h: 2 }]; this.frames.push(addFrame); } const subFrame = this.cloneGrid(this.frames[this.frames.length - 1]); const origDigit = parseInt(s1.padStart(maxLen, "0")[i], 10); subFrame.highlights = [ { x: col, y: p1[i] > origDigit ? 0 : 1, w: 1, h: p1[i] > origDigit ? 3 : 2, }, ]; subFrame.message = `Subtract: ${d1} - ${d2} = ${d1 - d2}.`; this.frames.push(subFrame); const resultFrame = this.cloneGrid(this.frames[this.frames.length - 1]); resultFrame.highlights = []; const res = d1 - d2; const currentP1 = BigInt(p1.slice(0, i + 1).join("")); const currentP2 = BigInt(p2.slice(0, i + 1).join("")); if (res > 0 || i === maxLen - 1 || currentP1 > currentP2) { this.setCell(resultFrame, col, 4, { text: res.toString(), color: this.colors.result, }); } resultFrame.message = `Write ${res}.`; this.frames.push(resultFrame); } } private generateMultiplication(s1: string, s2: string) { const topOffset = 2; const stackWidth = Math.max(s1.length, s2.length); const rightOffset = stackWidth; const opCol = rightOffset - s2.length; const width = rightOffset + 1; const frame = this.createEmptyFrame(); frame.message = `Setting up multiplication: ${s1} × ${s2}`; this.setCell(frame, opCol, topOffset + 1, { text: "×", color: this.colors.operator, }); for (let i = 0; i < s1.length; i++) { this.setCell(frame, rightOffset - s1.length + 1 + i, topOffset, { text: s1[i], color: this.colors.text, }); } for (let i = 0; i < s2.length; i++) { this.setCell(frame, rightOffset - s2.length + 1 + i, topOffset + 1, { text: s2[i], color: this.colors.text, }); } frame.lines.push({ y: topOffset + 2, startX: opCol, endX: rightOffset }); this.frames.push(this.cloneGrid(frame)); const partialProducts: string[] = []; let rowOffset = topOffset + 2; for (let j = s2.length - 1; j >= 0; j--) { const d2 = parseInt(s2[j], 10); let carry = 0; let partial = ""; const shift = s2.length - 1 - j; const startFrame = this.cloneGrid(this.frames[this.frames.length - 1]); Object.keys(startFrame.cells).forEach((k) => { if (k.endsWith(",0") || k.endsWith(",1")) delete startFrame.cells[k]; }); for (let i = s1.length - 1; i >= 0; i--) { const d1 = parseInt(s1[i], 10); const prod = d1 * d2 + carry; const nextCarry = Math.floor(prod / 10); const digit = prod % 10; const highlightCol = rightOffset - s1.length + 1 + i; const bottomCol = rightOffset - s2.length + 1 + j; const stepFrame = this.cloneGrid(this.frames[this.frames.length - 1]); stepFrame.highlights = [ { x: highlightCol, y: topOffset, w: 1, h: 1 }, { x: bottomCol, y: topOffset + 1, w: 1, h: 1 }, ]; let msg = `${d1} × ${d2}`; if (carry > 0) msg += ` + ${carry} (carry) = ${prod}.`; else msg += ` = ${prod}.`; if (nextCarry > 0) msg += ` Write ${digit}, carry ${nextCarry}.`; else msg += ` Write ${digit}.`; stepFrame.message = msg; if (carry > 0) { this.setCell(stepFrame, highlightCol, topOffset - 1, { text: carry.toString(), color: this.colors.carry, }); } this.frames.push(stepFrame); const writeFrame = this.cloneGrid(stepFrame); writeFrame.highlights = []; const writeCol = rightOffset - shift - (s1.length - 1 - i); this.setCell(writeFrame, writeCol, rowOffset, { text: digit.toString(), color: this.colors.result, }); if (nextCarry > 0) { this.setCell(writeFrame, highlightCol - 1, topOffset - 1, { text: nextCarry.toString(), color: this.colors.carry, }); } this.frames.push(writeFrame); carry = nextCarry; partial = digit.toString() + partial; } if (carry > 0) { const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]); const writeCol = rightOffset - shift - s1.length; this.setCell(finalFrame, writeCol, rowOffset, { text: carry.toString(), color: this.colors.result, }); finalFrame.message = "Write final carry down."; this.frames.push(finalFrame); partial = carry.toString() + partial; } if (shift > 0) { const padFrame = this.cloneGrid(this.frames[this.frames.length - 1]); for (let k = 0; k < shift; k++) { this.setCell(padFrame, rightOffset - k, rowOffset, { text: "0", color: this.colors.operator, }); } padFrame.message = "Add placeholder zero(s)."; this.frames.push(padFrame); partial = partial + "0".repeat(shift); } partialProducts.push(partial); rowOffset++; } if (partialProducts.length > 1) { const addSetupFrame = this.cloneGrid(this.frames[this.frames.length - 1]); addSetupFrame.lines.push({ y: rowOffset, startX: opCol, endX: rightOffset }); this.setCell(addSetupFrame, opCol, rowOffset - 1, { text: "+", color: this.colors.operator, }); addSetupFrame.message = "Add the partial products together."; Object.keys(addSetupFrame.cells).forEach((k) => { if (k.endsWith(`,${topOffset - 1}`)) delete addSetupFrame.cells[k]; }); this.frames.push(addSetupFrame); let carry = 0; const maxP = Math.max(...partialProducts.map((p) => p.length)); for (let i = 0; i < maxP; i++) { let sum = carry; for (let j = 0; j < partialProducts.length; j++) { const pStr = partialProducts[j]; if (i < pStr.length) sum += parseInt(pStr[pStr.length - 1 - i], 10); } const digit = sum % 10; const nextCarry = Math.floor(sum / 10); const col = rightOffset - i; const resFrame = this.cloneGrid(this.frames[this.frames.length - 1]); this.setCell(resFrame, col, rowOffset, { text: digit.toString(), color: this.colors.result, }); let msg = `Sum column = ${sum}. Write ${digit}.`; if (nextCarry > 0) { this.setCell(resFrame, col - 1, topOffset + 1, { text: nextCarry.toString(), color: this.colors.carry, }); msg += ` Carry ${nextCarry}.`; } resFrame.message = msg; this.frames.push(resFrame); carry = nextCarry; } if (carry > 0) { const finalFrame = this.cloneGrid(this.frames[this.frames.length - 1]); this.setCell(finalFrame, rightOffset - maxP, rowOffset, { text: carry.toString(), color: this.colors.result, }); finalFrame.message = "Write final addition carry."; this.frames.push(finalFrame); } } } private generateDivision(s1: string, s2: string) { if (s2.length > 15) throw new Error("Divisor too large for visualizer (max 15 digits)."); const div = parseInt(s2, 10); if (div === 0) throw new Error("Cannot divide by zero"); if (BigInt(s1) === 0n) throw new Error("0 divided by anything is 0"); const frame = this.createEmptyFrame(); const divLen = s2.length; const divOffset = 0; const divStart = divLen + 1; frame.message = `Setup long division: ${s1} ÷ ${s2}`; for (let i = 0; i < divLen; i++) { this.setCell(frame, divOffset + i, 2, { text: s2[i], color: this.colors.operator }); } this.setCell(frame, divLen, 2, { text: ")", color: this.colors.text }); for (let i = 0; i < s1.length; i++) { this.setCell(frame, divStart + i, 2, { text: s1[i], color: this.colors.text }); } frame.lines.push({ y: 1.5, startX: divStart - 0.2, endX: divStart + s1.length }); this.frames.push(this.cloneGrid(frame)); const dividendArr = s1.split(""); let currentVal = 0; let row = 3; let quotientStr = ""; for (let i = 0; i < dividendArr.length; i++) { const nextDigit = parseInt(dividendArr[i], 10); currentVal = currentVal * 10 + nextDigit; const bringFrame = this.cloneGrid(this.frames[this.frames.length - 1]); if (row > 3) { this.setCell(bringFrame, divStart + i, row - 1, { text: nextDigit.toString(), color: this.colors.text, }); bringFrame.message = `Bring down ${nextDigit}. Current value is ${currentVal}.`; bringFrame.highlights = [{ x: divStart + i, y: 2, w: 1, h: row - 2 }]; } else { bringFrame.message = `Consider ${currentVal}.`; bringFrame.highlights = [{ x: divStart, y: 2, w: i + 1, h: 1 }]; } this.frames.push(bringFrame); if (currentVal >= div || i === dividendArr.length - 1 || quotientStr.length > 0) { const qDigit = Math.floor(currentVal / div); quotientStr += qDigit.toString(); const qFrame = this.cloneGrid(this.frames[this.frames.length - 1]); qFrame.highlights = []; this.setCell(qFrame, divStart + i, 0, { text: qDigit.toString(), color: this.colors.result, }); qFrame.message = `${div} goes into ${currentVal} exactly ${qDigit} times.`; this.frames.push(qFrame); if (qDigit > 0 || i === dividendArr.length - 1) { const subVal = qDigit * div; const subStr = subVal.toString(); const subFrame = this.cloneGrid(this.frames[this.frames.length - 1]); this.setCell(subFrame, divStart + i - subStr.length, row, { text: "-", color: this.colors.operator, }); for (let k = 0; k < subStr.length; k++) { this.setCell(subFrame, divStart + i - subStr.length + 1 + k, row, { text: subStr[k], color: this.colors.borrow, }); } subFrame.lines.push({ y: row + 1, startX: divStart + i - subStr.length, endX: divStart + i + 1, }); subFrame.message = `Multiply ${qDigit} × ${div} = ${subVal}, then subtract.`; this.frames.push(subFrame); currentVal = currentVal - subVal; row += 2; const resFrame = this.cloneGrid(this.frames[this.frames.length - 1]); const currStr = currentVal.toString(); for (let k = 0; k < currStr.length; k++) { this.setCell(resFrame, divStart + i - currStr.length + 1 + k, row - 1, { text: currStr[k], color: this.colors.text, }); } resFrame.message = `Remainder is ${currentVal}.`; this.frames.push(resFrame); } } } } render() { this.refreshColors(); if (this.frames.length === 0) { const dpr = window.devicePixelRatio || 1; this.ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr); return; } const frame = this.frames[this.currentFrame]; const ctx = this.ctx; const dpr = window.devicePixelRatio || 1; ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr); let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; Object.keys(frame.cells).forEach((key) => { const [x, y] = key.split(",").map(Number); minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }); if (!Number.isFinite(minX)) { this.updateUI(); return; } minX -= 1; maxX += 1; minY -= 1; maxY += 1; const cols = maxX - minX + 1; const rows = maxY - minY + 1; const w = this.canvas.width / dpr; const h = this.canvas.height / dpr; const cellSize = Math.min(w / cols, h / rows, 60); const offsetX = (w - cols * cellSize) / 2 - minX * cellSize; const offsetY = (h - rows * cellSize) / 2 - minY * cellSize; ctx.textBaseline = "middle"; ctx.textAlign = "center"; frame.highlights.forEach((hl) => { ctx.fillStyle = this.colors.highlight; ctx.fillRect( offsetX + hl.x * cellSize, offsetY + hl.y * cellSize, hl.w * cellSize, hl.h * cellSize ); ctx.strokeStyle = this.colors.highlightBorder; ctx.lineWidth = 2; ctx.strokeRect( offsetX + hl.x * cellSize, offsetY + hl.y * cellSize, hl.w * cellSize, hl.h * cellSize ); }); ctx.strokeStyle = this.colors.line; ctx.lineWidth = 3; frame.lines.forEach((line) => { ctx.beginPath(); ctx.moveTo(offsetX + line.startX * cellSize, offsetY + line.y * cellSize); ctx.lineTo(offsetX + line.endX * cellSize, offsetY + line.y * cellSize); ctx.stroke(); }); const mono = getComputedStyle(this.themeRoot).getPropertyValue("--mono").trim() || "monospace"; ctx.font = `bold ${cellSize * 0.6}px ${mono}`; Object.entries(frame.cells).forEach(([key, cell]) => { const [x, y] = key.split(",").map(Number); const cx = offsetX + x * cellSize + cellSize / 2; const cy = offsetY + y * cellSize + cellSize / 2; ctx.fillStyle = cell.color || this.colors.text; if (cell.text) ctx.fillText(cell.text, cx, cy); if (cell.crossedOut) { ctx.strokeStyle = this.colors.carry; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx - cellSize * 0.3, cy + cellSize * 0.3); ctx.lineTo(cx + cellSize * 0.3, cy - cellSize * 0.3); ctx.stroke(); } }); this.updateUI(); } private updateUI() { if (this.frames.length > 0) { const msg = this.frames[this.currentFrame].message; this.onMessage?.(msg, false); this.onStep?.(this.currentFrame + 1, this.frames.length); } } play() { if (this.frames.length === 0) return; if (this.currentFrame >= this.frames.length - 1) this.currentFrame = 0; this.isPlaying = true; this.onPlayState?.(true); this.playInterval = setInterval(() => { if (this.currentFrame < this.frames.length - 1) this.step(1); else this.pause(); }, this.speed); } pause() { this.isPlaying = false; this.onPlayState?.(false); if (this.playInterval) clearInterval(this.playInterval); this.playInterval = null; } togglePlay() { if (this.isPlaying) this.pause(); else this.play(); } step(dir: number) { const newFrame = this.currentFrame + dir; if (newFrame >= 0 && newFrame < this.frames.length) { this.currentFrame = newFrame; this.render(); } } destroy() { this.pause(); } }