UNPKG

@teachinglab/omd

Version:

omd

1,165 lines (1,023 loc) 57.5 kB
import { omdNode } from "./omdNode.js"; import { getNodeForAST } from "../core/omdUtilities.js"; import { omdOperatorNode } from "./omdOperatorNode.js"; import { omdBinaryExpressionNode } from "./omdBinaryExpressionNode.js"; import { omdConstantNode } from "./omdConstantNode.js"; import { omdParenthesisNode } from "./omdParenthesisNode.js"; import { omdRationalNode } from "./omdRationalNode.js"; import { omdFunctionNode } from "./omdFunctionNode.js"; import { omdUnaryExpressionNode } from './omdUnaryExpressionNode.js'; /** * @global {math} math - The global math.js instance. */ export class omdEquationNode extends omdNode { constructor(ast) { super(ast); this.type = "omdEquationNode"; const type = ast.type || ast.mathjs; if (type === "AssignmentNode") { const LeftNodeType = getNodeForAST(ast.object); this.left = new LeftNodeType(ast.object); const RightNodeType = getNodeForAST(ast.value); this.right = new RightNodeType(ast.value); this.argumentNodeList.left = this.left; this.argumentNodeList.right = this.right; } else if (ast.args && ast.args.length === 2) { // Fallback for other potential structures const LeftNodeType = getNodeForAST(ast.args[0]); this.left = new LeftNodeType(ast.args[0]); const RightNodeType = getNodeForAST(ast.args[1]); this.right = new RightNodeType(ast.args[1]); // Ensure argumentNodeList is populated for replacement machinery this.argumentNodeList.left = this.left; this.argumentNodeList.right = this.right; } else { // Create dummy nodes to prevent further errors this.left = new omdNode({ type: 'SymbolNode', name: 'error' }); this.right = new omdNode({ type: 'SymbolNode', name: 'error' }); this.argumentNodeList.left = this.left; this.argumentNodeList.right = this.right; } this.equalsSign = new omdOperatorNode({ type: "OperatorNode", op: "=" }); this.addChild(this.left); this.addChild(this.equalsSign); this.addChild(this.right); // Optional background style configuration this._backgroundStyle = null; // { backgroundColor, cornerRadius, pill } this._propagateBackgroundStyle(this._backgroundStyle); } computeDimensions() { this.left.computeDimensions(); this.equalsSign.computeDimensions(); this.right.computeDimensions(); const spacing = 8 * this.getFontSize() / this.getRootFontSize(); let totalWidth = this.left.width + this.equalsSign.width + this.right.width + (spacing * 2); const contentHeight = Math.max(this.left.height, this.equalsSign.height, this.right.height); const { padX, padY } = this._getEffectivePadding(contentHeight); const maxHeight = contentHeight + (padY * 2); totalWidth += (padX * 2); this.setWidthAndHeight(totalWidth, maxHeight); } updateLayout() { // Keep argumentNodeList synchronized for replacement machinery this.argumentNodeList = { left: this.left, right: this.right }; const spacing = 8 * this.getFontSize() / this.getRootFontSize(); const maxBaseline = Math.max( this.left.getAlignmentBaseline(), this.equalsSign.getAlignmentBaseline(), this.right.getAlignmentBaseline() ); // Optional background padding offset (reuse effective padding) const contentHeight2 = Math.max(this.left.height, this.equalsSign.height, this.right.height); let { padX, padY } = this._getEffectivePadding(contentHeight2); let x = padX; // Position left node this.left.updateLayout(); this.left.setPosition(x, padY + (maxBaseline - this.left.getAlignmentBaseline())); x += this.left.width + spacing; // Position equals sign this.equalsSign.updateLayout(); this.equalsSign.setPosition(x, padY + (maxBaseline - this.equalsSign.getAlignmentBaseline())); x += this.equalsSign.width + spacing; // Position right node this.right.updateLayout(); this.right.setPosition(x, padY + (maxBaseline - this.right.getAlignmentBaseline())); // Recompute overall dimensions now that children are positioned (handles tall nodes like rationals) this.computeDimensions(); // Apply configured background styling after layout to ensure correct dimensions if (this._backgroundStyle) { const { backgroundColor, cornerRadius, pill } = this._backgroundStyle; if (backgroundColor) { this.backRect.setFillColor(backgroundColor); this.backRect.setOpacity(1.0); this.defaultOpaqueBack = true; } if (pill === true) { // Pill shape: half the height const radius = Math.max(0, Math.floor(this.height / 2)); this.backRect.setCornerRadius(radius); // Also apply pill corners to all descendant nodes so their backgrounds don't show square edges this._applyPillToDescendants(); } else if (typeof cornerRadius === 'number') { this.backRect.setCornerRadius(cornerRadius); } // Make all descendant backgrounds match the equation background color if (backgroundColor) { this._matchChildBackgrounds(backgroundColor); } } // Ensure the background rectangle always matches the current equation size if (this.backRect && (this.width || this.height)) { this.backRect.setWidthAndHeight(this.width, this.height); } // Final pass: center content visually within backRect const minTop2 = Math.min(this.left.ypos, this.equalsSign.ypos, this.right.ypos); const maxBottom2 = Math.max( this.left.ypos + this.left.height, this.equalsSign.ypos + this.equalsSign.height, this.right.ypos + this.right.height ); const topPad = minTop2; const bottomPad = Math.max(0, (this.height || 0) - maxBottom2); let deltaY2 = (topPad - bottomPad) / 2 - (0.06 * this.getFontSize()); if (Math.abs(deltaY2) > 0.01) { this.left.setPosition(this.left.xpos, this.left.ypos - deltaY2); this.equalsSign.setPosition(this.equalsSign.xpos, this.equalsSign.ypos - deltaY2); this.right.setPosition(this.right.xpos, this.right.ypos - deltaY2); } } /** * Computes effective padding taking into account defaults, user overrides, and pill radius clamping. * @param {number} contentHeight * @returns {{padX:number,padY:number}} */ _getEffectivePadding(contentHeight) { const ratio = this.getFontSize() / this.getRootFontSize(); const baseX = 2 * ratio; const baseY = 2 * ratio; const pad = this._backgroundStyle?.padding; let padX = (typeof pad === 'number' ? pad : pad?.x) ?? baseX; let padY = (typeof pad === 'number' ? pad : pad?.y) ?? baseY; if (this._backgroundStyle?.pill === true) { const radius = Math.ceil((contentHeight + 2 * padY) / 2); if (padX < radius) padX = radius; } return { padX, padY }; } _propagateBackgroundStyle(style, visited = new Set()) { if (visited.has(this)) return; visited.add(this); this._backgroundStyle = style; // Helper to recursively walk any object that might be a node function walkNode(node, style, visited) { if (!node || visited.has(node)) return; visited.add(node); if (node._propagateBackgroundStyle) { node._propagateBackgroundStyle(style, visited); return; } node._backgroundStyle = style; if (Array.isArray(node.childList)) { for (const c of node.childList) walkNode(c, style, visited); } if (node.argumentNodeList) { for (const val of Object.values(node.argumentNodeList)) { if (Array.isArray(val)) { for (const v of val) walkNode(v, style, visited); } else { walkNode(val, style, visited); } } } } // Propagate to childList if (Array.isArray(this.childList)) { for (const child of this.childList) { walkNode(child, style, visited); } } // Propagate to argumentNodeList (recursively, including arrays) if (this.argumentNodeList && typeof this.argumentNodeList === 'object') { for (const val of Object.values(this.argumentNodeList)) { if (Array.isArray(val)) { for (const v of val) { walkNode(v, style, visited); } } else { walkNode(val, style, visited); } } } } /** * Applies pill-shaped corner radius to all descendant nodes' backgrounds. * Ensures child nodes don't show square corners when the parent equation uses a pill. * @private */ _applyPillToDescendants() { const visited = new Set(); const stack = Array.isArray(this.childList) ? [...this.childList] : []; while (stack.length) { const node = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); if (node !== this && node.backRect && typeof node.backRect.setCornerRadius === 'function') { const h = typeof node.height === 'number' && node.height > 0 ? node.height : 0; const r = Math.max(0, Math.floor(h / 2)); node.backRect.setCornerRadius(r); } if (Array.isArray(node.childList)) { for (const c of node.childList) stack.push(c); } if (node.argumentNodeList && typeof node.argumentNodeList === 'object') { for (const val of Object.values(node.argumentNodeList)) { if (Array.isArray(val)) { val.forEach(v => v && stack.push(v)); } else if (val) { stack.push(val); } } } } } /** * Creates a value node from a number or a Math.js AST object. * @param {number|object} value - The value to convert. * @returns {omdNode} The corresponding OMD node. * @private */ _createNodeFromValue(value) { if (typeof value === 'number') { const node = new omdConstantNode({ value }); node.initialize(); // Constants need initialization to compute dimensions return node; } if (typeof value === 'object' && value !== null) { // It's a mathjs AST const NodeClass = getNodeForAST(value); const node = new NodeClass(value); // Most non-leaf nodes have initialize, but we call it just in case if (typeof node.initialize === 'function') { node.initialize(); } return node; } return null; } /** * Applies an operation to both sides of the equation. * @param {number|object} value - The value to apply. * @param {string} op - The operator symbol (e.g., '+', '-', '*', '/'). * @param {string} fn - The function name for the AST (e.g., 'add', 'subtract'). * @returns {omdEquationNode} A new equation node with the operation applied. * @private */ _applyOperation(value, op, fn) { const valueNode = this._createNodeFromValue(value); if (!valueNode) return this; // Return original if value is invalid // Determine if we need to wrap sides in parentheses for correct precedence const leftSideNeedsParens = this._needsParenthesesForOperation(this.left, op); const rightSideNeedsParens = this._needsParenthesesForOperation(this.right, op); // Wrap sides in parentheses if needed const leftOperand = leftSideNeedsParens ? { type: 'ParenthesisNode', content: this.left.toMathJSNode() } : this.left.toMathJSNode(); const rightOperand = rightSideNeedsParens ? { type: 'ParenthesisNode', content: this.right.toMathJSNode() } : this.right.toMathJSNode(); const newLeftAst = { type: 'OperatorNode', op, fn, args: [leftOperand, valueNode.toMathJSNode()] }; const newRightAst = { type: 'OperatorNode', op, fn, args: [rightOperand, valueNode.toMathJSNode()] }; let newLeft, newRight; if (op === '/') { newLeft = new omdRationalNode(newLeftAst); newRight = new omdRationalNode(newRightAst); } else { newLeft = new omdBinaryExpressionNode(newLeftAst); newRight = new omdBinaryExpressionNode(newRightAst); } const newEquationAst = { type: 'AssignmentNode', object: newLeft.toMathJSNode(), index: null, value: newRight.toMathJSNode() }; const newEquation = new omdEquationNode(newEquationAst); newEquation.setFontSize(this.getFontSize()); // Establish provenance tracking from original equation to new equation newEquation.provenance.push(this.id); // Establish granular provenance: left side to left side, right side to right side if (newEquation.left && this.left) { this._establishGranularProvenance(newEquation.left, this.left, value, fn); } if (newEquation.right && this.right) { this._establishGranularProvenance(newEquation.right, this.right, value, fn); } newEquation.initialize(); return newEquation; } /** * Determines if a node needs parentheses when used as an operand with the given operation. * This ensures correct operator precedence. * @param {omdNode} node - The node to check * @param {string} operation - The operation that will be applied ('*', '/', '+', '-') * @returns {boolean} True if parentheses are needed * @private */ _needsParenthesesForOperation(node, operation) { // If the node is not a binary expression, no parentheses needed if (!node || node.type !== 'omdBinaryExpressionNode') { return false; } // Define operator precedence (higher number = higher precedence) const precedence = { '+': 1, '-': 1, '*': 2, '/': 2, '^': 3 }; // Get the operation of the existing node let existingOp = node.operation; if (typeof existingOp === 'object' && existingOp && existingOp.name) { existingOp = existingOp.name; } if (node.astNodeData && node.astNodeData.op) { existingOp = node.astNodeData.op; } // Convert operation names to symbols if needed const opMap = { 'add': '+', 'subtract': '-', 'multiply': '*', 'divide': '/', 'pow': '^' }; const currentOpSymbol = opMap[existingOp] || existingOp; const newOpSymbol = opMap[operation] || operation; // If we can't determine the precedence, be safe and add parentheses if (!precedence[currentOpSymbol] || !precedence[newOpSymbol]) { return true; } // Need parentheses if the existing operation has lower precedence than the new operation // For example: (x + 2) * 3 needs parentheses, but x * 2 + 3 doesn't need them around x * 2 return precedence[currentOpSymbol] < precedence[newOpSymbol]; } /** * Returns a new equation with a value added to both sides. * @param {number|object} value - The value to add. */ addToBothSides(value) { return this._applyOperation(value, '+', 'add'); } /** * Returns a new equation with a value subtracted from both sides. * @param {number|object} value - The value to subtract. */ subtractFromBothSides(value) { return this._applyOperation(value, '-', 'subtract'); } /** * Returns a new equation with both sides multiplied by a value. * @param {number|object} value - The value to multiply by. * @param {string} [operationDisplayId] - Optional ID of the operation display for provenance tracking. */ multiplyBothSides(value, operationDisplayId) { return this._applyOperation(value, '*', 'multiply', operationDisplayId); } /** * Returns a new equation with both sides divided by a value. * @param {number|object} value - The value to divide by. */ divideBothSides(value) { return this._applyOperation(value, '/', 'divide'); } /** * Establishes granular provenance tracking between new and original nodes * This handles equation operations like "multiply both sides" by linking the new expression to the original * @param {omdNode} newNode - The new node being created (the result of the operation) * @param {omdNode} originalNode - The original node being transformed * @param {number|Object} operationValue - The value used in the operation * @param {string} operation - The operation being performed ('add', 'subtract', 'multiply', 'divide') * @private */ _establishGranularProvenance(newNode, originalNode, operationValue, operation) { if (!newNode || !originalNode) return; // Ensure newNode has a provenance array if (!newNode.provenance) { newNode.provenance = []; } // For equation operations, we want to establish provenance between corresponding parts if (operation === 'divide') { // For division operations like (2x)/2 = x, check if we can simplify if (originalNode.type === 'omdBinaryExpressionNode' && this._isMultiplicationOperation(originalNode)) { // Check if the operation value matches one of the factors const leftIsConstant = originalNode.left.isConstant(); const rightIsConstant = originalNode.right.isConstant(); // Convert operationValue to number if it's an object const opValue = (typeof operationValue === 'object' && operationValue.getValue) ? operationValue.getValue() : operationValue; if (leftIsConstant && originalNode.left.getValue() === opValue) { // Dividing by the left factor, so result should trace to right factor this._copyProvenanceStructure(newNode, originalNode.right); } else if (rightIsConstant && originalNode.right.getValue() === opValue) { // Dividing by the right factor, so result should trace to left factor this._copyProvenanceStructure(newNode, originalNode.left); } else { // Not a simple factor division, link to the whole expression this._copyProvenanceStructure(newNode, originalNode); } } else { // Not a multiplication, link to the whole original this._copyProvenanceStructure(newNode, originalNode); } } else if (operation === 'multiply') { // For multiplication operations like x * 2 = 2x // The new expression should trace back to the original expression this._copyProvenanceStructure(newNode, originalNode); // Also establish provenance for the binary expression structure if (newNode.type === 'omdBinaryExpressionNode') { // Link the left operand (which should be the original expression) to the original if (newNode.left) { this._copyProvenanceStructure(newNode.left, originalNode); } // The right operand is the operation value, no additional provenance needed } } else if (operation === 'add' || operation === 'subtract') { // For addition/subtraction, the new binary expression's provenance should // link to the original expression, but we should handle operands separately // to avoid incorrect linking of the added/subtracted value. newNode.provenance.push(originalNode.id); if (newNode.type === 'omdBinaryExpressionNode') { // Link the left operand (the original side of the equation) to the original node structure. if (newNode.left) { this._copyProvenanceStructure(newNode.left, originalNode); } // The right operand is the new value being added/subtracted - preserve its provenance // for proper highlighting when constants are combined later // (Don't clear provenance - let it maintain its own identity for combination rules) } } else { // For any other operations, link to the whole original expression this._copyProvenanceStructure(newNode, originalNode); } } /** * Helper method to check if a node represents a multiplication operation * @param {omdNode} node - The node to check * @returns {boolean} True if it's a multiplication operation * @private */ _isMultiplicationOperation(node) { if (node.type !== 'omdBinaryExpressionNode') return false; const op = node.operation; return op === 'multiply' || (typeof op === 'object' && op && op.name === 'multiply') || (node.op && node.op.opName === '*'); } /** * Copies the provenance structure from source to target, maintaining granularity * @param {omdNode} target - The node to set provenance on * @param {omdNode} source - The node to copy provenance from * @private */ _copyProvenanceStructure(target, source) { if (!target || !source) return; // Initialize provenance array if it doesn't exist if (!target.provenance) { target.provenance = []; } // If the source has its own provenance, copy it if (source.provenance && source.provenance.length > 0) { // Create a Set to track unique IDs we've already processed const processedIds = new Set(target.provenance); // Process each provenance ID from source source.provenance.forEach(id => { if (!processedIds.has(id)) { processedIds.add(id); target.provenance.push(id); } }); } // Add the source's own ID if not already present if (!target.provenance.includes(source.id)) { target.provenance.push(source.id); } // If both nodes have the same structure, recursively copy provenance if (target.type === source.type) { if (target.argumentNodeList && source.argumentNodeList) { for (const key of Object.keys(source.argumentNodeList)) { const targetChild = target.argumentNodeList[key]; const sourceChild = source.argumentNodeList[key]; if (targetChild && sourceChild) { if (Array.isArray(targetChild) && Array.isArray(sourceChild)) { // Handle array of children for (let i = 0; i < Math.min(targetChild.length, sourceChild.length); i++) { if (targetChild[i] && sourceChild[i]) { this._copyProvenanceStructure(targetChild[i], sourceChild[i]); } } } else { // Handle single child node this._copyProvenanceStructure(targetChild, sourceChild); } } } } } } /** * Creates an omdEquationNode instance from a string. * @param {string} equationString - The string to parse (e.g., "2x+4=10"). * @returns {omdEquationNode} A new instance of omdEquationNode. */ static fromString(equationString) { if (!equationString.includes('=')) { throw new Error("Input string is not a valid equation."); } const parts = equationString.split('='); if (parts.length > 2) { throw new Error("Equation can only have one '=' sign."); } const left = parts[0].trim(); const right = parts[1].trim(); if (!left || !right) { throw new Error("Equation must have a left and a right side."); } // Manually construct an AST-like object that the constructor can understand. const ast = { type: "AssignmentNode", object: math.parse(left), value: math.parse(right), // Add a clone method so it behaves like a real math.js node for our system. clone: function () { return { type: this.type, object: this.object.clone(), value: this.value.clone(), clone: this.clone }; } }; return new omdEquationNode(ast); } clone() { // Create a clone from a deep-copied AST. This creates a node tree // with the exact structure needed for simplification. const newAstNodeData = JSON.parse(JSON.stringify(this.astNodeData)); const clone = new omdEquationNode(newAstNodeData); // Recursively fix the provenance chain for the new clone. clone._syncProvenanceFrom(this); clone.setFontSize(this.getFontSize()); // Ensure argumentNodeList exists on clone for replacement machinery clone.argumentNodeList = { left: clone.left, right: clone.right }; return clone; } /** * Overrides default deselect behavior for equations inside a calculation. * @param {omdNode} root - The root of the deselection event. */ deselect(root) { if (!(root instanceof omdNode)) root = this; if (this === root && this.parent instanceof omdNode) { this.parent.select(root); } this.backRect.setFillColor(omdColor.lightGray); if (this.defaultOpaqueBack == false) { this.backRect.setOpacity(0.01); } this.childList.forEach((child) => { if (child !== root && child instanceof omdNode) { child.deselect(root); } }); } /** * Converts the omdEquationNode to a math.js AST node. * @returns {Object} A math.js-compatible AST node. */ toMathJSNode() { let astNode; // Get fresh AST representations from children to ensure parentheses and other // structural elements are properly preserved if (this.astNodeData.type === "AssignmentNode") { astNode = { type: 'AssignmentNode', object: this.left.toMathJSNode(), value: this.right.toMathJSNode(), id: this.id, provenance: this.provenance }; } else { astNode = { type: 'OperatorNode', op: '=', fn: 'equal', args: [this.left.toMathJSNode(), this.right.toMathJSNode()], id: this.id, provenance: this.provenance }; } // Add a clone method to maintain compatibility with math.js's expectations. astNode.clone = function() { const clonedNode = { ...this }; if (this.object) clonedNode.object = this.object.clone(); if (this.value) clonedNode.value = this.value.clone(); if (this.args) clonedNode.args = this.args.map(arg => arg.clone()); return clonedNode; }; return astNode; } /** * Applies a function to both sides of the equation * @param {string} functionName - The name of the function to apply * @returns {omdEquationNode} A new equation with the function applied to both sides */ applyFunction(functionName) { const leftWithFunction = this._createFunctionNode(functionName, this.left); const rightWithFunction = this._createFunctionNode(functionName, this.right); const newEquation = this._createNewEquation(leftWithFunction, rightWithFunction); newEquation.provenance.push(this.id); return newEquation; } /** * Creates a function node wrapping the given argument * @param {string} functionName - The function name * @param {omdNode} argument - The argument to wrap * @returns {omdNode} The function node * @private */ _createFunctionNode(functionName, argument) { // Create a math.js AST for the function const functionAst = { type: 'FunctionNode', fn: { type: 'SymbolNode', name: functionName }, args: [argument.toMathJSNode()] }; // Use the already imported getNodeForAST function const NodeClass = getNodeForAST(functionAst); const functionNode = new NodeClass(functionAst); functionNode.setFontSize(this.getFontSize()); return functionNode; } /** * Creates a new equation from left and right sides * @param {omdNode} left - The left side * @param {omdNode} right - The right side * @returns {omdEquationNode} The new equation * @private */ _createNewEquation(left, right) { const newAst = { type: "AssignmentNode", object: left.toMathJSNode(), value: right.toMathJSNode(), clone: function () { return { type: this.type, object: this.object.clone(), value: this.value.clone(), clone: this.clone }; } }; return new omdEquationNode(newAst); } /** * Apply an operation to one or both sides of the equation * @param {number|omdNode} value - The value to apply * @param {string} operation - 'add', 'subtract', 'multiply', or 'divide' * @param {string} side - 'left', 'right', or 'both' (default: 'both') * @returns {omdEquationNode} New equation with operation applied */ applyOperation(value, operation, side = 'both') { // Map operation names to operators and function names const operationMap = { 'add': { op: '+', fn: 'add' }, 'subtract': { op: '-', fn: 'subtract' }, 'multiply': { op: '*', fn: 'multiply' }, 'divide': { op: '/', fn: 'divide' } }; const opInfo = operationMap[operation]; if (!opInfo) { throw new Error(`Unknown operation: ${operation}`); } // Handle different side options if (side === 'both') { // Use existing methods for both sides return this._applyOperation(value, opInfo.op, opInfo.fn); } // For single side operations, we need to create the new equation manually const valueNode = this._createNodeFromValue(value); if (!valueNode) { throw new Error("Invalid value provided"); } // Create new AST for the specified side let newLeftAst, newRightAst; if (side === 'left') { // Apply operation to left side only const leftNeedsParens = this._needsParenthesesForOperation(this.left, opInfo.op); const leftOperand = leftNeedsParens ? { type: 'ParenthesisNode', content: this.left.toMathJSNode() } : this.left.toMathJSNode(); newLeftAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [leftOperand, valueNode.toMathJSNode()] }; newRightAst = this.right.toMathJSNode(); } else if (side === 'right') { // Apply operation to right side only const rightNeedsParens = this._needsParenthesesForOperation(this.right, opInfo.op); const rightOperand = rightNeedsParens ? { type: 'ParenthesisNode', content: this.right.toMathJSNode() } : this.right.toMathJSNode(); newLeftAst = this.left.toMathJSNode(); newRightAst = { type: 'OperatorNode', op: opInfo.op, fn: opInfo.fn, args: [rightOperand, valueNode.toMathJSNode()] }; } else { throw new Error(`Invalid side: ${side}. Must be 'left', 'right', or 'both'`); } // Create nodes from ASTs let newLeft, newRight; if (side === 'left' && opInfo.op === '/') { newLeft = new omdRationalNode(newLeftAst); newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst); } else if (side === 'right' && opInfo.op === '/') { newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst); newRight = new omdRationalNode(newRightAst); } else if (side === 'left') { newLeft = new omdBinaryExpressionNode(newLeftAst); newRight = getNodeForAST(newRightAst) === omdNode ? this.right : new (getNodeForAST(newRightAst))(newRightAst); } else { newLeft = getNodeForAST(newLeftAst) === omdNode ? this.left : new (getNodeForAST(newLeftAst))(newLeftAst); newRight = new omdBinaryExpressionNode(newRightAst); } // Create new equation const newEquationAst = { type: 'AssignmentNode', object: newLeft.toMathJSNode(), value: newRight.toMathJSNode() }; const newEquation = new omdEquationNode(newEquationAst); newEquation.setFontSize(this.getFontSize()); newEquation.provenance.push(this.id); // Initialize to compute dimensions newEquation.initialize(); return newEquation; } /** * Swap left and right sides of the equation * @returns {omdEquationNode} New equation with sides swapped */ swapSides() { const newEquation = this.clone(); [newEquation.left, newEquation.right] = [newEquation.right, newEquation.left]; // Update the AST for consistency [newEquation.astNodeData.object, newEquation.astNodeData.value] = [newEquation.astNodeData.value, newEquation.astNodeData.object]; newEquation.provenance.push(this.id); // This is a layout change, not a mathematical simplification, so no need for granular provenance newEquation.initialize(); return newEquation; } /** * Returns a string representation of the equation * @returns {string} The equation as a string */ toString() { return `${this.left.toString()} = ${this.right.toString()}`; } /** * Configure equation background styling. Defaults remain unchanged if not provided. * @param {{ backgroundColor?: string, cornerRadius?: number, pill?: boolean }} style */ setBackgroundStyle(style = {}) { this._backgroundStyle = { ...(this._backgroundStyle || {}), ...style }; this._propagateBackgroundStyle(this._backgroundStyle); if (this.backRect && (this.width || this.height)) { this.updateLayout(); } } /** * Returns the horizontal anchor X for the equals sign center relative to this node's origin. * Accounts for background padding and internal spacing. * @returns {number} */ getEqualsAnchorX() { const spacing = 8 * this.getFontSize() / this.getRootFontSize(); // Use EFFECTIVE padding so pill clamping and tall nodes are accounted for const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0); const { padX } = this._getEffectivePadding(contentHeight); // Anchor at center of equals sign return padX + this.left.width + spacing + (this.equalsSign?.width || 0) / 2; } /** * Returns the X padding applied by background style * @returns {number} */ getBackgroundPaddingX() { const pad = this._backgroundStyle?.padding; return pad == null ? 0 : (typeof pad === 'number' ? pad : (pad.x ?? 0)); } /** * Returns the effective horizontal padding used in layout, including pill clamping * @returns {number} */ getEffectiveBackgroundPaddingX() { const contentHeight = Math.max(this.left?.height || 0, this.equalsSign?.height || 0, this.right?.height || 0); const { padX } = this._getEffectivePadding(contentHeight); return padX; } /** * Hides the backgrounds of all child nodes (descendants), preserving only this node's background. * @private */ _matchChildBackgrounds(color) { const visited = new Set(); const stack = Array.isArray(this.childList) ? [...this.childList] : []; while (stack.length) { const node = stack.pop(); if (!node || visited.has(node)) continue; visited.add(node); if (node !== this && node.backRect) { node.backRect.setFillColor(color); node.backRect.setOpacity(1.0); } if (Array.isArray(node.childList)) { for (const c of node.childList) stack.push(c); } if (node.argumentNodeList && typeof node.argumentNodeList === 'object') { for (const val of Object.values(node.argumentNodeList)) { if (Array.isArray(val)) { val.forEach(v => v && stack.push(v)); } else if (val) { stack.push(val); } } } } } /** * Evaluates the equation by evaluating both sides and checking for equality. * @param {Object} variables - A map of variable names to their numeric values. * @returns {Object} An object containing the evaluated left and right sides. */ evaluate(variables = {}) { const leftValue = this.left.evaluate(variables); const rightValue = this.right.evaluate(variables); return { left: leftValue, right: rightValue }; } /** * Renders the equation to different visualization formats * @param {string} visualizationType - "graph" | "table" | "hanger" * @param {Object} options - Optional configuration * @param {string} options.side - "both" (default), "left", or "right" * @param {number} options.xMin - Domain min for x (default: -10) * @param {number} options.xMax - Domain max for x (default: 10) * @param {number} options.yMin - Range min for y (graph only, default: -10) * @param {number} options.yMax - Range max for y (graph only, default: 10) * @param {number} options.stepSize - Step size for table (default: 1) * @returns {Object} JSON per schemas in src/json-schemas.md */ renderTo(visualizationType, options = {}) { // Set default options const defaultOptions = { side: "both", xMin: -10, xMax: 10, yMin: -10, yMax: 10, stepSize: 1 }; const mergedOptions = { ...defaultOptions, ...options }; switch (visualizationType.toLowerCase()) { case 'graph': return this._renderToGraph(mergedOptions); case 'table': return this._renderToTable(mergedOptions); case 'hanger': return this._renderToHanger(mergedOptions); case 'tileequation': { const leftExpr = this.getLeft().toString(); const rightExpr = this.getRight().toString(); const eqString = `${leftExpr}=${rightExpr}`; // Colors/options passthrough const plusColor = mergedOptions.plusColor || '#79BBFD'; const equalsColor = mergedOptions.equalsColor || '#FF6B6B'; const xPillColor = mergedOptions.xPillColor; // optional const tileBgColor = mergedOptions.tileBackgroundColor; // optional const dotColor = mergedOptions.dotColor; // optional const tileSize = mergedOptions.tileSize || 28; const dotsPerColumn = mergedOptions.dotsPerColumn || 10; return { omdType: 'tileEquation', equation: eqString, tileSize, dotsPerColumn, plusColor, equalsColor, xPill: xPillColor ? { color: xPillColor } : undefined, numberTileDefaults: { backgroundColor: tileBgColor, dotColor } }; } default: throw new Error(`Unknown visualization type: ${visualizationType}. Supported types are: graph, table, hanger`); } } /** * Gets the left side of the equation * @returns {omdNode} The left side node */ getLeft() { return this.left; } /** * Gets the right side of the equation * @returns {omdNode} The right side node */ getRight() { return this.right; } /** * Generates JSON configuration for coordinate plane graph visualization * @param {Object} options - Configuration options * @returns {Object} JSON configuration for omdCoordinatePlane * @private */ _renderToGraph(options) { const leftExpr = this._normalizeExpressionString(this.getLeft().toString()); const rightExpr = this._normalizeExpressionString(this.getRight().toString()); let graphEquations = []; if (options.side === 'left') { graphEquations = [{ equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 }]; } else if (options.side === 'right') { graphEquations = [{ equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 }]; } else { // both: plot left and right as two functions; intersection corresponds to equality graphEquations = [ { equation: `y = ${leftExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'blue', strokeWidth: 2 }, { equation: `y = ${rightExpr}`, domain: { min: options.xMin, max: options.xMax }, color: 'red', strokeWidth: 2 } ]; } return { omdType: "coordinatePlane", xMin: options.xMin, xMax: options.xMax, yMin: options.yMin, yMax: options.yMax, // Allow caller to override visual settings via options xLabel: (options.xLabel !== undefined) ? options.xLabel : "x", yLabel: (options.yLabel !== undefined) ? options.yLabel : "y", size: (options.size !== undefined) ? options.size : "medium", tickInterval: (options.tickInterval !== undefined) ? options.tickInterval : 1, forceAllTickLabels: (options.forceAllTickLabels !== undefined) ? options.forceAllTickLabels : true, showTickLabels: (options.showTickLabels !== undefined) ? options.showTickLabels : true, // Background customization options backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined, backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined, backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined, showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined, graphEquations, lineSegments: [], dotValues: [], shapeSet: [] }; } /** * Generates JSON configuration for table visualization * @param {Object} options - Configuration options * @returns {Object} JSON configuration for omdTable * @private */ _renderToTable(options) { // Single side: let omdTable generate rows from equation if (options.side === 'left') { const expr = this._normalizeExpressionString(this.getLeft().toString()); return { omdType: "table", title: `Function Table: y = ${expr}`, headers: ["x", "y"], equation: `y = ${expr}`, xMin: options.xMin, xMax: options.xMax, stepSize: options.stepSize, // Background customization options backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined, backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined, backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined, showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined, // Alternating row color options alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined, evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined, oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined, alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined }; } else if (options.side === 'right') { const expr = this._normalizeExpressionString(this.getRight().toString()); return { omdType: "table", title: `Function Table: y = ${expr}`, headers: ["x", "y"], equation: `y = ${expr}`, xMin: options.xMin, xMax: options.xMax, stepSize: options.stepSize, // Background customization options backgroundColor: (options.backgroundColor !== undefined) ? options.backgroundColor : undefined, backgroundCornerRadius: (options.backgroundCornerRadius !== undefined) ? options.backgroundCornerRadius : undefined, backgroundOpacity: (options.backgroundOpacity !== undefined) ? options.backgroundOpacity : undefined, showBackground: (options.showBackground !== undefined) ? options.showBackground : undefined, // Alternating row color options alternatingRowColors: (options.alternatingRowColors !== undefined) ? options.alternatingRowColors : undefined, evenRowColor: (options.evenRowColor !== undefined) ? options.evenRowColor : undefined, oddRowColor: (options.oddRowColor !== undefined) ? options.oddRowColor : undefined, alternatingRowOpacity: (options.alternatingRowOpacity !== undefined) ? options.alternatingRowOpacity : undefined }; } // Both sides: compute data for x, left(x), right(x) const leftSide = this.getLeft(); const rightSide = this.getRight(); const leftLabel = leftSide.toString(); const rightLabel = rightSide.toString(); const headers = ["x", leftLabel, rightLabel]; const data = []; const start = options.xMin; con