@teachinglab/omd
Version:
omd
564 lines (494 loc) • 20.4 kB
JavaScript
import { omdMetaExpression } from "./omdMetaExpression.js";
import { jsvgTextLine } from "@teachinglab/jsvg";
/* Gerard - ADDED */
const precedence = {
"+": 1,
"-": 1,
"*": 2,
"/": 2,
"^": 3
};
function getPrecedence(opNode) {
return precedence[opNode.name] || 0;
}
/**
* omdNode - A minimal class representing a node in the expression tree
* Focuses purely on tree structure and layout, delegates visuals to superclasses
* Uses math.js AST format
*/
export class omdNode extends omdMetaExpression {
/**
* Constructor - Creates a tree node from math.js AST data
* @param {Object} nodeData - The AST node from math.js parser
*/
constructor(nodeData) {
super();
// Gerard added
this.canvasContext = document.createElement("canvas").getContext("2d");
this.canvasContext.font = "24px 'Alberto Sans'";
let measure = this.canvasContext.measureText("M");
this.textHeight = measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent;
this.type = "omdNode";
this.nodeData = nodeData; // The AST node from math.js
this.childNodes = []; // Array of child omdNodes
/* Gerard added */
this.name = this.getNodeLabel();
this.mathType = nodeData.type;
/* Gerard - Unnecessary */
this.edgeLines = []; // Array of edge lines
this.zoomLevel = 1.0; // Current zoom level
// Initialize the tree structure
this.initializeNode();
}
/**
* Sets zoom level for this node and all children recursively
* @param {number} zoom - The zoom level (1.0 = 100%, 2.0 = 200%, etc.)
*/
setZoomLevel(zoom) {
this.zoomLevel = zoom;
// Apply to all child nodes
this.childNodes.forEach(child => child.setZoomLevel(zoom));
// Invalidate cached dimensions
this.invalidateCache();
}
/**
* Gets the width of this node, scaled by zoom level
* @returns {number} The node width in pixels
*/
get nodeWidth() {
if (!this._cachedWidth) {
// Use superclass text measurement if available, otherwise estimate
const label = this.getNodeLabel();
const baseWidth = Math.max(label.length * 12 + 20, 30);
this._cachedWidth = baseWidth * this.zoomLevel;
}
return this._cachedWidth;
}
/**
* Gets the height of this node, scaled by zoom level
* @returns {number} The node height in pixels
*/
get nodeHeight() {
return 50 * this.zoomLevel;
}
/**
* Gets the total width needed for this entire subtree
* @returns {number} The subtree width in pixels
*/
/* Gerard - Unnecessary */
get subtreeWidth() {
if (this.childNodes.length === 0) {
return this.nodeWidth;
}
const childSubtreeWidths = this.childNodes.map(child => child.subtreeWidth);
const totalChildWidth = childSubtreeWidths.reduce((sum, width) => sum + width, 0);
const spacing = (this.childNodes.length - 1) * this.nodeSpacing;
return Math.max(this.nodeWidth, totalChildWidth + spacing);
}
/**
* Gets the horizontal spacing between child nodes, scaled by zoom level
* @returns {number} The spacing in pixels
*/
/* Gerard - Unnecessary */
get nodeSpacing() {
return 100 * this.zoomLevel;
}
/**
* Gets the vertical spacing between tree levels, scaled by zoom level
* @returns {number} The level height in pixels
*/
/* Gerard - Unnecessary */
get levelHeight() {
return 120 * this.zoomLevel;
}
/**
* Invalidates cached dimension calculations when content changes
*/
invalidateCache() {
this._cachedWidth = null;
if (this.parent && this.parent.invalidateCache) {
this.parent.invalidateCache();
}
}
/**
* Main initialization method - sets up visuals, creates children, and layouts tree
*/
initializeNode() {
this.setupVisuals(); // Gerard removed
this.createChildNodes();
//this.layoutTree(); // Gerard removed
this.layoutExpression();
}
/**
* Creates the visual elements for this node (text label and background)
*/
/* Gerard - Modifiable */
setupVisuals() {
// Create text label using jsvg
this.nodeLabel = new jsvgTextLine();
this.nodeLabel.setText(this.getNodeLabel());
this.nodeLabel.setFontSize(24 * this.zoomLevel);
this.nodeLabel.setAlignment('center');
// Update superclass background size
this.updateSize();
// Position text at center
/* Gerard modified */
this.nodeLabel.setPosition(0, this.textHeight/2 + this.nodeHeight/2);
this.addChild(this.nodeLabel);
}
/**
* Updates the size of visual elements to match calculated dimensions
*/
updateSize() {
// Update the superclass background rectangle
this.backRect.setWidthAndHeight(this.nodeWidth, this.nodeHeight);
this.setWidthAndHeight(this.nodeWidth, this.nodeHeight);
}
/**
* Creates child nodes from AST data and connecting edge lines
*/
/* Gerard - 50/50 */
createChildNodes() {
const childrenData = this.getNodeChildren();
childrenData.forEach((childData, index) => {
/* Gerard - Necessary */
// Create child node
const childNode = new omdNode(childData);
childNode.parent = this;
childNode.mathType = childData.type;
this.childNodes.push(childNode);
this.addChild(childNode);
/* Gerard - Unnecessary */
// Create edge line
// const edge = new jsvgLine();
// edge.setStrokeWidth(3);
// this.edgeLines.push(edge);
// this.addChild(edge);
});
}
/* Gerard - Modified */
layoutExpression() {
// First, calculates and sets size of the expression
let bounds = this.getExpressionBounds(getPrecedence(this));
this.setWidthAndHeight(bounds.right - bounds.left, this.nodeHeight);
// Then, positions children based on size
this.positionChildrenExpression(bounds);
}
// Gerard: ADDED + QUESTION - Is some of the recursion necessary if the nodes are
// being created and initializing themselves?
positionChildrenExpression(bounds) {
// Function for positioning changes based on node type. Store it here
let positionFn;
switch (this.mathType) {
case "OperatorNode":
positionFn = (child, index) => {
// TODO: Comment
let x, y;
if (index === 0) {
x = bounds.left + this.nodeWidth / 2;
//console.log(bounds);
}
else /* if (index === 1) */ {
x = bounds.right - this.nodeWidth / 2;
}
y = bounds.bottom * 2;
child.setPosition(x, y);
//child.backRect.setPosition(-this.width / 2, y - this.height);
}
break;
case "FunctionNode":
case "ParenthesisNode":
// TODO: Comment
positionFn = (child) => {
//console.log(bounds.right - bounds.left);
//this.setWidthAndHeight(bounds.right - bounds.left, this.nodeHeight);
child.setPosition(0, bounds.bottom * 2);
}
break;
case "ConstantNode":
case "SymbolNode":
positionFn = () => {};
break;
}
this.childNodes.forEach(positionFn);
}
/* Gerard - ADDED */
// Calculates the bounds of an expression.
// topPrecedence determines if it should calculate just leaves or entire subtree
getExpressionBounds(topPrecedence) {
// If this has no children, return just node size
if (this.childNodes.length === 0) {
return {
left: -this.nodeWidth / 2,
right: this.nodeWidth / 2,
top: -this.nodeHeight / 2,
bottom: this.nodeHeight / 2
};
}
if (this.mathType === "OperatorNode") {
//console.log(getPrecedence(this), topPrecedence);
let precedence = getPrecedence(this);
if (precedence !== topPrecedence) {
precedence = topPrecedence;
}
// Find the direct left and right operands
// If child operations are of same precedence, SHOULD NOT include the entire operation
let leftChild = this.findLeftOperand(precedence);
let rightChild = this.findRightOperand(precedence);
// Get the bounds of left and right direct operands
// If topPrecedence is lower than current operation, include all bounds
let leftChildBounds = leftChild.getExpressionBounds(topPrecedence);
let rightChildBounds = rightChild.getExpressionBounds(topPrecedence);
let left = -this.nodeWidth / 2 - (leftChildBounds.right - leftChildBounds.left);
let right = this.nodeWidth / 2 + (rightChildBounds.right - rightChildBounds.left);
return {
left: left,
right: right,
top: Math.min(leftChildBounds.top, rightChildBounds.top),
bottom: Math.max(leftChildBounds.bottom, rightChildBounds.bottom),
};
}
if (this.mathType === "FunctionNode") {
return this.childNodes[0].getExpressionBounds(getPrecedence(this.name));
}
if (this.mathType === "ParenthesisNode") {
// Parentheses always include total bounds (lowest precedence)
//console.log(getPrecedence(this.name));
return this.childNodes[0].getExpressionBounds(getPrecedence(this.name));
}
}
/* Gerard - ADDED */
// TODO: Combine into one function? Probably
findLeftOperand(topPrecedence) {
// Return if not another operand
// Parenthesized Nodes or Constants/Symbols
if (this.mathType !== "OperatorNode") return this;
// Keep searching if child precedence is the same as the top precedence
// TODO: Assure logic
let childPrecedence = getPrecedence(this.childNodes[0]);
if (childPrecedence === topPrecedence ) {
return this.childNodes[0].findRightOperand(topPrecedence);
}
else {
return this.childNodes[0];
}
}
findRightOperand(topPrecedence) {
// Return if not another operand
// Parenthesized Nodes or Constants/Symbols
if (this.mathType !== "OperatorNode") return this;
// Keep searching if child precedence is the same as the top precedence
// TODO: Assure logic
let childPrecedence = getPrecedence(this.childNodes[1]);
if (childPrecedence === topPrecedence) {
return this.childNodes[1].findLeftOperand(topPrecedence);
}
else {
return this.childNodes[1];
}
}
/**
* Recursively layouts the entire tree structure
*/
/* Gerard - Unnecessary */
layoutTree() {
if (this.childNodes.length === 0) return;
// Layout children first to get their final sizes
this.childNodes.forEach(child => child.layoutTree());
// Calculate positions
this.positionChildren();
this.updateEdges();
}
/**
* Positions child nodes horizontally to center them under this node
*/
/* Gerard - Unnecessary */
positionChildren() {
if (this.childNodes.length === 0) return;
// Calculate starting position to center children
const totalWidth = this.childNodes.reduce((sum, child) => sum + child.subtreeWidth, 0);
const totalSpacing = (this.childNodes.length - 1) * this.nodeSpacing;
const totalRequiredWidth = totalWidth + totalSpacing;
const startX = -totalRequiredWidth / 2 + this.nodeWidth / 2;
const childY = this.nodeHeight + this.levelHeight;
let currentX = startX;
this.childNodes.forEach((child, index) => {
// Center child in its allocated subtree space
const childX = currentX + child.subtreeWidth / 2 - child.nodeWidth / 2;
// Position using jsvg method
child.setPosition(childX, childY);
// Move to next position
currentX += child.subtreeWidth + this.nodeSpacing;
});
}
/**
* Updates the connecting edge lines between this node and its children
*/
/* Gerard - Unnecessary */
updateEdges() {
this.childNodes.forEach((child, index) => {
if (this.edgeLines[index]) {
// Connect center-bottom of parent to center-top of child
const parentCenterX = this.nodeWidth / 2;
const parentBottomY = this.nodeHeight;
const childCenterX = child.xpos + child.nodeWidth / 2;
const childTopY = child.ypos;
this.edgeLines[index].setEndpointA(parentCenterX, parentBottomY);
this.edgeLines[index].setEndpointB(childCenterX, childTopY);
}
});
}
/**
* Extracts child node data from the math.js AST based on node type
* @returns {Array} Array of child AST nodes
*/
getNodeChildren() {
if (!this.nodeData) return [];
const nodeType = this.detectNodeType();
// Handle math.js node types
switch (nodeType) {
case 'FunctionNode':
return this.nodeData.args || [];
case 'MatrixNode':
return this.nodeData.args || [];
case 'OperatorNode':
return this.nodeData.args || [];
case 'ParenthesisNode':
return [this.nodeData.content];
case 'ArrayNode':
return this.nodeData.items || [];
case 'IndexNode':
return [this.nodeData.object, this.nodeData.index];
case 'AccessorNode':
return [this.nodeData.object, this.nodeData.index];
case 'AssignmentNode':
return [this.nodeData.object, this.nodeData.value];
case 'ConditionalNode':
return [this.nodeData.condition, this.nodeData.trueExpr, this.nodeData.falseExpr];
default:
return [];
}
}
/**
* Determines the type of math.js AST node by examining its properties
* @returns {string} The node type (e.g., 'OperatorNode', 'ConstantNode', etc.)
*/
detectNodeType() {
if (!this.nodeData) return 'Unknown';
// Check for properties that uniquely identify each node type
if (this.nodeData.hasOwnProperty('op') && this.nodeData.hasOwnProperty('args')) {
return 'OperatorNode';
} else if (this.nodeData.hasOwnProperty('fn') && this.nodeData.hasOwnProperty('args')) {
if (this.nodeData.fn === 'matrix') {
return 'MatrixNode';
}
return 'FunctionNode';
} else if (this.nodeData.hasOwnProperty('value')) {
return 'ConstantNode';
} else if (this.nodeData.hasOwnProperty('name') && !this.nodeData.hasOwnProperty('args')) {
return 'SymbolNode';
} else if (this.nodeData.hasOwnProperty('content')) {
return 'ParenthesisNode';
} else if (this.nodeData.hasOwnProperty('items')) {
return 'ArrayNode';
} else if (this.nodeData.hasOwnProperty('object') && this.nodeData.hasOwnProperty('index')) {
return 'IndexNode';
} else if (this.nodeData.hasOwnProperty('condition')) {
return 'ConditionalNode';
}
return 'Unknown';
}
/**
* Generates the display label for this node based on its AST data
* @returns {string} The text label to display in the node
*/
getNodeLabel() {
const nodeType = this.detectNodeType();
switch (nodeType) {
case 'ConstantNode':
return this.nodeData.value.toString();
case 'SymbolNode':
return this.nodeData.name;
case 'FunctionNode':
return this.nodeData.fn.name || this.nodeData.fn;
case 'MatrixNode':
if (this.nodeData.args && this.nodeData.args.length > 0) {
const firstArg = this.nodeData.args[0];
if (firstArg && firstArg.items) {
const rows = firstArg.items.length;
const cols = firstArg.items[0] && firstArg.items[0].items ? firstArg.items[0].items.length : 1;
return `Matrix ${rows}×${cols}`;
}
}
return 'Matrix';
case 'OperatorNode':
const operatorMap = {
'add': '+',
'subtract': '-',
'multiply': '*',
'divide': '/',
'pow': '^',
'unaryMinus': '-',
'unaryPlus': '+'
};
return operatorMap[this.nodeData.fn] || this.nodeData.fn;
case 'ParenthesisNode':
return '( )';
case 'ArrayNode':
return '[ ]';
case 'IndexNode':
return '[]';
case 'AccessorNode':
return '.';
case 'AssignmentNode':
return '=';
case 'ConditionalNode':
return '?:';
default:
return nodeType || '?';
}
}
/**
* Calculates the bounding box of this entire subtree
* @returns {Object} Object with left, right, top, bottom coordinates
*/
/* Gerard - Unnecessary */
getTreeBounds() {
const myX = this.xpos || 0;
const myY = this.ypos || 0;
if (this.childNodes.length === 0) {
return {
left: myX,
right: myX + this.nodeWidth,
top: myY,
bottom: myY + this.nodeHeight
};
}
let left = myX;
let right = myX + this.nodeWidth;
let top = myY;
let bottom = myY + this.nodeHeight;
this.childNodes.forEach(child => {
const childBounds = child.getTreeBounds();
left = Math.min(left, childBounds.left);
right = Math.max(right, childBounds.right);
top = Math.min(top, childBounds.top);
bottom = Math.max(bottom, childBounds.bottom);
});
return { left, right, top, bottom };
}
/**
* Gets the total dimensions of this tree for layout purposes
* @returns {Object} Object with width, height, and bounds
*/
/* Gerard - Unnecessary */
/* Gerard: Question - Unused? */
getTreeDimensions() {
const bounds = this.getTreeBounds();
return {
width: bounds.right - bounds.left,
height: bounds.bottom - bounds.top,
bounds: bounds
};
}
}