From 4bf5d97f08e9371c99b4d7fdff16e52c82a79530 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:39:56 +0000 Subject: [PATCH 01/30] Initial plan From 3ccc55b51e9484e022ec5463794bbcc62d1d4298 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:48:13 +0000 Subject: [PATCH 02/30] feat: add unary function parsing and tree rendering support Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/1b9ad718-258f-4142-ba5d-146b7d889d15 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- README.md | 12 +++++ canvas.js | 8 +-- index.html | 4 +- infixToPostfix.js | 130 +++++++++++++++++++++++++++++++++++++++++----- tree.js | 74 +++++++++++++++++--------- 5 files changed, 185 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 56d4f59..eb66e8d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,18 @@ With that in mind, and inspired on my lectures on the tree data structure I deci Visit this [website](https://lnogueir.github.io/expression-tree-gen/) to simulate an expression yourself. +Supported syntax includes: +- Binary operators: `+`, `-`, `*`, `/` +- Single-letter variables: `a`-`z` +- Unary functions: `sin`, `cos`, `tan`, `log`, `ln`, `sqrt`, `exp`, `abs` + +Examples: +- `a+b` +- `sin(a)` +- `sin(a)+cos(b)` +- `sin(cos(a))` +- `sin(a+b)*cos(c)` + ## Credits: * This [article](https://llimllib.github.io/pymag-trees/) which helped me a lot introducing me to Knuth's algorithm for the layout of the tree. diff --git a/canvas.js b/canvas.js index adebbfe..9f9cd42 100644 --- a/canvas.js +++ b/canvas.js @@ -5,10 +5,11 @@ const SAMPLE_EXPRESSIONS = [ '(a + b)*c - (x - y)/z', '(a * b) - c + z / x', + 'sin(a)+cos(b)', + 'sin(a+b)*cos(c)', 'x - y + (c / (a + b))', '(a / y) + b - (c * x)', - '(a - b) * (c + d) / z', - '(a * b) - (x / y)' + '(a - b) * (c + d) / z' ] var canvas = document.querySelector('canvas') var c = canvas.getContext('2d') @@ -61,10 +62,11 @@ function displayErrorMessage() {
- You may only use these brackets ( ).
- Use * for multiplication and / for division.
+ - Supported unary functions: sin, cos, tan, log, ln, sqrt, exp, abs.
- Valid operators and operands are:
Operators: [+ - * / ]
- Operands: Any alphabetic letter. + Operands: Single letters a-z.
`, diff --git a/index.html b/index.html index 47fc397..3b7b178 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@

Expression Tree Generator

Current Expression: - +
@@ -46,4 +46,4 @@

Expression Tree Generator

- \ No newline at end of file + diff --git a/infixToPostfix.js b/infixToPostfix.js index 402a6f8..3e9c48a 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -17,14 +17,100 @@ function isBracketed(expr) { return false; } -function isValidExpression(expr) { - if (expr.length < 3) { - return false; +const VARIABLES = 'abcdefghijklmnopqrstuvwxyz' +const BINARY_OPERATORS = ['*', '/', '-', '+'] +const UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] + +function tokenize(expression) { + var tokens = [] + for (var i = 0; i < expression.length; i++) { + var current = expression[i] + if (current === ' ') { + continue + } + if (BINARY_OPERATORS.indexOf(current) !== -1 || current === '(' || current === ')') { + tokens.push(current) + continue + } + if (VARIABLES.indexOf(current) !== -1) { + var ident = current + while (i + 1 < expression.length && VARIABLES.indexOf(expression[i + 1]) !== -1) { + ident += expression[i + 1] + i++ + } + if (ident.length === 1) { + tokens.push(ident) + } else if (UNARY_FUNCTIONS.indexOf(ident) !== -1) { + tokens.push(ident) + } else { + return null + } + continue + } + return null } - for (var i = 0; i < expr.length; i++) { - if ('abcdefghijklmnopqrstuvwxyz*()/+-'.indexOf(expr[i]) === -1) { - return false; + return tokens +} + +function hasUnaryPlusOrMinus(node) { + if (!node || typeof node !== 'object') { + return false + } + if (node.type === 'OperatorNode' && (node.fn === 'unaryPlus' || node.fn === 'unaryMinus')) { + return true + } + if (node.args && node.args.length) { + for (var i = 0; i < node.args.length; i++) { + if (hasUnaryPlusOrMinus(node.args[i])) { + return true + } + } + } + if (node.content && hasUnaryPlusOrMinus(node.content)) { + return true + } + return false +} + +function isValidParsedNode(node) { + if (!node || typeof node !== 'object') { + return false + } + if (node.type === 'ParenthesisNode') { + return isValidParsedNode(node.content) + } + if (node.type === 'SymbolNode') { + return typeof node.name === 'string' && node.name.length === 1 && VARIABLES.indexOf(node.name) !== -1 + } + if (node.type === 'OperatorNode') { + if (node.implicit === true) { + return false + } + if (['add', 'subtract', 'multiply', 'divide'].indexOf(node.fn) === -1) { + return false + } + if (!node.args || node.args.length !== 2) { + return false } + return isValidParsedNode(node.args[0]) && isValidParsedNode(node.args[1]) + } + if (node.type === 'FunctionNode') { + var fnName = node.fn && node.fn.name + if (UNARY_FUNCTIONS.indexOf(fnName) === -1) { + return false + } + if (!node.args || node.args.length !== 1) { + return false + } + return isValidParsedNode(node.args[0]) + } + return false +} + +function isValidExpression(expr) { + var tokens = tokenize(expr) + if (null === tokens || tokens.length < 1) { + return false; } try { while ("(" === expr[0] && ")" === expr[expr.length - 1]) { @@ -33,7 +119,7 @@ function isValidExpression(expr) { } else break; } var res = math.parse(expr); - if ((typeof res.implicit === 'undefined') || res.fn.indexOf('unary') !== -1) { + if (hasUnaryPlusOrMinus(res) || !isValidParsedNode(res)) { return false; } return true; @@ -48,23 +134,38 @@ function infixToPostfix(expression) { return null; } const prec = { "*": 3, "/": 3, "-": 2, "+": 2, "(": 1 } - op_stack = [] - postfixList = [] - tokens = expression.split('') + var op_stack = [] + var postfixList = [] + var tokens = tokenize(expression) + if (null === tokens) { + return null + } for (const token of tokens) { - if ("abcdefghijklmnopqrstuvwxyz".indexOf(token) !== -1) { + if (VARIABLES.indexOf(token) !== -1 && token.length === 1) { postfixList.push(token) + } else if (UNARY_FUNCTIONS.indexOf(token) !== -1) { + op_stack.push(token) } else if ("(" === token) { op_stack.push(token) } else if (")" === token) { var top_op_token = op_stack.pop() while (top_op_token !== '(') { + if (typeof top_op_token === 'undefined') { + return null + } postfixList.push(top_op_token) top_op_token = op_stack.pop() } + var function_token = op_stack.slice(-1)[0] + if (UNARY_FUNCTIONS.indexOf(function_token) !== -1) { + postfixList.push(op_stack.pop()) + } } else { var peek_elem = op_stack.slice(-1)[0]; - while (op_stack.length > 0 && (prec[peek_elem] >= prec[token])) { + while ( + op_stack.length > 0 && + (UNARY_FUNCTIONS.indexOf(peek_elem) !== -1 || prec[peek_elem] >= prec[token]) + ) { postfixList.push(op_stack.pop()) peek_elem = op_stack.slice(-1)[0]; } @@ -72,7 +173,10 @@ function infixToPostfix(expression) { } } while (op_stack.length > 0) { + if (op_stack.slice(-1)[0] === '(') { + return null + } postfixList.push(op_stack.pop()) } return postfixList -} \ No newline at end of file +} diff --git a/tree.js b/tree.js index 2578aa9..f748ca2 100644 --- a/tree.js +++ b/tree.js @@ -1,6 +1,17 @@ -function Node(value) { - const radius = 32.5 +const TREE_BINARY_OPERATORS = ['*', '/', '-', '+'] +const TREE_UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] + +function isBinaryOperator(token) { + return TREE_BINARY_OPERATORS.includes(token) +} + +function isUnaryFunction(token) { + return TREE_UNARY_FUNCTIONS.includes(token) +} + +function Node(value, arity = 0) { this.value = value; + this.arity = arity; this.x = null; this.y = null; this.right = null; @@ -8,7 +19,14 @@ function Node(value) { this.isLeaf = () => this.right == null && this.left == null; + this.getRadius = function (context) { + context.font = '25px Times New Roman' + const textWidth = context.measureText(this.value).width + return Math.max(32.5, (textWidth / 2) + 10) + } + this.drawEdge = function (context, x, y, left_way, resolve) { + const radius = this.getRadius(context) context.strokeStyle = 'gray'; context.beginPath() const x_y_ratio = Math.abs(this.y - y) / Math.abs(this.x - x) @@ -22,6 +40,7 @@ function Node(value) { } this.draw = function (context) { + const radius = this.getRadius(context) context.beginPath() context.arc(this.x, this.y, radius, 0, Math.PI * 2, false) context.fillStyle = 'white' @@ -69,33 +88,35 @@ function drawEdgeAnimated(origin_x, origin_y, destine_x, destine_y, ctx, resolve } function constructTree(postfix) { - const OPERATORS = ['*', '/', '-', '+'] var stack = [] - var root = null; - var current; - var shift = false; - for (var i = postfix.length - 1; i >= 0; i--) { - if (null === root) { - current = new Node(postfix[i]); - root = current; - } else { - if (shift) { - current.left = new Node(postfix[i]) - current = current.left - shift = false - } else { - current.right = new Node(postfix[i]) - current = current.right + for (var i = 0; i < postfix.length; i++) { + var token = postfix[i] + if (isBinaryOperator(token)) { + if (stack.length < 2) { + throw new Error('Invalid postfix expression') } - } - if (OPERATORS.includes(postfix[i])) { - stack.push(current); + var rightNode = stack.pop() + var leftNode = stack.pop() + var binaryNode = new Node(token, 2) + binaryNode.left = leftNode + binaryNode.right = rightNode + stack.push(binaryNode) + } else if (isUnaryFunction(token)) { + if (stack.length < 1) { + throw new Error('Invalid postfix expression') + } + var childNode = stack.pop() + var unaryNode = new Node(token, 1) + unaryNode.right = childNode + stack.push(unaryNode) } else { - current = stack.pop(); - shift = true + stack.push(new Node(token, 0)) } } - return root; + if (stack.length !== 1) { + throw new Error('Invalid postfix expression') + } + return stack[0]; } function getSize(root) { @@ -131,6 +152,9 @@ function setCoordinates(root) { subt.y = 1.75 * OFFSET + (depth * 1.5 * OFFSET) i++ setCoordinates(subt.right, depth + 1) + if (null == subt.left && null != subt.right) { + subt.right.x = subt.x + } } } setCoordinates(root, 0) @@ -149,4 +173,4 @@ async function drawTree(root, context) { } drawTree(root.right, context) } -} \ No newline at end of file +} From a4bc34b277497f2ba54ae358b4233c8ef7adf38a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:53:08 +0000 Subject: [PATCH 03/30] chore: address validation feedback nits Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/1b9ad718-258f-4142-ba5d-146b7d889d15 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- infixToPostfix.js | 12 ++++++------ tree.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/infixToPostfix.js b/infixToPostfix.js index 3e9c48a..5535953 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -52,7 +52,7 @@ function tokenize(expression) { return tokens } -function hasUnaryPlusOrMinus(node) { +function containsUnaryPlusOrMinus(node) { if (!node || typeof node !== 'object') { return false } @@ -61,12 +61,12 @@ function hasUnaryPlusOrMinus(node) { } if (node.args && node.args.length) { for (var i = 0; i < node.args.length; i++) { - if (hasUnaryPlusOrMinus(node.args[i])) { + if (containsUnaryPlusOrMinus(node.args[i])) { return true } } } - if (node.content && hasUnaryPlusOrMinus(node.content)) { + if (node.content && containsUnaryPlusOrMinus(node.content)) { return true } return false @@ -109,7 +109,7 @@ function isValidParsedNode(node) { function isValidExpression(expr) { var tokens = tokenize(expr) - if (null === tokens || tokens.length < 1) { + if (tokens === null || tokens.length === 0) { return false; } try { @@ -119,7 +119,7 @@ function isValidExpression(expr) { } else break; } var res = math.parse(expr); - if (hasUnaryPlusOrMinus(res) || !isValidParsedNode(res)) { + if (containsUnaryPlusOrMinus(res) || !isValidParsedNode(res)) { return false; } return true; @@ -137,7 +137,7 @@ function infixToPostfix(expression) { var op_stack = [] var postfixList = [] var tokens = tokenize(expression) - if (null === tokens) { + if (tokens === null) { return null } for (const token of tokens) { diff --git a/tree.js b/tree.js index f748ca2..57a8543 100644 --- a/tree.js +++ b/tree.js @@ -152,7 +152,7 @@ function setCoordinates(root) { subt.y = 1.75 * OFFSET + (depth * 1.5 * OFFSET) i++ setCoordinates(subt.right, depth + 1) - if (null == subt.left && null != subt.right) { + if (subt.left === null && subt.right !== null) { subt.right.x = subt.x } } From 2a2c951b3e27b3563d5f16dbc0e06a0f11a32410 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:59:18 +0000 Subject: [PATCH 04/30] Initial plan From 8c979219c4169c7f64e376eae9e3bc2ef2664e8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:13:52 +0000 Subject: [PATCH 05/30] Add custom # operators with inferred arity up to 3 Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/b0a59eb7-ed41-437f-beb7-60d427c204bd Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- README.md | 8 ++ canvas.js | 6 +- index.html | 2 +- infixToPostfix.js | 195 ++++++++++++++++++++++++++++++++++++++++++++-- tree.js | 161 +++++++++++++++++++++++++------------- 5 files changed, 309 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index eb66e8d..303ed94 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Supported syntax includes: - Binary operators: `+`, `-`, `*`, `/` - Single-letter variables: `a`-`z` - Unary functions: `sin`, `cos`, `tan`, `log`, `ln`, `sqrt`, `exp`, `abs` +- Custom operators/functions: + - Arity 0: `#pi` + - Arity 1-3 (inferred): `#sign(a)`, `#add(a,b)`, `#clamp(x,a,b)` Examples: - `a+b` @@ -16,6 +19,11 @@ Examples: - `sin(a)+cos(b)` - `sin(cos(a))` - `sin(a+b)*cos(c)` +- `#pi` +- `#sign(a)` +- `#add(a,b)` +- `sin(#add(a,b))*#pi` +- `#clamp(x,a,b)` ## Credits: diff --git a/canvas.js b/canvas.js index 9f9cd42..fcf7153 100644 --- a/canvas.js +++ b/canvas.js @@ -7,6 +7,8 @@ '(a * b) - c + z / x', 'sin(a)+cos(b)', 'sin(a+b)*cos(c)', + 'sin(#add(a,b))*#pi', + '#clamp(x,#min(a,b),c)', 'x - y + (c / (a + b))', '(a / y) + b - (c * x)', '(a - b) * (c + d) / z' @@ -19,7 +21,6 @@ var expression = document.getElementById('expression-input').value if (typeof expression !== 'undefined' && null != expression) { expression = expression.replace(/\s+/g, '') - expression = expression.toLowerCase() var postfix = infixToPostfix(expression); if (null !== postfix) { try { @@ -63,10 +64,11 @@ function displayErrorMessage() { - You may only use these brackets ( ).
- Use * for multiplication and / for division.
- Supported unary functions: sin, cos, tan, log, ln, sqrt, exp, abs.
+ - Custom operators: #name, #name(arg), #name(arg1,arg2,arg3) (max arity 3).
- Valid operators and operands are:
Operators: [+ - * / ]
- Operands: Single letters a-z. + Operands: Single letters a-z and custom operators.
`, diff --git a/index.html b/index.html index 3b7b178..75a39a2 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@

Expression Tree Generator

Current Expression: - +
diff --git a/infixToPostfix.js b/infixToPostfix.js index 5535953..ba73301 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -20,6 +20,29 @@ function isBracketed(expr) { const VARIABLES = 'abcdefghijklmnopqrstuvwxyz' const BINARY_OPERATORS = ['*', '/', '-', '+'] const UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] +const CUSTOM_IDENTIFIER_START = /[A-Za-z_]/ +const CUSTOM_IDENTIFIER_BODY = /[A-Za-z0-9_]/ +const ALLOWED_CHARACTERS_REGEX = /^[A-Za-z0-9_#,+\-*/()\s]*$/ + +function isCustomToken(token) { + return token && typeof token === 'object' && token.type === 'custom' && typeof token.name === 'string' +} + +function createCustomToken(name, arity) { + var token = { type: 'custom', name: name } + if (typeof arity === 'number') { + token.arity = arity + } + return token +} + +function isFunctionToken(token) { + return UNARY_FUNCTIONS.indexOf(token) !== -1 || isCustomToken(token) +} + +function isVariableToken(token) { + return typeof token === 'string' && token.length === 1 && VARIABLES.indexOf(token) !== -1 +} function tokenize(expression) { var tokens = [] @@ -32,6 +55,23 @@ function tokenize(expression) { tokens.push(current) continue } + if (current === ',') { + tokens.push(current) + continue + } + if (current === '#') { + if (i + 1 >= expression.length || !CUSTOM_IDENTIFIER_START.test(expression[i + 1])) { + return null + } + var customName = expression[i + 1] + i++ + while (i + 1 < expression.length && CUSTOM_IDENTIFIER_BODY.test(expression[i + 1])) { + customName += expression[i + 1] + i++ + } + tokens.push(createCustomToken(customName)) + continue + } if (VARIABLES.indexOf(current) !== -1) { var ident = current while (i + 1 < expression.length && VARIABLES.indexOf(expression[i + 1]) !== -1) { @@ -52,6 +92,107 @@ function tokenize(expression) { return tokens } +function isStopToken(token, stopOnComma) { + return typeof token === 'undefined' || token === ')' || (stopOnComma && token === ',') +} + +function parsePrimary(tokens, index, stopOnComma) { + var token = tokens[index] + if (isStopToken(token, stopOnComma)) { + return null + } + if (isVariableToken(token)) { + return { nextIndex: index + 1 } + } + if (token === '(') { + var grouped = parseAddSubtract(tokens, index + 1, stopOnComma) + if (!grouped || tokens[grouped.nextIndex] !== ')') { + return null + } + return { nextIndex: grouped.nextIndex + 1 } + } + if (UNARY_FUNCTIONS.indexOf(token) !== -1) { + if (tokens[index + 1] !== '(') { + return null + } + var unaryArg = parseAddSubtract(tokens, index + 2, false) + if (!unaryArg || tokens[unaryArg.nextIndex] !== ')') { + return null + } + return { nextIndex: unaryArg.nextIndex + 1 } + } + if (isCustomToken(token)) { + if (tokens[index + 1] !== '(') { + return { nextIndex: index + 1 } + } + var argsIndex = index + 2 + if (tokens[argsIndex] === ')') { + return null + } + var arity = 0 + while (true) { + var arg = parseAddSubtract(tokens, argsIndex, true) + if (!arg) { + return null + } + arity++ + if (arity > 3) { + return null + } + argsIndex = arg.nextIndex + if (tokens[argsIndex] === ',') { + argsIndex++ + if (tokens[argsIndex] === ')' || typeof tokens[argsIndex] === 'undefined') { + return null + } + continue + } + if (tokens[argsIndex] === ')') { + return { nextIndex: argsIndex + 1 } + } + return null + } + } + return null +} + +function parseMultiplyDivide(tokens, index, stopOnComma) { + var left = parsePrimary(tokens, index, stopOnComma) + if (!left) { + return null + } + var i = left.nextIndex + while (tokens[i] === '*' || tokens[i] === '/') { + var right = parsePrimary(tokens, i + 1, stopOnComma) + if (!right) { + return null + } + i = right.nextIndex + } + return { nextIndex: i } +} + +function parseAddSubtract(tokens, index, stopOnComma) { + var left = parseMultiplyDivide(tokens, index, stopOnComma) + if (!left) { + return null + } + var i = left.nextIndex + while (tokens[i] === '+' || tokens[i] === '-') { + var right = parseMultiplyDivide(tokens, i + 1, stopOnComma) + if (!right) { + return null + } + i = right.nextIndex + } + return { nextIndex: i } +} + +function isValidCustomExpression(tokens) { + var parsed = parseAddSubtract(tokens, 0, false) + return !!parsed && parsed.nextIndex === tokens.length +} + function containsUnaryPlusOrMinus(node) { if (!node || typeof node !== 'object') { return false @@ -108,10 +249,17 @@ function isValidParsedNode(node) { } function isValidExpression(expr) { + if (!ALLOWED_CHARACTERS_REGEX.test(expr)) { + return false + } var tokens = tokenize(expr) if (tokens === null || tokens.length === 0) { return false; } + var hasCustom = tokens.some(function (token) { return isCustomToken(token) }) + if (hasCustom) { + return isValidCustomExpression(tokens) + } try { while ("(" === expr[0] && ")" === expr[expr.length - 1]) { if (isBracketed(expr)) { @@ -135,18 +283,43 @@ function infixToPostfix(expression) { } const prec = { "*": 3, "/": 3, "-": 2, "+": 2, "(": 1 } var op_stack = [] + var customCallStack = [] var postfixList = [] var tokens = tokenize(expression) if (tokens === null) { return null } - for (const token of tokens) { - if (VARIABLES.indexOf(token) !== -1 && token.length === 1) { + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i] + if (isVariableToken(token)) { postfixList.push(token) + } else if (isCustomToken(token)) { + if (tokens[i + 1] === '(') { + op_stack.push(token) + } else { + postfixList.push(createCustomToken(token.name, 0)) + } } else if (UNARY_FUNCTIONS.indexOf(token) !== -1) { op_stack.push(token) } else if ("(" === token) { + if (isCustomToken(tokens[i - 1])) { + customCallStack.push({ token: tokens[i - 1], commaCount: 0 }) + } op_stack.push(token) + } else if ("," === token) { + var commaTop = op_stack.pop() + while (commaTop !== '(') { + if (typeof commaTop === 'undefined') { + return null + } + postfixList.push(commaTop) + commaTop = op_stack.pop() + } + op_stack.push('(') + if (customCallStack.length === 0) { + return null + } + customCallStack[customCallStack.length - 1].commaCount++ } else if (")" === token) { var top_op_token = op_stack.pop() while (top_op_token !== '(') { @@ -157,14 +330,23 @@ function infixToPostfix(expression) { top_op_token = op_stack.pop() } var function_token = op_stack.slice(-1)[0] - if (UNARY_FUNCTIONS.indexOf(function_token) !== -1) { - postfixList.push(op_stack.pop()) + if (isFunctionToken(function_token)) { + var emittedFunction = op_stack.pop() + if (isCustomToken(emittedFunction)) { + var customFrame = customCallStack.pop() + if (!customFrame || customFrame.token !== emittedFunction) { + return null + } + postfixList.push(createCustomToken(emittedFunction.name, customFrame.commaCount + 1)) + } else { + postfixList.push(emittedFunction) + } } } else { var peek_elem = op_stack.slice(-1)[0]; while ( op_stack.length > 0 && - (UNARY_FUNCTIONS.indexOf(peek_elem) !== -1 || prec[peek_elem] >= prec[token]) + (isFunctionToken(peek_elem) || prec[peek_elem] >= prec[token]) ) { postfixList.push(op_stack.pop()) peek_elem = op_stack.slice(-1)[0]; @@ -176,6 +358,9 @@ function infixToPostfix(expression) { if (op_stack.slice(-1)[0] === '(') { return null } + if (isCustomToken(op_stack.slice(-1)[0])) { + return null + } postfixList.push(op_stack.pop()) } return postfixList diff --git a/tree.js b/tree.js index 57a8543..2bc781a 100644 --- a/tree.js +++ b/tree.js @@ -9,15 +9,51 @@ function isUnaryFunction(token) { return TREE_UNARY_FUNCTIONS.includes(token) } -function Node(value, arity = 0) { +function isCustomToken(token) { + return token && typeof token === 'object' && token.type === 'custom' && typeof token.name === 'string' +} + +function getTokenArity(token) { + if (isBinaryOperator(token)) { + return 2 + } + if (isUnaryFunction(token)) { + return 1 + } + if (isCustomToken(token)) { + return token.arity || 0 + } + return 0 +} + +function getTokenLabel(token) { + if (isCustomToken(token)) { + return '#' + token.name + } + return token +} + +function Node(value, arity = 0, children = []) { this.value = value; this.arity = arity; this.x = null; this.y = null; - this.right = null; - this.left = null; + this.children = children; + + Object.defineProperty(this, 'left', { + get: () => this.children[0] || null, + set: (node) => { + this.children[0] = node + } + }) + Object.defineProperty(this, 'right', { + get: () => this.children[1] || null, + set: (node) => { + this.children[1] = node + } + }) - this.isLeaf = () => this.right == null && this.left == null; + this.isLeaf = () => this.children.length === 0; this.getRadius = function (context) { context.font = '25px Times New Roman' @@ -25,18 +61,22 @@ function Node(value, arity = 0) { return Math.max(32.5, (textWidth / 2) + 10) } - this.drawEdge = function (context, x, y, left_way, resolve) { + this.drawEdge = function (context, childNode, resolve) { const radius = this.getRadius(context) + const childRadius = childNode.getRadius(context) context.strokeStyle = 'gray'; - context.beginPath() - const x_y_ratio = Math.abs(this.y - y) / Math.abs(this.x - x) - const w = radius * Math.sqrt(1 / (1 + Math.pow(x_y_ratio, 2))) - const d = x_y_ratio * w - if (left_way) { - drawEdgeAnimated(this.x - w, this.y + d, x + w, y - d, context, resolve) - } else { - drawEdgeAnimated(this.x + w, this.y + d, x - w, y - d, context, resolve) + const dx = childNode.x - this.x + const dy = childNode.y - this.y + const distance = Math.sqrt((dx * dx) + (dy * dy)) + if (distance === 0) { + resolve() + return } + const startX = this.x + (dx / distance) * radius + const startY = this.y + (dy / distance) * radius + const endX = childNode.x - (dx / distance) * childRadius + const endY = childNode.y - (dy / distance) * childRadius + drawEdgeAnimated(startX, startY, endX, endY, context, resolve) } this.draw = function (context) { @@ -91,26 +131,15 @@ function constructTree(postfix) { var stack = [] for (var i = 0; i < postfix.length; i++) { var token = postfix[i] - if (isBinaryOperator(token)) { - if (stack.length < 2) { - throw new Error('Invalid postfix expression') - } - var rightNode = stack.pop() - var leftNode = stack.pop() - var binaryNode = new Node(token, 2) - binaryNode.left = leftNode - binaryNode.right = rightNode - stack.push(binaryNode) - } else if (isUnaryFunction(token)) { - if (stack.length < 1) { + var arity = getTokenArity(token) + if (arity === 0) { + stack.push(new Node(getTokenLabel(token), 0, [])) + } else { + if (stack.length < arity) { throw new Error('Invalid postfix expression') } - var childNode = stack.pop() - var unaryNode = new Node(token, 1) - unaryNode.right = childNode - stack.push(unaryNode) - } else { - stack.push(new Node(token, 0)) + var children = stack.splice(stack.length - arity, arity) + stack.push(new Node(getTokenLabel(token), arity, children)) } } if (stack.length !== 1) { @@ -124,8 +153,9 @@ function getSize(root) { function countSize(root) { if (null != root) { size++; - countSize(root.left) - countSize(root.right) + for (var i = 0; i < root.children.length; i++) { + countSize(root.children[i]) + } } } countSize(root); @@ -134,43 +164,64 @@ function getSize(root) { function print_coords(root) { if (null != root) { - print_coords(root.left) console.log(root.value, root.x, root.y) - print_coords(root.right) + for (var i = 0; i < root.children.length; i++) { + print_coords(root.children[i]) + } } } function setCoordinates(root) { - var i = 0 const OFFSET = 50 - const size = getSize(root) + if (null == root) { + return + } + + function getLeafCount(node) { + if (node.children.length === 0) { + node.leafCount = 1 + return 1 + } + var leaves = 0 + for (var j = 0; j < node.children.length; j++) { + leaves += getLeafCount(node.children[j]) + } + node.leafCount = leaves + return leaves + } + + const size = getLeafCount(root) const canvas_mid_point = window.innerWidth / 2; - function setCoordinates(subt, depth) { - if (null != subt) { - setCoordinates(subt.left, depth + 1) - subt.x = canvas_mid_point + (OFFSET * (i - size / 2)) - subt.y = 1.75 * OFFSET + (depth * 1.5 * OFFSET) - i++ - setCoordinates(subt.right, depth + 1) - if (subt.left === null && subt.right !== null) { - subt.right.x = subt.x - } + function assignCoordinates(node, depth, startX, endX) { + node.x = (startX + endX) / 2 + node.y = 1.75 * OFFSET + (depth * 1.5 * OFFSET) + if (node.children.length === 0) { + return + } + var totalLeaves = 0 + for (var i = 0; i < node.children.length; i++) { + totalLeaves += node.children[i].leafCount + } + var currentX = startX + for (var childIndex = 0; childIndex < node.children.length; childIndex++) { + var child = node.children[childIndex] + var width = (endX - startX) * (child.leafCount / totalLeaves) + assignCoordinates(child, depth + 1, currentX, currentX + width) + currentX += width } } - setCoordinates(root, 0) + var totalWidth = size * OFFSET + assignCoordinates(root, 0, canvas_mid_point - (totalWidth / 2), canvas_mid_point + (totalWidth / 2)) } // must be async in order to await till animation is done async function drawTree(root, context) { if (null != root) { root.draw(context) - if (null != root.left) { - await new Promise(resolve => root.drawEdge(context, root.left.x, root.left.y, true, resolve)) - } - drawTree(root.left, context) - if (null != root.right) { - await new Promise(resolve => root.drawEdge(context, root.right.x, root.right.y, false, resolve)) + for (var i = 0; i < root.children.length; i++) { + var child = root.children[i] + await new Promise(resolve => root.drawEdge(context, child, resolve)) + await drawTree(child, context) } - drawTree(root.right, context) } } From b9bd5204ca9a9a3e102bd45d0e07a64d6a4c570a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:21:39 +0000 Subject: [PATCH 06/30] Address review nits and finalize custom operator support Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/b0a59eb7-ed41-437f-beb7-60d427c204bd Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- infixToPostfix.js | 12 ++++++------ tree.js | 21 +++++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/infixToPostfix.js b/infixToPostfix.js index ba73301..4e4077b 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -142,7 +142,7 @@ function parsePrimary(tokens, index, stopOnComma) { argsIndex = arg.nextIndex if (tokens[argsIndex] === ',') { argsIndex++ - if (tokens[argsIndex] === ')' || typeof tokens[argsIndex] === 'undefined') { + if (isStopToken(tokens[argsIndex], false)) { return null } continue @@ -256,8 +256,8 @@ function isValidExpression(expr) { if (tokens === null || tokens.length === 0) { return false; } - var hasCustom = tokens.some(function (token) { return isCustomToken(token) }) - if (hasCustom) { + var hasCustomToken = tokens.some(function (token) { return isCustomToken(token) }) + if (hasCustomToken) { return isValidCustomExpression(tokens) } try { @@ -302,8 +302,8 @@ function infixToPostfix(expression) { } else if (UNARY_FUNCTIONS.indexOf(token) !== -1) { op_stack.push(token) } else if ("(" === token) { - if (isCustomToken(tokens[i - 1])) { - customCallStack.push({ token: tokens[i - 1], commaCount: 0 }) + if (i > 0 && isCustomToken(tokens[i - 1])) { + customCallStack.push({ name: tokens[i - 1].name, commaCount: 0 }) } op_stack.push(token) } else if ("," === token) { @@ -334,7 +334,7 @@ function infixToPostfix(expression) { var emittedFunction = op_stack.pop() if (isCustomToken(emittedFunction)) { var customFrame = customCallStack.pop() - if (!customFrame || customFrame.token !== emittedFunction) { + if (!customFrame || customFrame.name !== emittedFunction.name) { return null } postfixList.push(createCustomToken(emittedFunction.name, customFrame.commaCount + 1)) diff --git a/tree.js b/tree.js index 2bc781a..1328565 100644 --- a/tree.js +++ b/tree.js @@ -33,23 +33,28 @@ function getTokenLabel(token) { return token } -function Node(value, arity = 0, children = []) { +function Node(value, arity = 0, children) { + var self = this this.value = value; this.arity = arity; this.x = null; this.y = null; - this.children = children; + this.children = children || []; Object.defineProperty(this, 'left', { - get: () => this.children[0] || null, - set: (node) => { - this.children[0] = node + get: function () { + return self.children[0] || null + }, + set: function (node) { + self.children[0] = node } }) Object.defineProperty(this, 'right', { - get: () => this.children[1] || null, - set: (node) => { - this.children[1] = node + get: function () { + return self.children[1] || null + }, + set: function (node) { + self.children[1] = node } }) From bf0c48cc83a69e843b2f206b86dd971100405142 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:28:04 +0000 Subject: [PATCH 07/30] Hide # prefix in node labels, fix font scaling, improve spacing Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/9641a83b-5b89-4e77-8331-bbb358b7a92e Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- tree.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tree.js b/tree.js index 1328565..e023ed6 100644 --- a/tree.js +++ b/tree.js @@ -28,7 +28,7 @@ function getTokenArity(token) { function getTokenLabel(token) { if (isCustomToken(token)) { - return '#' + token.name + return token.name } return token } @@ -61,9 +61,19 @@ function Node(value, arity = 0, children) { this.isLeaf = () => this.children.length === 0; this.getRadius = function (context) { - context.font = '25px Times New Roman' + return 32.5 + } + + this.getFontSize = function (context) { + const baseSize = 25 + const radius = this.getRadius(context) + const maxTextWidth = (radius - 6) * 2 + context.font = baseSize + 'px Times New Roman' const textWidth = context.measureText(this.value).width - return Math.max(32.5, (textWidth / 2) + 10) + if (textWidth <= maxTextWidth) { + return baseSize + } + return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) } this.drawEdge = function (context, childNode, resolve) { @@ -92,7 +102,8 @@ function Node(value, arity = 0, children) { context.fill() context.strokeStyle = '#212121' context.stroke() - context.font = '25px Times New Roman' + const fontSize = this.getFontSize(context) + context.font = fontSize + 'px Times New Roman' context.textAlign = 'center' context.textBaseline = 'middle' context.fillStyle = "#212121"; @@ -178,6 +189,7 @@ function print_coords(root) { function setCoordinates(root) { const OFFSET = 50 + const HORIZONTAL_SPACING = 80 if (null == root) { return } @@ -215,7 +227,7 @@ function setCoordinates(root) { currentX += width } } - var totalWidth = size * OFFSET + var totalWidth = size * HORIZONTAL_SPACING assignCoordinates(root, 0, canvas_mid_point - (totalWidth / 2), canvas_mid_point + (totalWidth / 2)) } From f1167f8e48a6b95a9f0d57f88154d2ae8032c6a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:41:54 +0000 Subject: [PATCH 08/30] Initial plan From d3dc64b6543f02eec562348ad8253022b9f3f3f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:48:41 +0000 Subject: [PATCH 09/30] feat: add export png control enter-to-generate and animation toggle Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/9d244446-6338-4b90-b8b0-d1dc770c4140 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- app.css | 12 +++++++++++- canvas.js | 31 ++++++++++++++++++++++++++++++- index.html | 12 ++++++++++++ tree.js | 18 +++++++++++++----- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app.css b/app.css index e697ba4..0e60644 100644 --- a/app.css +++ b/app.css @@ -82,12 +82,22 @@ flex-direction: row; justify-content: center; align-items: center; + flex-wrap: wrap; + row-gap: 10px; } .buttons > * { margin-right: 15px; } + .bg-mode-label, + .animate-toggle-label{ + color: #e0e0e0; + display: inline-flex; + align-items: center; + gap: 6px; + } + .contact-section button{ margin-bottom: 20px; outline: none; @@ -208,4 +218,4 @@ canvas{ border: 1px solid lightgray; width: 100%; height: 100%; -} \ No newline at end of file +} diff --git a/canvas.js b/canvas.js index fcf7153..0c54a65 100644 --- a/canvas.js +++ b/canvas.js @@ -16,6 +16,26 @@ var canvas = document.querySelector('canvas') var c = canvas.getContext('2d') function clearCanvas() { c.clearRect(0, 0, canvas.width, canvas.height) } + function exportCanvas() { + var bgMode = document.getElementById('export-bg-mode').value + var dataURL + if (bgMode === 'white') { + var exportCanvas = document.createElement('canvas') + var exportContext = exportCanvas.getContext('2d') + exportCanvas.width = canvas.width + exportCanvas.height = canvas.height + exportContext.fillStyle = '#ffffff' + exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height) + exportContext.drawImage(canvas, 0, 0) + dataURL = exportCanvas.toDataURL('image/png') + } else { + dataURL = canvas.toDataURL('image/png') + } + var link = document.createElement('a') + link.href = dataURL + link.download = 'expression-tree.png' + link.click() + } document.getElementById('generate-tree').addEventListener('click', () => { var expression = document.getElementById('expression-input').value @@ -29,7 +49,7 @@ clearCanvas() canvas.height = document.getElementById('canvas-container').offsetHeight; canvas.width = document.getElementById('canvas-container').offsetWidth; - drawTree(root, c) + drawTree(root, c, document.getElementById('animate-toggle').checked) } catch (e) { displayErrorMessage() } @@ -47,6 +67,15 @@ clearCanvas() }) + document.getElementById('export-tree').addEventListener('click', exportCanvas) + + document.getElementById('expression-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + document.getElementById('generate-tree').click() + } + }) + document.getElementById('expression-input').value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)] setTimeout(() => { document.getElementById('generate-tree').click() diff --git a/index.html b/index.html index 75a39a2..5a68624 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,18 @@

Expression Tree Generator

+ + +
diff --git a/tree.js b/tree.js index e023ed6..298087f 100644 --- a/tree.js +++ b/tree.js @@ -76,7 +76,7 @@ function Node(value, arity = 0, children) { return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) } - this.drawEdge = function (context, childNode, resolve) { + this.drawEdge = function (context, childNode, resolve, animate) { const radius = this.getRadius(context) const childRadius = childNode.getRadius(context) context.strokeStyle = 'gray'; @@ -91,7 +91,15 @@ function Node(value, arity = 0, children) { const startY = this.y + (dy / distance) * radius const endX = childNode.x - (dx / distance) * childRadius const endY = childNode.y - (dy / distance) * childRadius - drawEdgeAnimated(startX, startY, endX, endY, context, resolve) + if (animate) { + drawEdgeAnimated(startX, startY, endX, endY, context, resolve) + } else { + context.beginPath() + context.moveTo(startX, startY) + context.lineTo(endX, endY) + context.stroke() + resolve() + } } this.draw = function (context) { @@ -232,13 +240,13 @@ function setCoordinates(root) { } // must be async in order to await till animation is done -async function drawTree(root, context) { +async function drawTree(root, context, animate = true) { if (null != root) { root.draw(context) for (var i = 0; i < root.children.length; i++) { var child = root.children[i] - await new Promise(resolve => root.drawEdge(context, child, resolve)) - await drawTree(child, context) + await new Promise(resolve => root.drawEdge(context, child, resolve, animate)) + await drawTree(child, context, animate) } } } From 555323ede4ca8a6dcb20a04070b5ba94c4abcb41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:58:23 +0000 Subject: [PATCH 10/30] Initial plan From 7865c16b8042406ca82d3a28b273ce3ddc90de75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:05:25 +0000 Subject: [PATCH 11/30] feat: implement minimal realtime UI with dark mode and sync tree rendering Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/e008f843-b6be-49f2-867a-0a32bd59bc0a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- app.css | 332 +++++++++++++++++++++-------------------------------- canvas.js | 184 +++++++++++++++++------------ index.html | 91 ++++++++------- tree.js | 73 ++++-------- 4 files changed, 313 insertions(+), 367 deletions(-) diff --git a/app.css b/app.css index 0e60644..211a9af 100644 --- a/app.css +++ b/app.css @@ -1,221 +1,149 @@ -*{ +* { + box-sizing: border-box; margin: 0; padding: 0; - box-sizing: border-box; - font-family: avenir, 'Century Gothic'; - } - .contact-section{ - background-color: #828282; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2000 1500'%3E%3Cdefs%3E%3CradialGradient id='a' gradientUnits='objectBoundingBox'%3E%3Cstop offset='0' stop-color='%23ffffff'/%3E%3Cstop offset='1' stop-color='%23828282'/%3E%3C/radialGradient%3E%3ClinearGradient id='b' gradientUnits='userSpaceOnUse' x1='0' y1='750' x2='1550' y2='750'%3E%3Cstop offset='0' stop-color='%23c1c1c1'/%3E%3Cstop offset='1' stop-color='%23828282'/%3E%3C/linearGradient%3E%3Cpath id='s' fill='url(%23b)' d='M1549.2 51.6c-5.4 99.1-20.2 197.6-44.2 293.6c-24.1 96-57.4 189.4-99.3 278.6c-41.9 89.2-92.4 174.1-150.3 253.3c-58 79.2-123.4 152.6-195.1 219c-71.7 66.4-149.6 125.8-232.2 177.2c-82.7 51.4-170.1 94.7-260.7 129.1c-90.6 34.4-184.4 60-279.5 76.3C192.6 1495 96.1 1502 0 1500c96.1-2.1 191.8-13.3 285.4-33.6c93.6-20.2 185-49.5 272.5-87.2c87.6-37.7 171.3-83.8 249.6-137.3c78.4-53.5 151.5-114.5 217.9-181.7c66.5-67.2 126.4-140.7 178.6-218.9c52.3-78.3 96.9-161.4 133-247.9c36.1-86.5 63.8-176.2 82.6-267.6c18.8-91.4 28.6-184.4 29.6-277.4c0.3-27.6 23.2-48.7 50.8-48.4s49.5 21.8 49.2 49.5c0 0.7 0 1.3-0.1 2L1549.2 51.6z'/%3E%3Cg id='g'%3E%3Cuse href='%23s' transform='scale(0.12) rotate(60)'/%3E%3Cuse href='%23s' transform='scale(0.2) rotate(10)'/%3E%3Cuse href='%23s' transform='scale(0.25) rotate(40)'/%3E%3Cuse href='%23s' transform='scale(0.3) rotate(-20)'/%3E%3Cuse href='%23s' transform='scale(0.4) rotate(-30)'/%3E%3Cuse href='%23s' transform='scale(0.5) rotate(20)'/%3E%3Cuse href='%23s' transform='scale(0.6) rotate(60)'/%3E%3Cuse href='%23s' transform='scale(0.7) rotate(10)'/%3E%3Cuse href='%23s' transform='scale(0.835) rotate(-40)'/%3E%3Cuse href='%23s' transform='scale(0.9) rotate(40)'/%3E%3Cuse href='%23s' transform='scale(1.05) rotate(25)'/%3E%3Cuse href='%23s' transform='scale(1.2) rotate(8)'/%3E%3Cuse href='%23s' transform='scale(1.333) rotate(-60)'/%3E%3Cuse href='%23s' transform='scale(1.45) rotate(-30)'/%3E%3Cuse href='%23s' transform='scale(1.6) rotate(10)'/%3E%3C/g%3E%3C/defs%3E%3Cg transform='translate(2000 0)'%3E%3Cg transform='translate(0 405)'%3E%3Ccircle fill='url(%23a)' r='3000'/%3E%3Cg opacity='0.5'%3E%3Ccircle fill='url(%23a)' r='2000'/%3E%3Ccircle fill='url(%23a)' r='1800'/%3E%3Ccircle fill='url(%23a)' r='1700'/%3E%3Ccircle fill='url(%23a)' r='1651'/%3E%3Ccircle fill='url(%23a)' r='1450'/%3E%3Ccircle fill='url(%23a)' r='1250'/%3E%3Ccircle fill='url(%23a)' r='1175'/%3E%3Ccircle fill='url(%23a)' r='900'/%3E%3Ccircle fill='url(%23a)' r='750'/%3E%3Ccircle fill='url(%23a)' r='500'/%3E%3Ccircle fill='url(%23a)' r='380'/%3E%3Ccircle fill='url(%23a)' r='250'/%3E%3C/g%3E%3Cg transform='rotate(-201.6 0 0)'%3E%3Cuse href='%23g' transform='rotate(10)'/%3E%3Cuse href='%23g' transform='rotate(120)'/%3E%3Cuse href='%23g' transform='rotate(240)'/%3E%3C/g%3E%3Ccircle fill-opacity='0' fill='url(%23a)' r='3000'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); - background-attachment: fixed; - background-size: cover; - padding: 60px 0 40px 0; - text-align: center; - } - - .inner-input-container{ - text-align: left; - } - - .inner-input-container > span{ - position: relative; - left: 6px; - color: #e0e0e0; - font-size:20px; - } - - .github-link{ - position: absolute; - right: 35px; - top: 330px; - } - - .github-link > img{ - height: 70px; - background: white; - padding: 15px; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - transition: all 0.3s cubic-bezier(.25,.8,.25,1); - border-radius: 60px; - } - - .github-link > img:hover{ - box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); - } - - - - .inner-width{ - max-width: 600px; - margin: auto; - padding: 0 20px; - } - - .contact-section h1{ - font-size:30px; - color: #FFF; - margin-bottom: 40px; - text-transform: uppercase; - letter-spacing: 4px; - font-weight: 400; - } - - - .name,.email,.message{ - background: none; - border:none; - outline: none; - border-bottom: 1px solid; - color: #FFF; - padding: 10px 6px; - font-size: 24px; - margin-bottom: 40px; - } - - .name{ - float: center; - width: 540px; - } - - .buttons{ - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - flex-wrap: wrap; - row-gap: 10px; - } - - .buttons > * { - margin-right: 15px; - } - - .bg-mode-label, - .animate-toggle-label{ - color: #e0e0e0; - display: inline-flex; - align-items: center; - gap: 6px; - } - - .contact-section button{ - margin-bottom: 20px; - outline: none; - background: none; - color: #fafafa; - border: 1px solid #e0e0e0; - padding: 12px 40px; - border-radius: 8px; - text-transform: uppercase; - font-size: 14px; - transition: 0.4s linear; - cursor: pointer; - } - - .contact-section button:hover{ - background: #fafafa; - color: #212121; - } - - .ending-section{ - z-index: 10; - bottom: 0; - position: relative; - background: #001f3f; - padding: 80px 0; - text-align: center; - } - - .end-row{ - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - margin: auto; - color: white; - } - - .end-1-text{ - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - } - - .end-1-text h4{ - font-weight: 400; - padding: 0 0 .25rem 0; - } - - .end-1-text p{ - font-size: 10px; - width: 75%; - } - - .end-2-text p a{ - text-decoration: none; - color: #FFF; -} - -body{ - margin:0; -} - - .end-1{ - flex: 1; - } - - .end-2-text{ + font-family: avenir, 'Century Gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +:root { + --bg: #ffffff; + --fg: #212121; + --muted: #9e9e9e; + --node-fill: #ffffff; + --node-stroke: #212121; + --edge: #9e9e9e; + --input-border: #212121; +} + +[data-theme="dark"] { + --bg: #121212; + --fg: #e0e0e0; + --muted: #6b6b6b; + --node-fill: #1e1e1e; + --node-stroke: #e0e0e0; + --edge: #6b6b6b; + --input-border: #e0e0e0; +} + +html, +body { + width: 100%; + height: 100%; +} + +body { + background: var(--bg); + color: var(--fg); +} + +.app { + min-height: 100vh; display: flex; flex-direction: column; - justify-content: center; align-items: center; } -.end-2-text h4{ - padding: 0 0 .25rem 0; - font-weight: 400; +.controls { + width: min(720px, calc(100vw - 2rem)); + margin: 1.25rem auto 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.65rem; } -.end-2-text p{ - font-size: 10px; - width: 75%; +.input-wrap { + width: 100%; + position: relative; } -.end-2{ - flex: 1; -} - - @media screen and (max-width:700px){ - .contact-section{ - background: #001f3f; - } - } - - - @media screen and (max-width:600px){ - .name,.email{ - width: 100%; - } - } - -#canvas-container{ +#expression-input { + width: 100%; + border: 1px solid var(--input-border); + border-radius: 10px; + background: transparent; + color: var(--fg); + padding: 0.8rem 5rem 0.8rem 0.85rem; + font-size: 1rem; outline: none; +} + +#expression-input::placeholder { + color: var(--muted); +} + +.warning { + position: absolute; + right: 3rem; + top: 50%; + transform: translateY(-50%); + font-size: 1rem; + line-height: 1; +} + +.icon-btn { border: none; - display: flex; - flex-direction: column; + background: transparent; + color: var(--fg); + display: inline-flex; align-items: center; - margin: auto; - height: 100vh; - width: 100vw; - max-width: 100vw; - background: #FFF; - background-color: #ffffff; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='328' height='328' viewBox='0 0 800 800'%3E%3Cg fill='none' stroke='%23e8e8e8' stroke-width='1.7'%3E%3Cpath d='M769 229L1037 260.9M927 880L731 737 520 660 309 538 40 599 295 764 126.5 879.5 40 599-197 493 102 382-31 229 126.5 79.5-69-63'/%3E%3Cpath d='M-31 229L237 261 390 382 603 493 308.5 537.5 101.5 381.5M370 905L295 764'/%3E%3Cpath d='M520 660L578 842 731 737 840 599 603 493 520 660 295 764 309 538 390 382 539 269 769 229 577.5 41.5 370 105 295 -36 126.5 79.5 237 261 102 382 40 599 -69 737 127 880'/%3E%3Cpath d='M520-140L578.5 42.5 731-63M603 493L539 269 237 261 370 105M902 382L539 269M390 382L102 382'/%3E%3Cpath d='M-222 42L126.5 79.5 370 105 539 269 577.5 41.5 927 80 769 229 902 382 603 493 731 737M295-36L577.5 41.5M578 842L295 764M40-201L127 80M102 382L-261 269'/%3E%3C/g%3E%3Cg fill='%23d9d9d9'%3E%3Ccircle cx='769' cy='229' r='7'/%3E%3Ccircle cx='539' cy='269' r='7'/%3E%3Ccircle cx='603' cy='493' r='7'/%3E%3Ccircle cx='731' cy='737' r='7'/%3E%3Ccircle cx='520' cy='660' r='7'/%3E%3Ccircle cx='309' cy='538' r='7'/%3E%3Ccircle cx='295' cy='764' r='7'/%3E%3Ccircle cx='40' cy='599' r='7'/%3E%3Ccircle cx='102' cy='382' r='7'/%3E%3Ccircle cx='127' cy='80' r='7'/%3E%3Ccircle cx='370' cy='105' r='7'/%3E%3Ccircle cx='578' cy='42' r='7'/%3E%3Ccircle cx='237' cy='261' r='7'/%3E%3Ccircle cx='390' cy='382' r='7'/%3E%3C/g%3E%3C/svg%3E"); + justify-content: center; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s ease; +} + +.icon-btn:hover { + opacity: 1; +} + +.icon-btn svg { + width: 1.2rem; + height: 1.2rem; +} + +.input-icon { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + padding: 0.25rem; +} + +.save-btn { + padding: 0.25rem; +} + +#canvas-container { + flex: 1; + width: 100%; + min-height: 0; + padding: 0.75rem 1rem 0.25rem; } -canvas{ - border: 1px solid lightgray; +canvas { width: 100%; height: 100%; + display: block; +} + +.bottom-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.35rem 0 0.8rem; +} + +.theme-icon { + display: none; +} + +[data-theme="light"] .sun-icon, +:root:not([data-theme="dark"]) .sun-icon { + display: block; +} + +[data-theme="dark"] .moon-icon { + display: block; } diff --git a/canvas.js b/canvas.js index 0c54a65..efef66a 100644 --- a/canvas.js +++ b/canvas.js @@ -1,6 +1,3 @@ - - - (function () { const SAMPLE_EXPRESSIONS = [ '(a + b)*c - (x - y)/z', @@ -13,94 +10,133 @@ '(a / y) + b - (c * x)', '(a - b) * (c + d) / z' ] + const THEME_STORAGE_KEY = 'etg-theme' + const DEBOUNCE_MS = 150 + var canvas = document.querySelector('canvas') - var c = canvas.getContext('2d') - function clearCanvas() { c.clearRect(0, 0, canvas.width, canvas.height) } - function exportCanvas() { - var bgMode = document.getElementById('export-bg-mode').value - var dataURL - if (bgMode === 'white') { - var exportCanvas = document.createElement('canvas') - var exportContext = exportCanvas.getContext('2d') - exportCanvas.width = canvas.width - exportCanvas.height = canvas.height - exportContext.fillStyle = '#ffffff' - exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height) - exportContext.drawImage(canvas, 0, 0) - dataURL = exportCanvas.toDataURL('image/png') - } else { - dataURL = canvas.toDataURL('image/png') + var context = canvas.getContext('2d') + var container = document.getElementById('canvas-container') + var input = document.getElementById('expression-input') + var warning = document.getElementById('warning') + var themeToggle = document.getElementById('theme-toggle') + + var debounceId = null + var currentRoot = null + + function resizeCanvas() { + canvas.height = container.offsetHeight + canvas.width = container.offsetWidth + } + + function clearCanvas() { + context.clearRect(0, 0, canvas.width, canvas.height) + } + + function setWarningVisible(isVisible) { + warning.hidden = !isVisible + } + + function renderRoot(root) { + clearCanvas() + if (!root) { + return + } + setCoordinates(root) + drawTree(root, context) + } + + function renderExpression() { + var expression = input.value + if (typeof expression === 'undefined' || expression === null) { + setWarningVisible(false) + return + } + + expression = expression.replace(/\s+/g, '') + if (expression.length === 0) { + currentRoot = null + setWarningVisible(false) + clearCanvas() + return + } + + try { + var postfix = infixToPostfix(expression) + if (postfix === null) { + setWarningVisible(true) + return + } + var root = constructTree(postfix) + currentRoot = root + setWarningVisible(false) + renderRoot(root) + } catch (error) { + setWarningVisible(true) } + } + + function debouncedRender() { + clearTimeout(debounceId) + debounceId = setTimeout(renderExpression, DEBOUNCE_MS) + } + + function exportCanvas() { var link = document.createElement('a') - link.href = dataURL + link.href = canvas.toDataURL('image/png') link.download = 'expression-tree.png' link.click() } - document.getElementById('generate-tree').addEventListener('click', () => { - var expression = document.getElementById('expression-input').value - if (typeof expression !== 'undefined' && null != expression) { - expression = expression.replace(/\s+/g, '') - var postfix = infixToPostfix(expression); - if (null !== postfix) { - try { - var root = constructTree(postfix) - setCoordinates(root) - clearCanvas() - canvas.height = document.getElementById('canvas-container').offsetHeight; - canvas.width = document.getElementById('canvas-container').offsetWidth; - drawTree(root, c, document.getElementById('animate-toggle').checked) - } catch (e) { - displayErrorMessage() - } - } else { - displayErrorMessage() - } + function getPreferredTheme() { + var storedTheme = localStorage.getItem(THEME_STORAGE_KEY) + if (storedTheme === 'light' || storedTheme === 'dark') { + return storedTheme + } + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + + function applyTheme(theme, shouldPersist) { + document.documentElement.setAttribute('data-theme', theme) + if (shouldPersist) { + localStorage.setItem(THEME_STORAGE_KEY, theme) + } + if (currentRoot) { + renderRoot(currentRoot) + } else { + clearCanvas() + } + } + resizeCanvas() + + window.addEventListener('resize', function () { + resizeCanvas() + if (currentRoot) { + renderRoot(currentRoot) } else { - displayErrorMessage() + clearCanvas() } }) - document.getElementById('clear-tree').addEventListener('click', () => { - document.getElementById('expression-input').value = '' + input.addEventListener('input', debouncedRender) + + document.getElementById('clear-tree').addEventListener('click', function () { + input.value = '' + currentRoot = null + setWarningVisible(false) clearCanvas() + input.focus() }) document.getElementById('export-tree').addEventListener('click', exportCanvas) - document.getElementById('expression-input').addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault() - document.getElementById('generate-tree').click() - } + themeToggle.addEventListener('click', function () { + var nextTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark' + applyTheme(nextTheme, true) }) - document.getElementById('expression-input').value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)] - setTimeout(() => { - document.getElementById('generate-tree').click() - }, 500) -})(); - - + applyTheme(getPreferredTheme(), false) -function displayErrorMessage() { - Swal.fire({ - icon: 'error', - title: 'Invalid expression', - html: ` -
- - You may only use these brackets ( ).
- - Use * for multiplication and / for division.
- - Supported unary functions: sin, cos, tan, log, ln, sqrt, exp, abs.
- - Custom operators: #name, #name(arg), #name(arg1,arg2,arg3) (max arity 3).
- - Valid operators and operands are:
-
- Operators: [+ - * / ]
- Operands: Single letters a-z and custom operators. -
-
- `, - footer: 'Learn more' - }) -} + input.value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)] + renderExpression() +})(); diff --git a/index.html b/index.html index 5a68624..a316584 100644 --- a/index.html +++ b/index.html @@ -5,57 +5,70 @@ Expression Tree Generator - - - - -
-
-

Expression Tree Generator

-
- Current Expression: - +
+
+
+ + +
-
- - - - - -
+ +
+
+
-
- -
- -
- - - +
+ + + + +
+ + + + + - - - - - diff --git a/tree.js b/tree.js index 298087f..ec23b51 100644 --- a/tree.js +++ b/tree.js @@ -76,81 +76,51 @@ function Node(value, arity = 0, children) { return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) } - this.drawEdge = function (context, childNode, resolve, animate) { + this.drawEdge = function (context, childNode) { + const styles = getComputedStyle(document.documentElement) const radius = this.getRadius(context) const childRadius = childNode.getRadius(context) - context.strokeStyle = 'gray'; + const edgeColor = styles.getPropertyValue('--edge').trim() || '#9e9e9e' + context.strokeStyle = edgeColor const dx = childNode.x - this.x const dy = childNode.y - this.y const distance = Math.sqrt((dx * dx) + (dy * dy)) if (distance === 0) { - resolve() return } const startX = this.x + (dx / distance) * radius const startY = this.y + (dy / distance) * radius const endX = childNode.x - (dx / distance) * childRadius const endY = childNode.y - (dy / distance) * childRadius - if (animate) { - drawEdgeAnimated(startX, startY, endX, endY, context, resolve) - } else { - context.beginPath() - context.moveTo(startX, startY) - context.lineTo(endX, endY) - context.stroke() - resolve() - } + context.beginPath() + context.moveTo(startX, startY) + context.lineTo(endX, endY) + context.stroke() } this.draw = function (context) { + const styles = getComputedStyle(document.documentElement) const radius = this.getRadius(context) + const nodeFill = styles.getPropertyValue('--node-fill').trim() || '#ffffff' + const nodeStroke = styles.getPropertyValue('--node-stroke').trim() || '#212121' + const textColor = styles.getPropertyValue('--fg').trim() || '#212121' + context.beginPath() context.arc(this.x, this.y, radius, 0, Math.PI * 2, false) - context.fillStyle = 'white' + context.fillStyle = nodeFill context.fill() - context.strokeStyle = '#212121' + context.strokeStyle = nodeStroke context.stroke() + const fontSize = this.getFontSize(context) context.font = fontSize + 'px Times New Roman' context.textAlign = 'center' context.textBaseline = 'middle' - context.fillStyle = "#212121"; - context.fillText(this.value, this.x, this.y); + context.fillStyle = textColor + context.fillText(this.value, this.x, this.y) } } -function drawEdgeAnimated(origin_x, origin_y, destine_x, destine_y, ctx, resolve) { - const vertices = [{ x: origin_x, y: origin_y }, { x: destine_x, y: destine_y }] - const N = 35; - var waypoints = []; - for (var i = 1; i < vertices.length; i++) { - var pt0 = vertices[i - 1]; - var pt1 = vertices[i]; - var dx = pt1.x - pt0.x; - var dy = pt1.y - pt0.y; - for (var j = 0; j <= N; j++) { - var x = pt0.x + dx * j / N; - var y = pt0.y + dy * j / N; - waypoints.push({ x: x, y: y }); - } - } - var t = 1 - function resolveCallback(callback) { - function animate() { - if (t < waypoints.length - 1) { requestAnimationFrame(animate) } - else { callback() } - ctx.beginPath(); - ctx.moveTo(waypoints[t - 1].x, waypoints[t - 1].y); - ctx.lineTo(waypoints[t].x, waypoints[t].y); - ctx.stroke(); - t++; - } - return animate - } - - requestAnimationFrame(resolveCallback(resolve)) -} - function constructTree(postfix) { var stack = [] for (var i = 0; i < postfix.length; i++) { @@ -239,14 +209,13 @@ function setCoordinates(root) { assignCoordinates(root, 0, canvas_mid_point - (totalWidth / 2), canvas_mid_point + (totalWidth / 2)) } -// must be async in order to await till animation is done -async function drawTree(root, context, animate = true) { +function drawTree(root, context) { if (null != root) { root.draw(context) for (var i = 0; i < root.children.length; i++) { var child = root.children[i] - await new Promise(resolve => root.drawEdge(context, child, resolve, animate)) - await drawTree(child, context, animate) + root.drawEdge(context, child) + drawTree(child, context) } } } From 66531e345bd9fd56cc0d30daea7a85fd1d7bf894 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:07:26 +0000 Subject: [PATCH 12/30] chore: add SRI for mathjs CDN script Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/e008f843-b6be-49f2-867a-0a32bd59bc0a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index a316584..1a1c6fc 100644 --- a/index.html +++ b/index.html @@ -65,7 +65,7 @@ - + From 2cefffe2e67c9131102f303fda3d3bff0ed3df44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:18:05 +0000 Subject: [PATCH 13/30] Initial plan From 193ddfe0f85330ae9fb7b767d5b3f59b3b4bc006 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:26:48 +0000 Subject: [PATCH 14/30] Fix parser validation and add export popover with PNG/SVG options Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/83c7ae30-dbfc-4592-97f7-63dc5bddc7f3 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- app.css | 98 +++++++++++++++++++++++++++++++--- canvas.js | 132 ++++++++++++++++++++++++++++++++++++++++++++-- index.html | 34 +++++++++--- infixToPostfix.js | 96 +-------------------------------- tree.js | 30 +++++++---- 5 files changed, 266 insertions(+), 124 deletions(-) diff --git a/app.css b/app.css index 211a9af..68a7e5f 100644 --- a/app.css +++ b/app.css @@ -34,22 +34,25 @@ body { body { background: var(--bg); color: var(--fg); + display: flex; + flex-direction: column; } .app { - min-height: 100vh; + flex: 1; display: flex; flex-direction: column; - align-items: center; + position: relative; } .controls { width: min(720px, calc(100vw - 2rem)); - margin: 1.25rem auto 0; + margin: auto; display: flex; flex-direction: column; align-items: center; gap: 0.65rem; + z-index: 1; } .input-wrap { @@ -115,10 +118,12 @@ body { } #canvas-container { - flex: 1; + position: fixed; + inset: 0; width: 100%; - min-height: 0; - padding: 0.75rem 1rem 0.25rem; + height: 100%; + padding: 0; + z-index: 0; } canvas { @@ -128,11 +133,15 @@ canvas { } .bottom-bar { + position: fixed; + bottom: 0.8rem; + left: 50%; + transform: translateX(-50%); display: flex; align-items: center; justify-content: center; gap: 0.75rem; - padding: 0.35rem 0 0.8rem; + z-index: 1; } .theme-icon { @@ -147,3 +156,78 @@ canvas { [data-theme="dark"] .moon-icon { display: block; } + +.export-popover { + position: relative; +} + +.export-popover summary { + list-style: none; +} + +.export-popover summary::-webkit-details-marker { + display: none; +} + +.export-panel { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + width: 180px; + border: 1px solid var(--muted); + border-radius: 12px; + background: var(--bg); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.15); + padding: 0.75rem; + gap: 0.6rem; +} + +.export-popover:not([open]) .export-panel { + display: none; +} + +.export-popover[open] .export-panel { + display: grid; +} + +.export-fieldset { + border: 0; + padding: 0; + margin: 0; + display: grid; + gap: 0.35rem; +} + +.export-fieldset legend { + font-size: 0.8rem; + margin-bottom: 0.2rem; +} + +.scale-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.9rem; +} + +#png-scale { + border: 1px solid var(--muted); + border-radius: 8px; + background: var(--bg); + color: var(--fg); + padding: 0.2rem 0.35rem; +} + +.download-btn { + border: 1px solid var(--muted); + border-radius: 9px; + background: transparent; + color: var(--fg); + padding: 0.35rem 0.5rem; + cursor: pointer; +} + +.download-btn:hover { + opacity: 0.9; +} diff --git a/canvas.js b/canvas.js index efef66a..e19c736 100644 --- a/canvas.js +++ b/canvas.js @@ -19,6 +19,11 @@ var input = document.getElementById('expression-input') var warning = document.getElementById('warning') var themeToggle = document.getElementById('theme-toggle') + var exportPopover = document.getElementById('export-popover') + var pngScaleRow = document.getElementById('png-scale-row') + var pngScale = document.getElementById('png-scale') + var formatInputs = document.querySelectorAll('input[name="export-format"]') + var downloadExport = document.getElementById('download-export') var debounceId = null var currentRoot = null @@ -80,13 +85,111 @@ debounceId = setTimeout(renderExpression, DEBOUNCE_MS) } - function exportCanvas() { + function triggerDownload(url, filename) { var link = document.createElement('a') - link.href = canvas.toDataURL('image/png') - link.download = 'expression-tree.png' + link.href = url + link.download = filename link.click() } + function exportPng(scale) { + if (!currentRoot) { + return + } + var exportScale = Math.max(1, scale || 1) + var offscreen = document.createElement('canvas') + offscreen.width = canvas.width * exportScale + offscreen.height = canvas.height * exportScale + var offscreenContext = offscreen.getContext('2d') + offscreenContext.scale(exportScale, exportScale) + drawTree(currentRoot, offscreenContext) + triggerDownload(offscreen.toDataURL('image/png'), 'expression-tree.png') + } + + function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function getThemeColors() { + var styles = getComputedStyle(document.documentElement) + return { + nodeFill: styles.getPropertyValue('--node-fill').trim() || '#ffffff', + nodeStroke: styles.getPropertyValue('--node-stroke').trim() || '#212121', + edge: styles.getPropertyValue('--edge').trim() || '#9e9e9e', + text: styles.getPropertyValue('--fg').trim() || '#212121' + } + } + + function treeToSvg(root) { + if (!root) { + return '' + } + var measureCanvas = document.createElement('canvas') + var measureContext = measureCanvas.getContext('2d') + var radius = getNodeRadius() + var colors = getThemeColors() + var lines = [] + var nodes = [] + + function walk(node) { + for (var i = 0; i < node.children.length; i++) { + var child = node.children[i] + var dx = child.x - node.x + var dy = child.y - node.y + var distance = Math.sqrt((dx * dx) + (dy * dy)) + if (distance !== 0) { + var startX = node.x + (dx / distance) * radius + var startY = node.y + (dy / distance) * radius + var endX = child.x - (dx / distance) * radius + var endY = child.y - (dy / distance) * radius + lines.push('') + } + walk(child) + } + var fontSize = getNodeFontSize(node.value, measureContext) + nodes.push('' + escapeXml(node.value) + '') + } + + walk(root) + return '' + + '' + + '' + lines.join('') + '' + + nodes.join('') + + '' + } + + function exportSvg() { + if (!currentRoot) { + return + } + var svgString = treeToSvg(currentRoot) + var blob = new Blob([svgString], { type: 'image/svg+xml' }) + var url = URL.createObjectURL(blob) + triggerDownload(url, 'expression-tree.svg') + setTimeout(function () { + URL.revokeObjectURL(url) + }, 0) + } + + function getSelectedExportFormat() { + for (var i = 0; i < formatInputs.length; i++) { + if (formatInputs[i].checked) { + return formatInputs[i].value + } + } + return 'png' + } + + function syncExportOptionsUi() { + var isPng = getSelectedExportFormat() === 'png' + pngScaleRow.hidden = !isPng + } + function getPreferredTheme() { var storedTheme = localStorage.getItem(THEME_STORAGE_KEY) if (storedTheme === 'light' || storedTheme === 'dark') { @@ -128,7 +231,27 @@ input.focus() }) - document.getElementById('export-tree').addEventListener('click', exportCanvas) + for (var formatIndex = 0; formatIndex < formatInputs.length; formatIndex++) { + formatInputs[formatIndex].addEventListener('change', syncExportOptionsUi) + } + + downloadExport.addEventListener('click', function () { + if (getSelectedExportFormat() === 'svg') { + exportSvg() + } else { + exportPng(parseInt(pngScale.value, 10) || 1) + } + exportPopover.open = false + }) + + document.addEventListener('click', function (event) { + if (!exportPopover.open) { + return + } + if (!exportPopover.contains(event.target)) { + exportPopover.open = false + } + }) themeToggle.addEventListener('click', function () { var nextTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark' @@ -136,6 +259,7 @@ }) applyTheme(getPreferredTheme(), false) + syncExportOptionsUi() input.value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)] renderExpression() diff --git a/index.html b/index.html index 1a1c6fc..d17da49 100644 --- a/index.html +++ b/index.html @@ -27,13 +27,32 @@
- +
+ + + +
+
+ Format + + +
+ + +
+
@@ -65,7 +84,6 @@ - diff --git a/infixToPostfix.js b/infixToPostfix.js index 4e4077b..7fca6b8 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -1,22 +1,3 @@ -function isBracketed(expr) { - var stack = [] - for (var i = 0; i < expr.length; i++) { - if ('(' === expr[i]) { - stack.push('(') - } else if (')' === expr[i]) { - stack.pop() - if (stack.length === 0) { - if (i !== expr.length - 1) { - return false - } else { - return true - } - } - } - } - return false; -} - const VARIABLES = 'abcdefghijklmnopqrstuvwxyz' const BINARY_OPERATORS = ['*', '/', '-', '+'] const UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] @@ -188,66 +169,11 @@ function parseAddSubtract(tokens, index, stopOnComma) { return { nextIndex: i } } -function isValidCustomExpression(tokens) { +function isValidTokens(tokens) { var parsed = parseAddSubtract(tokens, 0, false) return !!parsed && parsed.nextIndex === tokens.length } -function containsUnaryPlusOrMinus(node) { - if (!node || typeof node !== 'object') { - return false - } - if (node.type === 'OperatorNode' && (node.fn === 'unaryPlus' || node.fn === 'unaryMinus')) { - return true - } - if (node.args && node.args.length) { - for (var i = 0; i < node.args.length; i++) { - if (containsUnaryPlusOrMinus(node.args[i])) { - return true - } - } - } - if (node.content && containsUnaryPlusOrMinus(node.content)) { - return true - } - return false -} - -function isValidParsedNode(node) { - if (!node || typeof node !== 'object') { - return false - } - if (node.type === 'ParenthesisNode') { - return isValidParsedNode(node.content) - } - if (node.type === 'SymbolNode') { - return typeof node.name === 'string' && node.name.length === 1 && VARIABLES.indexOf(node.name) !== -1 - } - if (node.type === 'OperatorNode') { - if (node.implicit === true) { - return false - } - if (['add', 'subtract', 'multiply', 'divide'].indexOf(node.fn) === -1) { - return false - } - if (!node.args || node.args.length !== 2) { - return false - } - return isValidParsedNode(node.args[0]) && isValidParsedNode(node.args[1]) - } - if (node.type === 'FunctionNode') { - var fnName = node.fn && node.fn.name - if (UNARY_FUNCTIONS.indexOf(fnName) === -1) { - return false - } - if (!node.args || node.args.length !== 1) { - return false - } - return isValidParsedNode(node.args[0]) - } - return false -} - function isValidExpression(expr) { if (!ALLOWED_CHARACTERS_REGEX.test(expr)) { return false @@ -256,25 +182,7 @@ function isValidExpression(expr) { if (tokens === null || tokens.length === 0) { return false; } - var hasCustomToken = tokens.some(function (token) { return isCustomToken(token) }) - if (hasCustomToken) { - return isValidCustomExpression(tokens) - } - try { - while ("(" === expr[0] && ")" === expr[expr.length - 1]) { - if (isBracketed(expr)) { - expr = expr.substring(1, expr.length - 1); - } else break; - } - var res = math.parse(expr); - if (containsUnaryPlusOrMinus(res) || !isValidParsedNode(res)) { - return false; - } - return true; - } - catch (ex) { - return false; - } + return isValidTokens(tokens) } function infixToPostfix(expression) { diff --git a/tree.js b/tree.js index ec23b51..ade6273 100644 --- a/tree.js +++ b/tree.js @@ -33,6 +33,22 @@ function getTokenLabel(token) { return token } +function getNodeRadius() { + return 32.5 +} + +function getNodeFontSize(value, context) { + const baseSize = 25 + const radius = getNodeRadius() + const maxTextWidth = (radius - 6) * 2 + context.font = baseSize + 'px Times New Roman' + const textWidth = context.measureText(value).width + if (textWidth <= maxTextWidth) { + return baseSize + } + return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) +} + function Node(value, arity = 0, children) { var self = this this.value = value; @@ -60,20 +76,12 @@ function Node(value, arity = 0, children) { this.isLeaf = () => this.children.length === 0; - this.getRadius = function (context) { - return 32.5 + this.getRadius = function () { + return getNodeRadius() } this.getFontSize = function (context) { - const baseSize = 25 - const radius = this.getRadius(context) - const maxTextWidth = (radius - 6) * 2 - context.font = baseSize + 'px Times New Roman' - const textWidth = context.measureText(this.value).width - if (textWidth <= maxTextWidth) { - return baseSize - } - return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) + return getNodeFontSize(this.value, context) } this.drawEdge = function (context, childNode) { From 84b0c94c471a4ef03b54f6cb216ad5d304bc2e19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:28:03 +0000 Subject: [PATCH 15/30] Ensure SVG blob URL persists long enough for download Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/83c7ae30-dbfc-4592-97f7-63dc5bddc7f3 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canvas.js b/canvas.js index e19c736..529c882 100644 --- a/canvas.js +++ b/canvas.js @@ -173,7 +173,7 @@ triggerDownload(url, 'expression-tree.svg') setTimeout(function () { URL.revokeObjectURL(url) - }, 0) + }, 1000) } function getSelectedExportFormat() { From 0a74059bacf409f820f18a28da94acc16799d8b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:13:05 +0000 Subject: [PATCH 16/30] Surface parser errors and display them between input and canvas Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/b76f2e13-c151-4ff7-b4e5-11c4f21e04aa Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- app.css | 19 +++++++++++ canvas.js | 35 ++++++++++++++----- index.html | 3 ++ infixToPostfix.js | 87 ++++++++++++++++++++++++++++++++++------------- 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/app.css b/app.css index 68a7e5f..b9b3da6 100644 --- a/app.css +++ b/app.css @@ -55,6 +55,25 @@ body { z-index: 1; } +.error-message { + width: 100%; + padding: 0.55rem 0.85rem; + border: 1px solid #e53935; + border-radius: 10px; + background: rgba(229, 57, 53, 0.08); + color: #e53935; + font-size: 0.9rem; + line-height: 1.3; + text-align: center; + word-break: break-word; +} + +[data-theme="dark"] .error-message { + color: #ff6b68; + border-color: #ff6b68; + background: rgba(255, 107, 104, 0.1); +} + .input-wrap { width: 100%; position: relative; diff --git a/canvas.js b/canvas.js index 529c882..9ad927a 100644 --- a/canvas.js +++ b/canvas.js @@ -18,6 +18,7 @@ var container = document.getElementById('canvas-container') var input = document.getElementById('expression-input') var warning = document.getElementById('warning') + var errorMessage = document.getElementById('error-message') var themeToggle = document.getElementById('theme-toggle') var exportPopover = document.getElementById('export-popover') var pngScaleRow = document.getElementById('png-scale-row') @@ -41,6 +42,26 @@ warning.hidden = !isVisible } + function setErrorMessage(message) { + if (message) { + errorMessage.textContent = message + errorMessage.hidden = false + } else { + errorMessage.textContent = '' + errorMessage.hidden = true + } + } + + function showError(message) { + setWarningVisible(true) + setErrorMessage(message) + } + + function clearError() { + setWarningVisible(false) + setErrorMessage('') + } + function renderRoot(root) { clearCanvas() if (!root) { @@ -53,30 +74,26 @@ function renderExpression() { var expression = input.value if (typeof expression === 'undefined' || expression === null) { - setWarningVisible(false) + clearError() return } expression = expression.replace(/\s+/g, '') if (expression.length === 0) { currentRoot = null - setWarningVisible(false) + clearError() clearCanvas() return } try { var postfix = infixToPostfix(expression) - if (postfix === null) { - setWarningVisible(true) - return - } var root = constructTree(postfix) currentRoot = root - setWarningVisible(false) + clearError() renderRoot(root) } catch (error) { - setWarningVisible(true) + showError((error && error.message) ? error.message : 'Invalid expression') } } @@ -226,7 +243,7 @@ document.getElementById('clear-tree').addEventListener('click', function () { input.value = '' currentRoot = null - setWarningVisible(false) + clearError() clearCanvas() input.focus() }) diff --git a/index.html b/index.html index d17da49..1e1b17c 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,8 @@
+ +
+
diff --git a/infixToPostfix.js b/infixToPostfix.js index 7fca6b8..e356435 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -42,7 +42,7 @@ function tokenize(expression) { } if (current === '#') { if (i + 1 >= expression.length || !CUSTOM_IDENTIFIER_START.test(expression[i + 1])) { - return null + throw new Error("Expected an identifier after '#'") } var customName = expression[i + 1] i++ @@ -64,11 +64,11 @@ function tokenize(expression) { } else if (UNARY_FUNCTIONS.indexOf(ident) !== -1) { tokens.push(ident) } else { - return null + throw new Error("Unknown identifier '" + ident + "' (use '#" + ident + "' for a custom function)") } continue } - return null + throw new Error("Invalid character '" + current + "' in expression") } return tokens } @@ -174,29 +174,69 @@ function isValidTokens(tokens) { return !!parsed && parsed.nextIndex === tokens.length } +function describeToken(token) { + if (typeof token === 'undefined') { + return 'end of expression' + } + if (isCustomToken(token)) { + return "'#" + token.name + "'" + } + return "'" + token + "'" +} + +function validateTokens(tokens) { + var depth = 0 + for (var k = 0; k < tokens.length; k++) { + if (tokens[k] === '(') { + depth++ + } else if (tokens[k] === ')') { + depth-- + if (depth < 0) { + throw new Error("Mismatched ')' with no matching '('") + } + } + } + if (depth > 0) { + throw new Error("Missing closing ')'") + } + var parsed = parseAddSubtract(tokens, 0, false) + if (!parsed) { + throw new Error('Incomplete or malformed expression') + } + if (parsed.nextIndex !== tokens.length) { + throw new Error('Unexpected token ' + describeToken(tokens[parsed.nextIndex]) + ' at position ' + parsed.nextIndex) + } +} + function isValidExpression(expr) { - if (!ALLOWED_CHARACTERS_REGEX.test(expr)) { + try { + if (!ALLOWED_CHARACTERS_REGEX.test(expr)) { + return false + } + var tokens = tokenize(expr) + if (tokens === null || tokens.length === 0) { + return false; + } + return isValidTokens(tokens) + } catch (e) { return false } - var tokens = tokenize(expr) - if (tokens === null || tokens.length === 0) { - return false; - } - return isValidTokens(tokens) } function infixToPostfix(expression) { - if (!isValidExpression(expression)) { - return null; + if (!ALLOWED_CHARACTERS_REGEX.test(expression)) { + var badMatch = expression.match(/[^A-Za-z0-9_#,+\-*/()\s]/) + throw new Error("Invalid character '" + (badMatch ? badMatch[0] : '?') + "' in expression") + } + var tokens = tokenize(expression) + if (tokens.length === 0) { + throw new Error('Empty expression') } + validateTokens(tokens) const prec = { "*": 3, "/": 3, "-": 2, "+": 2, "(": 1 } var op_stack = [] var customCallStack = [] var postfixList = [] - var tokens = tokenize(expression) - if (tokens === null) { - return null - } for (var i = 0; i < tokens.length; i++) { var token = tokens[i] if (isVariableToken(token)) { @@ -218,21 +258,21 @@ function infixToPostfix(expression) { var commaTop = op_stack.pop() while (commaTop !== '(') { if (typeof commaTop === 'undefined') { - return null + throw new Error("Unexpected ',' outside of a function call") } postfixList.push(commaTop) commaTop = op_stack.pop() } op_stack.push('(') if (customCallStack.length === 0) { - return null + throw new Error("Unexpected ',' outside of a function call") } customCallStack[customCallStack.length - 1].commaCount++ } else if (")" === token) { var top_op_token = op_stack.pop() while (top_op_token !== '(') { if (typeof top_op_token === 'undefined') { - return null + throw new Error("Mismatched ')' with no matching '('") } postfixList.push(top_op_token) top_op_token = op_stack.pop() @@ -243,7 +283,7 @@ function infixToPostfix(expression) { if (isCustomToken(emittedFunction)) { var customFrame = customCallStack.pop() if (!customFrame || customFrame.name !== emittedFunction.name) { - return null + throw new Error("Mismatched custom function call for '#" + emittedFunction.name + "'") } postfixList.push(createCustomToken(emittedFunction.name, customFrame.commaCount + 1)) } else { @@ -263,11 +303,12 @@ function infixToPostfix(expression) { } } while (op_stack.length > 0) { - if (op_stack.slice(-1)[0] === '(') { - return null + var tail = op_stack.slice(-1)[0] + if (tail === '(') { + throw new Error("Missing closing ')'") } - if (isCustomToken(op_stack.slice(-1)[0])) { - return null + if (isCustomToken(tail)) { + throw new Error("Missing closing ')' for '#" + tail.name + "'") } postfixList.push(op_stack.pop()) } From 2a9c4d9af3e1ba4add157485a4ff6b16c9fc37d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:14:50 +0000 Subject: [PATCH 17/30] Remove now-unused isValidExpression/isValidTokens helpers Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/b76f2e13-c151-4ff7-b4e5-11c4f21e04aa Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- infixToPostfix.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/infixToPostfix.js b/infixToPostfix.js index e356435..64400ae 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -169,11 +169,6 @@ function parseAddSubtract(tokens, index, stopOnComma) { return { nextIndex: i } } -function isValidTokens(tokens) { - var parsed = parseAddSubtract(tokens, 0, false) - return !!parsed && parsed.nextIndex === tokens.length -} - function describeToken(token) { if (typeof token === 'undefined') { return 'end of expression' @@ -208,21 +203,6 @@ function validateTokens(tokens) { } } -function isValidExpression(expr) { - try { - if (!ALLOWED_CHARACTERS_REGEX.test(expr)) { - return false - } - var tokens = tokenize(expr) - if (tokens === null || tokens.length === 0) { - return false; - } - return isValidTokens(tokens) - } catch (e) { - return false - } -} - function infixToPostfix(expression) { if (!ALLOWED_CHARACTERS_REGEX.test(expression)) { var badMatch = expression.match(/[^A-Za-z0-9_#,+\-*/()\s]/) From 176a07dfa80aff4664d7557ea17e2bc47061388a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:39:36 +0000 Subject: [PATCH 18/30] Render node labels as LaTeX via KaTeX with graceful fallback Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/ee4d85e1-eab5-46a5-8afd-e949b3a8f90a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- README.md | 29 ++++++ canvas.js | 112 ++++++++++++++++---- index.html | 8 +- tree.js | 293 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 410 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 303ed94..1248046 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,35 @@ Examples: - `sin(#add(a,b))*#pi` - `#clamp(x,a,b)` +## LaTeX-rendered node labels + +Node labels are typeset with [KaTeX](https://katex.org/) (loaded from a CDN, +so no build step is required) for both the interactive canvas and the +exported PNG / SVG files. A few conventions make common symbols render +nicely out of the box: + +- **Operators**: `*` renders as `·` (`\cdot`), `/` as `÷` (`\div`); `+` + and `-` are left as-is. +- **Unary functions**: `sin`, `cos`, `tan`, `log`, `ln`, `exp` render in + upright math style; `sqrt` shows a radical (`√`) and `abs` shows + `|·|`. +- **Custom token symbols**: well-known names are mapped to their LaTeX + command, e.g. `#pi` → π, `#theta` → θ, `#alpha` → α, `#beta` → β, + `#gamma` → γ, `#lambda` → λ, `#mu` → μ, `#sigma` → σ, `#phi` → φ, + `#omega` → ω (plus uppercase variants like `#Gamma`, `#Delta`, + `#Sigma`, `#Omega`, …), `#infty` / `#infinity` → ∞, `#sum` → ∑, + `#prod` → ∏, `#int` → ∫, `#nabla` → ∇, `#partial` → ∂, + `#emptyset` → ∅. +- **Subscripts**: identifiers ending in digits are rendered with the + digits as a subscript — `x1` → *x*₁, `#theta12` → θ₁₂. An underscore + also introduces a subscript — `#a_foo` → *a*foo. +- **Unknown custom names** fall back to upright `\mathrm{name}` so they + remain legible without looking like variable products. + +If KaTeX fails to load or a label cannot be parsed, the node falls back +to rendering the raw token text, so expressions will always be +displayed. + ## Credits: * This [article](https://llimllib.github.io/pymag-trees/) which helped me a lot introducing me to Knuth's algorithm for the layout of the tree. diff --git a/canvas.js b/canvas.js index 9ad927a..80c2e9a 100644 --- a/canvas.js +++ b/canvas.js @@ -83,6 +83,7 @@ currentRoot = null clearError() clearCanvas() + updateCanvasAccessibility('') return } @@ -92,6 +93,7 @@ currentRoot = root clearError() renderRoot(root) + updateCanvasAccessibility(expression) } catch (error) { showError((error && error.message) ? error.message : 'Invalid expression') } @@ -114,13 +116,19 @@ return } var exportScale = Math.max(1, scale || 1) - var offscreen = document.createElement('canvas') - offscreen.width = canvas.width * exportScale - offscreen.height = canvas.height * exportScale - var offscreenContext = offscreen.getContext('2d') - offscreenContext.scale(exportScale, exportScale) - drawTree(currentRoot, offscreenContext) - triggerDownload(offscreen.toDataURL('image/png'), 'expression-tree.png') + var colors = getThemeColors() + var warmPromise = (typeof warmLatexCache === 'function') + ? warmLatexCache(currentRoot, colors.text) + : Promise.resolve() + warmPromise.then(function () { + var offscreen = document.createElement('canvas') + offscreen.width = canvas.width * exportScale + offscreen.height = canvas.height * exportScale + var offscreenContext = offscreen.getContext('2d') + offscreenContext.scale(exportScale, exportScale) + drawTree(currentRoot, offscreenContext) + triggerDownload(offscreen.toDataURL('image/png'), 'expression-tree.png') + }) } function escapeXml(value) { @@ -142,12 +150,10 @@ } } - function treeToSvg(root) { + function treeToSvg(root, cssText) { if (!root) { return '' } - var measureCanvas = document.createElement('canvas') - var measureContext = measureCanvas.getContext('2d') var radius = getNodeRadius() var colors = getThemeColors() var lines = [] @@ -168,29 +174,73 @@ } walk(child) } - var fontSize = getNodeFontSize(node.value, measureContext) - nodes.push('' + escapeXml(node.value) + '') + + var nodeSvg = '' + + // Try to embed the KaTeX-rendered HTML as a foreignObject so the + // exported SVG stays vector and editable. Fall back to a plain + // element with the raw label if KaTeX is unavailable. + var html = (typeof renderLatexToHtml === 'function') ? renderLatexToHtml(node.latex) : null + if (html) { + var metrics = measureLatexHtml(html, colors.text) + var maxDim = (radius - 6) * 2 + var scale = Math.min(1, maxDim / metrics.width, maxDim / metrics.height) + var drawW = metrics.width * scale + var drawH = metrics.height * scale + var foX = node.x - drawW / 2 + var foY = node.y - drawH / 2 + nodeSvg += '' + + '
' + + '' + html + '
' + } else { + var fontSize = getNodeFontSize(node.rawLabel, measureContextForSvg()) + nodeSvg += '' + + escapeXml(node.rawLabel) + '' + } + nodeSvg += '
' + nodes.push(nodeSvg) } walk(root) + var styleBlock = cssText ? '' : '' return '' + - '' + + '' + + styleBlock + '' + lines.join('') + '' + nodes.join('') + '' } + var svgMeasureCanvas = null + function measureContextForSvg() { + if (!svgMeasureCanvas) { + svgMeasureCanvas = document.createElement('canvas') + } + return svgMeasureCanvas.getContext('2d') + } + function exportSvg() { if (!currentRoot) { return } - var svgString = treeToSvg(currentRoot) - var blob = new Blob([svgString], { type: 'image/svg+xml' }) - var url = URL.createObjectURL(blob) - triggerDownload(url, 'expression-tree.svg') - setTimeout(function () { - URL.revokeObjectURL(url) - }, 1000) + var cssPromise = (typeof getKatexCss === 'function') ? getKatexCss() : Promise.resolve('') + cssPromise.then(function (cssText) { + var svgString = treeToSvg(currentRoot, cssText) + var blob = new Blob([svgString], { type: 'image/svg+xml' }) + var url = URL.createObjectURL(blob) + triggerDownload(url, 'expression-tree.svg') + setTimeout(function () { + URL.revokeObjectURL(url) + }, 1000) + }) } function getSelectedExportFormat() { @@ -220,6 +270,10 @@ if (shouldPersist) { localStorage.setItem(THEME_STORAGE_KEY, theme) } + // Invalidate the rasterized LaTeX cache so labels re-render in the new --fg color. + if (typeof invalidateLatexCache === 'function') { + invalidateLatexCache() + } if (currentRoot) { renderRoot(currentRoot) } else { @@ -227,6 +281,23 @@ } } + if (typeof setLatexRerenderHandler === 'function') { + setLatexRerenderHandler(function () { + if (currentRoot) { + renderRoot(currentRoot) + } + }) + } + + function updateCanvasAccessibility(expression) { + if (expression) { + canvas.setAttribute('role', 'img') + canvas.setAttribute('aria-label', 'Expression tree for ' + expression) + } else { + canvas.removeAttribute('aria-label') + } + } + resizeCanvas() window.addEventListener('resize', function () { @@ -245,6 +316,7 @@ currentRoot = null clearError() clearCanvas() + updateCanvasAccessibility('') input.focus() }) diff --git a/index.html b/index.html index 1e1b17c..5856642 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,8 @@ + + @@ -87,9 +89,9 @@ - - - + + + diff --git a/tree.js b/tree.js index ade6273..24eafde 100644 --- a/tree.js +++ b/tree.js @@ -1,6 +1,102 @@ const TREE_BINARY_OPERATORS = ['*', '/', '-', '+'] const TREE_UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] +// Names recognized as math symbols / operators for custom tokens. +// Keys are the raw identifier (e.g. '#pi' -> 'pi'), values are LaTeX source. +const LATEX_SYMBOL_MAP = { + // Greek (lowercase) + 'alpha': '\\alpha', 'beta': '\\beta', 'gamma': '\\gamma', 'delta': '\\delta', + 'epsilon': '\\epsilon', 'varepsilon': '\\varepsilon', 'zeta': '\\zeta', 'eta': '\\eta', + 'theta': '\\theta', 'vartheta': '\\vartheta', 'iota': '\\iota', 'kappa': '\\kappa', + 'lambda': '\\lambda', 'mu': '\\mu', 'nu': '\\nu', 'xi': '\\xi', 'omicron': 'o', + 'pi': '\\pi', 'varpi': '\\varpi', 'rho': '\\rho', 'varrho': '\\varrho', + 'sigma': '\\sigma', 'varsigma': '\\varsigma', 'tau': '\\tau', 'upsilon': '\\upsilon', + 'phi': '\\phi', 'varphi': '\\varphi', 'chi': '\\chi', 'psi': '\\psi', 'omega': '\\omega', + // Greek (uppercase) + 'Gamma': '\\Gamma', 'Delta': '\\Delta', 'Theta': '\\Theta', 'Lambda': '\\Lambda', + 'Xi': '\\Xi', 'Pi': '\\Pi', 'Sigma': '\\Sigma', 'Upsilon': '\\Upsilon', + 'Phi': '\\Phi', 'Psi': '\\Psi', 'Omega': '\\Omega', + // Common constants / operators + 'infty': '\\infty', 'infinity': '\\infty', + 'sum': '\\sum', 'prod': '\\prod', 'int': '\\int', + 'nabla': '\\nabla', 'partial': '\\partial', + 'emptyset': '\\emptyset' +} + +// Escape a plain string so it is safe inside LaTeX \text{...}. +function escapeLatexText(value) { + return String(value) + .replace(/\\/g, '\\textbackslash{}') + .replace(/([&%$#_{}])/g, '\\$1') + .replace(/\^/g, '\\^{}') + .replace(/~/g, '\\~{}') +} + +// Convert an identifier like "x1", "a_2", "theta12" into LaTeX with a trailing subscript. +// Returns an object { latex, handled } where `handled` is false if the identifier +// should fall through to the default mapping. +function identifierToLatex(name) { + if (typeof name !== 'string' || name.length === 0) { + return { latex: '', handled: false } + } + // Explicit underscore split: everything after the first `_` becomes the subscript. + var underscoreIdx = name.indexOf('_') + if (underscoreIdx > 0 && underscoreIdx < name.length - 1) { + var base = name.slice(0, underscoreIdx) + var sub = name.slice(underscoreIdx + 1) + var baseLatex = baseIdentifierToLatex(base) + return { latex: baseLatex + '_{' + escapeLatexText(sub) + '}', handled: true } + } + // Trailing digits: "x1" -> x_{1}, "theta12" -> \theta_{12}. + var match = name.match(/^([A-Za-z]+)(\d+)$/) + if (match) { + var baseLatex2 = baseIdentifierToLatex(match[1]) + return { latex: baseLatex2 + '_{' + match[2] + '}', handled: true } + } + return { latex: baseIdentifierToLatex(name), handled: true } +} + +// Map a bare alphabetic identifier (no digits, no underscores) to LaTeX: +// - single letter -> as-is (rendered italic in math mode) +// - known symbol name -> symbol command (e.g. "pi" -> "\pi") +// - anything else -> \mathrm{name} +function baseIdentifierToLatex(name) { + if (name.length === 1) { + return name + } + if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOL_MAP, name)) { + return LATEX_SYMBOL_MAP[name] + } + return '\\mathrm{' + escapeLatexText(name) + '}' +} + +// Produce the LaTeX source that should be rendered inside a node for the given token. +function getTokenLatex(token) { + if (isCustomToken(token)) { + var info = identifierToLatex(token.name) + return info.latex || '\\text{' + escapeLatexText(token.name) + '}' + } + if (isBinaryOperator(token)) { + if (token === '*') { return '\\cdot' } + if (token === '/') { return '\\div' } + return token + } + if (isUnaryFunction(token)) { + if (token === 'sqrt') { return '\\sqrt{\\;}' } + if (token === 'abs') { return '\\lvert\\,\\cdot\\,\\rvert' } + // sin, cos, tan, log, ln, exp all have \-prefixed LaTeX commands. + return '\\' + token + } + if (typeof token === 'string') { + var ident = identifierToLatex(token) + if (ident.handled) { + return ident.latex + } + return '\\text{' + escapeLatexText(token) + '}' + } + return '\\text{' + escapeLatexText(String(token)) + '}' +} + function isBinaryOperator(token) { return TREE_BINARY_OPERATORS.includes(token) } @@ -49,10 +145,172 @@ function getNodeFontSize(value, context) { return Math.max(8, Math.floor(baseSize * (maxTextWidth / textWidth))) } -function Node(value, arity = 0, children) { +// --------------------------------------------------------------------------- +// LaTeX label rasterization cache +// --------------------------------------------------------------------------- +// The canvas 2D API cannot render LaTeX directly, so we rasterize each +// (latex, color) pair via KaTeX -> HTML -> SVG -> . +// The cache is keyed by "|" so theme switches (which change +// --fg) transparently produce a new rendering. +// --------------------------------------------------------------------------- + +const KATEX_VERSION = '0.16.11' +const KATEX_FONT_BASE = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/' +const KATEX_CSS_URL = KATEX_FONT_BASE + 'katex.min.css' +const LATEX_BASE_FONT_SIZE = 25 + +var latexImageCache = new Map() +var latexRerenderHandler = null +var katexCssPromise = null + +function setLatexRerenderHandler(handler) { + latexRerenderHandler = handler +} + +function invalidateLatexCache() { + latexImageCache = new Map() +} + +// Fetch the KaTeX stylesheet so rasterized images and exported SVGs render +// correctly (font files, spacing, radicals, etc.). The fetch is lazy and +// shared across all callers. Font URLs are rewritten to absolute so they +// resolve from inside data: URLs and downloaded SVGs. +function getKatexCss() { + if (!katexCssPromise) { + if (typeof fetch !== 'function') { + katexCssPromise = Promise.resolve('') + } else { + katexCssPromise = fetch(KATEX_CSS_URL) + .then(function (response) { + return response.ok ? response.text() : '' + }) + .then(function (css) { + return css.replace(/url\(fonts\//g, 'url(' + KATEX_FONT_BASE + 'fonts/') + }) + .catch(function () { return '' }) + } + } + return katexCssPromise +} + +function renderLatexToHtml(latex) { + if (typeof katex === 'undefined' || !katex || typeof katex.renderToString !== 'function') { + return null + } + try { + return katex.renderToString(latex, { throwOnError: false, output: 'html', displayMode: false }) + } catch (error) { + return null + } +} + +function measureLatexHtml(html, color) { + var measure = document.createElement('div') + measure.setAttribute('aria-hidden', 'true') + measure.style.cssText = + 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;' + + 'color:' + color + ';font-size:' + LATEX_BASE_FONT_SIZE + 'px;' + + 'line-height:1.2;display:inline-block;white-space:nowrap;' + measure.innerHTML = html + document.body.appendChild(measure) + var rect = measure.getBoundingClientRect() + document.body.removeChild(measure) + return { + width: Math.max(1, Math.ceil(rect.width)), + height: Math.max(1, Math.ceil(rect.height)) + } +} + +function buildLatexSvg(html, width, height, color, cssText) { + var style = cssText ? '' : '' + return '' + + style + + '' + + '
' + html + '
' + + '
' +} + +// Returns a cache entry: { ready, image, width, height }. +// The entry object is returned synchronously; when the underlying image +// finishes loading, `ready` flips to true and the registered rerender +// handler is invoked so the node can be redrawn. +function getLatexImage(latex, color) { + var key = latex + '|' + color + var cached = latexImageCache.get(key) + if (cached) { return cached } + + var entry = { ready: false, image: null, width: 0, height: 0 } + latexImageCache.set(key, entry) + + var html = renderLatexToHtml(latex) + if (html === null) { + // KaTeX unavailable / failed: leave entry unready so the caller + // falls back to plain text. Retry once KaTeX loads. + if (typeof window !== 'undefined' && typeof katex === 'undefined') { + window.addEventListener('load', function () { + if (latexImageCache.get(key) === entry && !entry.ready) { + latexImageCache.delete(key) + if (latexRerenderHandler) { latexRerenderHandler() } + } + }, { once: true }) + } + return entry + } + + var size = measureLatexHtml(html, color) + entry.width = size.width + entry.height = size.height + + getKatexCss().then(function (cssText) { + var svg = buildLatexSvg(html, size.width, size.height, color, cssText) + var image = new Image() + image.onload = function () { + entry.image = image + entry.ready = true + if (latexRerenderHandler) { latexRerenderHandler() } + } + image.onerror = function () { + // Leave entry unready so callers fall back to plain text. + } + image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + }) + + return entry +} + +// Warm the cache for every label in the given tree and resolve once all +// images have loaded (or failed). Used before PNG export so the offscreen +// draw has the rasters ready. +function warmLatexCache(root, color) { + var promises = [] + function walk(node) { + if (!node) { return } + var entry = getLatexImage(node.latex, color) + if (!entry.ready && entry.width > 0) { + promises.push(new Promise(function (resolve) { + var check = function () { + if (entry.ready) { resolve() } + else { setTimeout(check, 30) } + } + // Cap the wait so a broken image never blocks export forever. + setTimeout(resolve, 2000) + check() + })) + } + for (var i = 0; i < node.children.length; i++) { walk(node.children[i]) } + } + walk(root) + return Promise.all(promises) +} + +function Node(value, arity = 0, children, latex, rawLabel) { var self = this this.value = value; this.arity = arity; + this.latex = (typeof latex === 'string' && latex.length > 0) ? latex : ('\\text{' + escapeLatexText(String(value)) + '}') + this.rawLabel = (typeof rawLabel === 'string') ? rawLabel : String(value) this.x = null; this.y = null; this.children = children || []; @@ -106,6 +364,15 @@ function Node(value, arity = 0, children) { context.stroke() } + this.drawFallbackLabel = function (context, textColor) { + const fontSize = this.getFontSize(context) + context.font = fontSize + 'px Times New Roman' + context.textAlign = 'center' + context.textBaseline = 'middle' + context.fillStyle = textColor + context.fillText(this.rawLabel, this.x, this.y) + } + this.draw = function (context) { const styles = getComputedStyle(document.documentElement) const radius = this.getRadius(context) @@ -120,12 +387,18 @@ function Node(value, arity = 0, children) { context.strokeStyle = nodeStroke context.stroke() - const fontSize = this.getFontSize(context) - context.font = fontSize + 'px Times New Roman' - context.textAlign = 'center' - context.textBaseline = 'middle' - context.fillStyle = textColor - context.fillText(this.value, this.x, this.y) + var entry = getLatexImage(this.latex, textColor) + if (entry && entry.ready && entry.image) { + const maxDim = (radius - 6) * 2 + var w = entry.width + var h = entry.height + var scale = Math.min(1, maxDim / w, maxDim / h) + var drawW = w * scale + var drawH = h * scale + context.drawImage(entry.image, this.x - drawW / 2, this.y - drawH / 2, drawW, drawH) + } else { + this.drawFallbackLabel(context, textColor) + } } } @@ -134,14 +407,16 @@ function constructTree(postfix) { for (var i = 0; i < postfix.length; i++) { var token = postfix[i] var arity = getTokenArity(token) + var label = getTokenLabel(token) + var latex = getTokenLatex(token) if (arity === 0) { - stack.push(new Node(getTokenLabel(token), 0, [])) + stack.push(new Node(label, 0, [], latex, label)) } else { if (stack.length < arity) { throw new Error('Invalid postfix expression') } var children = stack.splice(stack.length - arity, arity) - stack.push(new Node(getTokenLabel(token), arity, children)) + stack.push(new Node(label, arity, children, latex, label)) } } if (stack.length !== 1) { From 6b5a433e0527552248659a2575c8938ecd9ec528 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:42:23 +0000 Subject: [PATCH 19/30] Address review: single-pass LaTeX escape, cleaner warm-cache + a11y Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/ee4d85e1-eab5-46a5-8afd-e949b3a8f90a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 1 + tree.js | 52 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/canvas.js b/canvas.js index 80c2e9a..92ec939 100644 --- a/canvas.js +++ b/canvas.js @@ -294,6 +294,7 @@ canvas.setAttribute('role', 'img') canvas.setAttribute('aria-label', 'Expression tree for ' + expression) } else { + canvas.removeAttribute('role') canvas.removeAttribute('aria-label') } } diff --git a/tree.js b/tree.js index 24eafde..ddcb379 100644 --- a/tree.js +++ b/tree.js @@ -24,12 +24,25 @@ const LATEX_SYMBOL_MAP = { } // Escape a plain string so it is safe inside LaTeX \text{...}. +// Done in a single pass so substitutions cannot re-escape each other +// (e.g. the braces produced by \textbackslash{} must not be treated as +// further `{`/`}` occurrences in the input). function escapeLatexText(value) { - return String(value) - .replace(/\\/g, '\\textbackslash{}') - .replace(/([&%$#_{}])/g, '\\$1') - .replace(/\^/g, '\\^{}') - .replace(/~/g, '\\~{}') + var replacements = { + '\\': '\\textbackslash{}', + '&': '\\&', + '%': '\\%', + '$': '\\$', + '#': '\\#', + '_': '\\_', + '{': '\\{', + '}': '\\}', + '^': '\\^{}', + '~': '\\~{}' + } + return String(value == null ? '' : value).replace(/[\\&%$#_{}^~]/g, function (ch) { + return replacements[ch] + }) } // Convert an identifier like "x1", "a_2", "theta12" into LaTeX with a trailing subscript. @@ -247,14 +260,21 @@ function getLatexImage(latex, color) { var html = renderLatexToHtml(latex) if (html === null) { // KaTeX unavailable / failed: leave entry unready so the caller - // falls back to plain text. Retry once KaTeX loads. + // falls back to plain text. Retry once KaTeX becomes available. if (typeof window !== 'undefined' && typeof katex === 'undefined') { - window.addEventListener('load', function () { + var retry = function () { if (latexImageCache.get(key) === entry && !entry.ready) { latexImageCache.delete(key) if (latexRerenderHandler) { latexRerenderHandler() } } - }, { once: true }) + } + if (typeof document !== 'undefined' && document.readyState === 'complete') { + // `load` has already fired; try again on the next tick so the + // CDN script (if still loading) has a chance to finish. + setTimeout(retry, 0) + } else { + window.addEventListener('load', retry, { once: true }) + } } return entry } @@ -290,12 +310,22 @@ function warmLatexCache(root, color) { var entry = getLatexImage(node.latex, color) if (!entry.ready && entry.width > 0) { promises.push(new Promise(function (resolve) { + var resolved = false + var pollId = 0 + var timeoutId = 0 + var finish = function () { + if (resolved) { return } + resolved = true + if (pollId) { clearTimeout(pollId) } + if (timeoutId) { clearTimeout(timeoutId) } + resolve() + } var check = function () { - if (entry.ready) { resolve() } - else { setTimeout(check, 30) } + if (entry.ready) { finish() } + else { pollId = setTimeout(check, 30) } } // Cap the wait so a broken image never blocks export forever. - setTimeout(resolve, 2000) + timeoutId = setTimeout(finish, 2000) check() })) } From 758c8d38d71c74e861745d1fee4fd720ace8479b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:04:32 +0000 Subject: [PATCH 20/30] Require `#\name` for LaTeX symbols, sharpen canvas on HiDPI, pin controls to top on mobile Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/48759929-9b18-4e12-aafe-9360db561da6 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- README.md | 33 +++++++++++++++++++-------------- app.css | 6 +++++- canvas.js | 26 ++++++++++++++++++++------ index.html | 2 +- infixToPostfix.js | 13 ++++++++++--- tree.js | 47 ++++++++++++++++++++++++++++++++--------------- 6 files changed, 87 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1248046..63a3f9e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Supported syntax includes: - Single-letter variables: `a`-`z` - Unary functions: `sin`, `cos`, `tan`, `log`, `ln`, `sqrt`, `exp`, `abs` - Custom operators/functions: - - Arity 0: `#pi` + - Arity 0: `#\pi` (LaTeX symbol), `#myvar` (plain upright text) - Arity 1-3 (inferred): `#sign(a)`, `#add(a,b)`, `#clamp(x,a,b)` Examples: @@ -19,10 +19,10 @@ Examples: - `sin(a)+cos(b)` - `sin(cos(a))` - `sin(a+b)*cos(c)` -- `#pi` +- `#\pi` - `#sign(a)` - `#add(a,b)` -- `sin(#add(a,b))*#pi` +- `sin(#add(a,b))*#\pi` - `#clamp(x,a,b)` ## LaTeX-rendered node labels @@ -37,18 +37,23 @@ nicely out of the box: - **Unary functions**: `sin`, `cos`, `tan`, `log`, `ln`, `exp` render in upright math style; `sqrt` shows a radical (`√`) and `abs` shows `|·|`. -- **Custom token symbols**: well-known names are mapped to their LaTeX - command, e.g. `#pi` → π, `#theta` → θ, `#alpha` → α, `#beta` → β, - `#gamma` → γ, `#lambda` → λ, `#mu` → μ, `#sigma` → σ, `#phi` → φ, - `#omega` → ω (plus uppercase variants like `#Gamma`, `#Delta`, - `#Sigma`, `#Omega`, …), `#infty` / `#infinity` → ∞, `#sum` → ∑, - `#prod` → ∏, `#int` → ∫, `#nabla` → ∇, `#partial` → ∂, - `#emptyset` → ∅. +- **Custom token symbols**: well-known LaTeX command names can be + reached with a leading backslash after `#`, e.g. `#\pi` → π, + `#\theta` → θ, `#\alpha` → α, `#\beta` → β, `#\gamma` → γ, + `#\lambda` → λ, `#\mu` → μ, `#\sigma` → σ, `#\phi` → φ, + `#\omega` → ω (plus uppercase variants like `#\Gamma`, `#\Delta`, + `#\Sigma`, `#\Omega`, …), `#\infty` / `#\infinity` → ∞, `#\sum` → ∑, + `#\prod` → ∏, `#\int` → ∫, `#\nabla` → ∇, `#\partial` → ∂, + `#\emptyset` → ∅. Any other `#\name` is forwarded to KaTeX as the + raw `\name` command. Without the backslash, `#phi` simply renders + as the upright text `phi`. - **Subscripts**: identifiers ending in digits are rendered with the - digits as a subscript — `x1` → *x*₁, `#theta12` → θ₁₂. An underscore - also introduces a subscript — `#a_foo` → *a*foo. -- **Unknown custom names** fall back to upright `\mathrm{name}` so they - remain legible without looking like variable products. + digits as a subscript — `x1` → *x*₁, `#\theta12` → θ₁₂. An + underscore also introduces a subscript — `#a_foo` → + *a*foo. +- **Unknown custom names** without a backslash fall back to upright + `\mathrm{name}` so they remain legible without looking like variable + products. If KaTeX fails to load or a label cannot be parsed, the node falls back to rendering the raw token text, so expressions will always be diff --git a/app.css b/app.css index b9b3da6..d8edc9a 100644 --- a/app.css +++ b/app.css @@ -47,7 +47,11 @@ body { .controls { width: min(720px, calc(100vw - 2rem)); - margin: auto; + /* Pin the input near the top rather than vertically centering it, so + the mobile keyboard opening (which shrinks the visual viewport and + re-centers anything using `margin: auto`) does not push the input + down over the tree. */ + margin: 1rem auto 0 auto; display: flex; flex-direction: column; align-items: center; diff --git a/canvas.js b/canvas.js index 92ec939..49643c7 100644 --- a/canvas.js +++ b/canvas.js @@ -4,7 +4,7 @@ '(a * b) - c + z / x', 'sin(a)+cos(b)', 'sin(a+b)*cos(c)', - 'sin(#add(a,b))*#pi', + 'sin(#add(a,b))*#\\pi', '#clamp(x,#min(a,b),c)', 'x - y + (c / (a + b))', '(a / y) + b - (c * x)', @@ -28,14 +28,28 @@ var debounceId = null var currentRoot = null + var cssWidth = 0 + var cssHeight = 0 function resizeCanvas() { - canvas.height = container.offsetHeight - canvas.width = container.offsetWidth + var dpr = Math.max(1, window.devicePixelRatio || 1) + cssWidth = container.offsetWidth + cssHeight = container.offsetHeight + canvas.width = Math.round(cssWidth * dpr) + canvas.height = Math.round(cssHeight * dpr) + canvas.style.width = cssWidth + 'px' + canvas.style.height = cssHeight + 'px' + // All drawing uses CSS pixels; the transform scales up to the + // high-resolution backing store so the tree stays crisp on HiDPI + // / mobile displays. + context.setTransform(dpr, 0, 0, dpr, 0, 0) } function clearCanvas() { + context.save() + context.setTransform(1, 0, 0, 1, 0, 0) context.clearRect(0, 0, canvas.width, canvas.height) + context.restore() } function setWarningVisible(isVisible) { @@ -122,8 +136,8 @@ : Promise.resolve() warmPromise.then(function () { var offscreen = document.createElement('canvas') - offscreen.width = canvas.width * exportScale - offscreen.height = canvas.height * exportScale + offscreen.width = cssWidth * exportScale + offscreen.height = cssHeight * exportScale var offscreenContext = offscreen.getContext('2d') offscreenContext.scale(exportScale, exportScale) drawTree(currentRoot, offscreenContext) @@ -212,7 +226,7 @@ walk(root) var styleBlock = cssText ? '' : '' return '' + - '' + + '' + styleBlock + '' + lines.join('') + '' + nodes.join('') + diff --git a/index.html b/index.html index 5856642..64703d0 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@
- + + + +
+
diff --git a/infixToPostfix.js b/infixToPostfix.js index 699733a..b3e52c6 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -3,7 +3,7 @@ const BINARY_OPERATORS = ['*', '/', '-', '+'] const UNARY_FUNCTIONS = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'exp', 'abs'] const CUSTOM_IDENTIFIER_START = /[A-Za-z_]/ const CUSTOM_IDENTIFIER_BODY = /[A-Za-z0-9_]/ -const ALLOWED_CHARACTERS_REGEX = /^[A-Za-z0-9_#\\,+\-*/()\s]*$/ +const NUMBER_BODY = /[0-9]/ function isCustomToken(token) { return token && typeof token === 'object' && token.type === 'custom' && typeof token.name === 'string' @@ -17,6 +17,15 @@ function createCustomToken(name, arity) { return token } +function isLiteralToken(token) { + return token && typeof token === 'object' && token.type === 'literal' && + (token.kind === 'text' || token.kind === 'latex') && typeof token.value === 'string' +} + +function createLiteralToken(kind, value) { + return { type: 'literal', kind: kind, value: value } +} + function isFunctionToken(token) { return UNARY_FUNCTIONS.indexOf(token) !== -1 || isCustomToken(token) } @@ -25,6 +34,15 @@ function isVariableToken(token) { return typeof token === 'string' && token.length === 1 && VARIABLES.indexOf(token) !== -1 } +function isNumberToken(token) { + return typeof token === 'string' && token.length > 0 && /^[0-9]+(\.[0-9]+)?$/.test(token) +} + +function isLeafToken(token) { + return isVariableToken(token) || isNumberToken(token) || isLiteralToken(token) || + (isCustomToken(token) && typeof token.arity === 'number' && token.arity === 0) +} + function tokenize(expression) { var tokens = [] for (var i = 0; i < expression.length; i++) { @@ -40,6 +58,40 @@ function tokenize(expression) { tokens.push(current) continue } + if (current === '"' || current === '$') { + var quote = current + var kind = (quote === '$') ? 'latex' : 'text' + var value = '' + var j = i + 1 + while (j < expression.length && expression[j] !== quote) { + value += expression[j] + j++ + } + if (j >= expression.length) { + throw new Error("Missing closing " + quote + " for literal") + } + tokens.push(createLiteralToken(kind, value)) + i = j + continue + } + if (NUMBER_BODY.test(current)) { + var number = current + while (i + 1 < expression.length && NUMBER_BODY.test(expression[i + 1])) { + number += expression[i + 1] + i++ + } + if (i + 1 < expression.length && expression[i + 1] === '.' && + i + 2 < expression.length && NUMBER_BODY.test(expression[i + 2])) { + number += '.' + i++ + while (i + 1 < expression.length && NUMBER_BODY.test(expression[i + 1])) { + number += expression[i + 1] + i++ + } + } + tokens.push(number) + continue + } if (current === '#') { var customName = '' // Optional leading backslash marks the identifier as a LaTeX command @@ -89,7 +141,7 @@ function parsePrimary(tokens, index, stopOnComma) { if (isStopToken(token, stopOnComma)) { return null } - if (isVariableToken(token)) { + if (isVariableToken(token) || isNumberToken(token) || isLiteralToken(token)) { return { nextIndex: index + 1 } } if (token === '(') { @@ -183,6 +235,10 @@ function describeToken(token) { if (isCustomToken(token)) { return "'#" + token.name + "'" } + if (isLiteralToken(token)) { + var quote = token.kind === 'latex' ? '$' : '"' + return quote + token.value + quote + } return "'" + token + "'" } @@ -211,10 +267,6 @@ function validateTokens(tokens) { } function infixToPostfix(expression) { - if (!ALLOWED_CHARACTERS_REGEX.test(expression)) { - var badMatch = expression.match(/[^A-Za-z0-9_#,+\-*/()\s]/) - throw new Error("Invalid character '" + (badMatch ? badMatch[0] : '?') + "' in expression") - } var tokens = tokenize(expression) if (tokens.length === 0) { throw new Error('Empty expression') @@ -226,7 +278,7 @@ function infixToPostfix(expression) { var postfixList = [] for (var i = 0; i < tokens.length; i++) { var token = tokens[i] - if (isVariableToken(token)) { + if (isVariableToken(token) || isNumberToken(token) || isLiteralToken(token)) { postfixList.push(token) } else if (isCustomToken(token)) { if (tokens[i + 1] === '(') { diff --git a/tree.js b/tree.js index fe4975b..87d71d0 100644 --- a/tree.js +++ b/tree.js @@ -102,6 +102,12 @@ function baseIdentifierToLatex(name) { // Produce the LaTeX source that should be rendered inside a node for the given token. function getTokenLatex(token) { + if (isLiteralToken(token)) { + if (token.kind === 'latex') { + return token.value + } + return '\\text{' + escapeLatexText(token.value) + '}' + } if (isCustomToken(token)) { var info = identifierToLatex(token.name) return info.latex || '\\text{' + escapeLatexText(token.name) + '}' @@ -118,6 +124,10 @@ function getTokenLatex(token) { return '\\' + token } if (typeof token === 'string') { + // Numeric literal: render digits as-is in math mode. + if (/^[0-9]+(\.[0-9]+)?$/.test(token)) { + return token + } var ident = identifierToLatex(token) if (ident.handled) { return ident.latex @@ -139,6 +149,11 @@ function isCustomToken(token) { return token && typeof token === 'object' && token.type === 'custom' && typeof token.name === 'string' } +function isLiteralToken(token) { + return token && typeof token === 'object' && token.type === 'literal' && + (token.kind === 'text' || token.kind === 'latex') && typeof token.value === 'string' +} + function getTokenArity(token) { if (isBinaryOperator(token)) { return 2 @@ -156,6 +171,9 @@ function getTokenLabel(token) { if (isCustomToken(token)) { return token.name } + if (isLiteralToken(token)) { + return token.value + } return token } From 746f351f14451f87174fb535826697bee4b27e12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:35:35 +0000 Subject: [PATCH 23/30] Address review: guard PNG fallback, skip all whitespace, note execCommand deprecation Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/5b75eb54-6f1c-48ba-9bf7-aaf9694c9e14 Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 12 ++++++++++-- infixToPostfix.js | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/canvas.js b/canvas.js index dcd31e5..95d8946 100644 --- a/canvas.js +++ b/canvas.js @@ -147,9 +147,15 @@ if (typeof offscreen.toBlob === 'function') { offscreen.toBlob(function (blob) { resolve(blob) }, 'image/png') } else { - // Fallback: derive blob from data URL. + // Fallback: derive blob from data URL. Guard against a + // malformed data URL so we don't throw inside atob(). var dataUrl = offscreen.toDataURL('image/png') - var binary = atob(dataUrl.split(',')[1]) + var commaIndex = dataUrl.indexOf(',') + if (commaIndex === -1) { + resolve(null) + return + } + var binary = atob(dataUrl.slice(commaIndex + 1)) var bytes = new Uint8Array(binary.length) for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } resolve(new Blob([bytes], { type: 'image/png' })) @@ -480,6 +486,8 @@ }) } else { // Fallback for environments without the async Clipboard API. + // document.execCommand('copy') is deprecated but remains the + // only option on older/insecure contexts. try { input.focus() input.select() diff --git a/infixToPostfix.js b/infixToPostfix.js index b3e52c6..ed54d94 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -47,7 +47,7 @@ function tokenize(expression) { var tokens = [] for (var i = 0; i < expression.length; i++) { var current = expression[i] - if (current === ' ') { + if (/\s/.test(current)) { continue } if (BINARY_OPERATORS.indexOf(current) !== -1 || current === '(' || current === ')') { From 5fd66539a5b334b93daa5a38a64f47069a66482f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:55:43 +0000 Subject: [PATCH 24/30] Support "..." and $...$ literals as operator (non-leaf) nodes Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/6a07f6e3-f15c-48fd-9a87-599e008970de Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- infixToPostfix.js | 40 ++++++++++++++++++++++++++++++---------- tree.js | 3 +++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/infixToPostfix.js b/infixToPostfix.js index ed54d94..d2bf199 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -22,12 +22,16 @@ function isLiteralToken(token) { (token.kind === 'text' || token.kind === 'latex') && typeof token.value === 'string' } -function createLiteralToken(kind, value) { - return { type: 'literal', kind: kind, value: value } +function createLiteralToken(kind, value, arity) { + var token = { type: 'literal', kind: kind, value: value } + if (typeof arity === 'number') { + token.arity = arity + } + return token } function isFunctionToken(token) { - return UNARY_FUNCTIONS.indexOf(token) !== -1 || isCustomToken(token) + return UNARY_FUNCTIONS.indexOf(token) !== -1 || isCustomToken(token) || isLiteralToken(token) } function isVariableToken(token) { @@ -39,7 +43,8 @@ function isNumberToken(token) { } function isLeafToken(token) { - return isVariableToken(token) || isNumberToken(token) || isLiteralToken(token) || + return isVariableToken(token) || isNumberToken(token) || + (isLiteralToken(token) && (typeof token.arity !== 'number' || token.arity === 0)) || (isCustomToken(token) && typeof token.arity === 'number' && token.arity === 0) } @@ -141,7 +146,7 @@ function parsePrimary(tokens, index, stopOnComma) { if (isStopToken(token, stopOnComma)) { return null } - if (isVariableToken(token) || isNumberToken(token) || isLiteralToken(token)) { + if (isVariableToken(token) || isNumberToken(token)) { return { nextIndex: index + 1 } } if (token === '(') { @@ -161,7 +166,7 @@ function parsePrimary(tokens, index, stopOnComma) { } return { nextIndex: unaryArg.nextIndex + 1 } } - if (isCustomToken(token)) { + if (isCustomToken(token) || isLiteralToken(token)) { if (tokens[index + 1] !== '(') { return { nextIndex: index + 1 } } @@ -278,7 +283,7 @@ function infixToPostfix(expression) { var postfixList = [] for (var i = 0; i < tokens.length; i++) { var token = tokens[i] - if (isVariableToken(token) || isNumberToken(token) || isLiteralToken(token)) { + if (isVariableToken(token) || isNumberToken(token)) { postfixList.push(token) } else if (isCustomToken(token)) { if (tokens[i + 1] === '(') { @@ -286,11 +291,17 @@ function infixToPostfix(expression) { } else { postfixList.push(createCustomToken(token.name, 0)) } + } else if (isLiteralToken(token)) { + if (tokens[i + 1] === '(') { + op_stack.push(token) + } else { + postfixList.push(token) + } } else if (UNARY_FUNCTIONS.indexOf(token) !== -1) { op_stack.push(token) } else if ("(" === token) { - if (i > 0 && isCustomToken(tokens[i - 1])) { - customCallStack.push({ name: tokens[i - 1].name, commaCount: 0 }) + if (i > 0 && (isCustomToken(tokens[i - 1]) || isLiteralToken(tokens[i - 1]))) { + customCallStack.push({ token: tokens[i - 1], commaCount: 0 }) } op_stack.push(token) } else if ("," === token) { @@ -321,10 +332,16 @@ function infixToPostfix(expression) { var emittedFunction = op_stack.pop() if (isCustomToken(emittedFunction)) { var customFrame = customCallStack.pop() - if (!customFrame || customFrame.name !== emittedFunction.name) { + if (!customFrame || customFrame.token !== emittedFunction) { throw new Error("Mismatched custom function call for '#" + emittedFunction.name + "'") } postfixList.push(createCustomToken(emittedFunction.name, customFrame.commaCount + 1)) + } else if (isLiteralToken(emittedFunction)) { + var literalFrame = customCallStack.pop() + if (!literalFrame || literalFrame.token !== emittedFunction) { + throw new Error("Mismatched literal function call for " + describeToken(emittedFunction)) + } + postfixList.push(createLiteralToken(emittedFunction.kind, emittedFunction.value, literalFrame.commaCount + 1)) } else { postfixList.push(emittedFunction) } @@ -349,6 +366,9 @@ function infixToPostfix(expression) { if (isCustomToken(tail)) { throw new Error("Missing closing ')' for '#" + tail.name + "'") } + if (isLiteralToken(tail)) { + throw new Error("Missing closing ')' for " + describeToken(tail)) + } postfixList.push(op_stack.pop()) } return postfixList diff --git a/tree.js b/tree.js index 87d71d0..3f2507b 100644 --- a/tree.js +++ b/tree.js @@ -164,6 +164,9 @@ function getTokenArity(token) { if (isCustomToken(token)) { return token.arity || 0 } + if (isLiteralToken(token)) { + return token.arity || 0 + } return 0 } From 83b7ab1d8478f8a6b0f5cff71300a524cfb44733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:56:47 +0000 Subject: [PATCH 25/30] Consolidate custom/literal arity branch in getTokenArity Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/6a07f6e3-f15c-48fd-9a87-599e008970de Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- tree.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tree.js b/tree.js index 3f2507b..ab47210 100644 --- a/tree.js +++ b/tree.js @@ -161,10 +161,7 @@ function getTokenArity(token) { if (isUnaryFunction(token)) { return 1 } - if (isCustomToken(token)) { - return token.arity || 0 - } - if (isLiteralToken(token)) { + if (isCustomToken(token) || isLiteralToken(token)) { return token.arity || 0 } return 0 From 31bb7d501dc9f910cd7ac0837ee896e94a6f4ed3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:01:18 +0000 Subject: [PATCH 26/30] Initial plan From 1bb09bfe64c00e14b61c7cfa9f5b5e03c1eb69cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:07:01 +0000 Subject: [PATCH 27/30] fix: crop PNG and SVG exports to tree bounds Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/canvas.js b/canvas.js index 95d8946..66589b3 100644 --- a/canvas.js +++ b/canvas.js @@ -12,6 +12,7 @@ ] const THEME_STORAGE_KEY = 'etg-theme' const DEBOUNCE_MS = 150 + const EXPORT_PADDING = 16 var canvas = document.querySelector('canvas') var context = canvas.getContext('2d') @@ -127,6 +128,36 @@ link.click() } + function computeTreeBounds(root) { + if (!root) { + return null + } + var radius = getNodeRadius() + var minX = Infinity + var minY = Infinity + var maxX = -Infinity + var maxY = -Infinity + + function walk(node) { + if (!node) { + return + } + minX = Math.min(minX, node.x - radius) + minY = Math.min(minY, node.y - radius) + maxX = Math.max(maxX, node.x + radius) + maxY = Math.max(maxY, node.y + radius) + for (var i = 0; i < node.children.length; i++) { + walk(node.children[i]) + } + } + + walk(root) + if (!isFinite(minX)) { + return null + } + return { minX: minX, minY: minY, maxX: maxX, maxY: maxY } + } + function buildPngBlob(scale) { if (!currentRoot) { return Promise.resolve(null) @@ -137,11 +168,18 @@ ? warmLatexCache(currentRoot, colors.text) : Promise.resolve() return warmPromise.then(function () { + var bounds = computeTreeBounds(currentRoot) + if (!bounds) { + return null + } + var width = (bounds.maxX - bounds.minX) + (2 * EXPORT_PADDING) + var height = (bounds.maxY - bounds.minY) + (2 * EXPORT_PADDING) var offscreen = document.createElement('canvas') - offscreen.width = cssWidth * exportScale - offscreen.height = cssHeight * exportScale + offscreen.width = Math.ceil(width * exportScale) + offscreen.height = Math.ceil(height * exportScale) var offscreenContext = offscreen.getContext('2d') offscreenContext.scale(exportScale, exportScale) + offscreenContext.translate(-bounds.minX + EXPORT_PADDING, -bounds.minY + EXPORT_PADDING) drawTree(currentRoot, offscreenContext) return new Promise(function (resolve) { if (typeof offscreen.toBlob === 'function') { @@ -197,6 +235,14 @@ return '' } var radius = getNodeRadius() + var bounds = computeTreeBounds(root) + if (!bounds) { + return '' + } + var viewBoxX = bounds.minX - EXPORT_PADDING + var viewBoxY = bounds.minY - EXPORT_PADDING + var viewBoxWidth = (bounds.maxX - bounds.minX) + (2 * EXPORT_PADDING) + var viewBoxHeight = (bounds.maxY - bounds.minY) + (2 * EXPORT_PADDING) var colors = getThemeColors() var lines = [] var nodes = [] @@ -254,7 +300,7 @@ walk(root) var styleBlock = cssText ? '' : '' return '' + - '' + + '' + styleBlock + '' + lines.join('') + '' + nodes.join('') + From 08dafb1225ee8d2ab2b030083768f20efb734eda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:10:28 +0000 Subject: [PATCH 28/30] chore: harden bounds traversal for missing children Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/canvas.js b/canvas.js index 66589b3..4428d5f 100644 --- a/canvas.js +++ b/canvas.js @@ -146,8 +146,9 @@ minY = Math.min(minY, node.y - radius) maxX = Math.max(maxX, node.x + radius) maxY = Math.max(maxY, node.y + radius) - for (var i = 0; i < node.children.length; i++) { - walk(node.children[i]) + var children = node.children || [] + for (var i = 0; i < children.length; i++) { + walk(children[i]) } } From e3b604a8543d11e7539dd9a5b57e39bfd35b1de6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:11:54 +0000 Subject: [PATCH 29/30] refactor: share padded bounds size for export cropping Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com> --- canvas.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/canvas.js b/canvas.js index 4428d5f..ce68ed9 100644 --- a/canvas.js +++ b/canvas.js @@ -132,6 +132,7 @@ if (!root) { return null } + // All nodes share a single radius (from getNodeRadius()). var radius = getNodeRadius() var minX = Infinity var minY = Infinity @@ -159,6 +160,13 @@ return { minX: minX, minY: minY, maxX: maxX, maxY: maxY } } + function getPaddedBoundsSize(bounds) { + return { + width: (bounds.maxX - bounds.minX) + (2 * EXPORT_PADDING), + height: (bounds.maxY - bounds.minY) + (2 * EXPORT_PADDING) + } + } + function buildPngBlob(scale) { if (!currentRoot) { return Promise.resolve(null) @@ -173,11 +181,10 @@ if (!bounds) { return null } - var width = (bounds.maxX - bounds.minX) + (2 * EXPORT_PADDING) - var height = (bounds.maxY - bounds.minY) + (2 * EXPORT_PADDING) + var size = getPaddedBoundsSize(bounds) var offscreen = document.createElement('canvas') - offscreen.width = Math.ceil(width * exportScale) - offscreen.height = Math.ceil(height * exportScale) + offscreen.width = Math.ceil(size.width * exportScale) + offscreen.height = Math.ceil(size.height * exportScale) var offscreenContext = offscreen.getContext('2d') offscreenContext.scale(exportScale, exportScale) offscreenContext.translate(-bounds.minX + EXPORT_PADDING, -bounds.minY + EXPORT_PADDING) @@ -242,8 +249,9 @@ } var viewBoxX = bounds.minX - EXPORT_PADDING var viewBoxY = bounds.minY - EXPORT_PADDING - var viewBoxWidth = (bounds.maxX - bounds.minX) + (2 * EXPORT_PADDING) - var viewBoxHeight = (bounds.maxY - bounds.minY) + (2 * EXPORT_PADDING) + var size = getPaddedBoundsSize(bounds) + var viewBoxWidth = size.width + var viewBoxHeight = size.height var colors = getThemeColors() var lines = [] var nodes = [] From 266e362bdcfbed7d91a8609160dd0a688a68b6e2 Mon Sep 17 00:00:00 2001 From: psaegert Date: Sat, 13 Jun 2026 17:09:39 +0200 Subject: [PATCH 30/30] docs: fork-aware README + footer credit to original, with screenshots - Rewrite README to reflect the current app (KaTeX labels, numbers, text/ LaTeX literals, PNG/SVG export with scale + copy, live render, dark mode). - Point primary links to this fork's site/repo; credit Lucas Nogueira's original (lnogueir/expression-tree-gen) and link it. - Fix dead Georgia Tech shunting-yard credit link -> runestone (pythonds3); add a no-license attribution note. - Footer now links to BOTH this fork (GitHub icon) and the original ("Fork of lnogueir/expression-tree-gen" text link); wrap/width guard so the floating bar does not overflow on narrow screens. - Add light/dark screenshots (docs/) and embed them near the top. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 191 +++++++++++++++++++++++++------------- app.css | 18 +++- docs/screenshot-dark.png | Bin 0 -> 68714 bytes docs/screenshot-light.png | Bin 0 -> 68599 bytes index.html | 3 +- 5 files changed, 144 insertions(+), 68 deletions(-) create mode 100644 docs/screenshot-dark.png create mode 100644 docs/screenshot-light.png diff --git a/README.md b/README.md index 63a3f9e..53deb8a 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,125 @@ -# expression-tree-gen -Building an expression tree of an arithmetic expression is something quite useful and it is one of the steps a compilers must take to generate machine code. - -With that in mind, and inspired on my lectures on the tree data structure I decided to create a web app that simulates the creation of such a tree given an expression. - -Visit this [website](https://lnogueir.github.io/expression-tree-gen/) to simulate an expression yourself. - -Supported syntax includes: -- Binary operators: `+`, `-`, `*`, `/` -- Single-letter variables: `a`-`z` -- Unary functions: `sin`, `cos`, `tan`, `log`, `ln`, `sqrt`, `exp`, `abs` -- Custom operators/functions: - - Arity 0: `#\pi` (LaTeX symbol), `#myvar` (plain upright text) - - Arity 1-3 (inferred): `#sign(a)`, `#add(a,b)`, `#clamp(x,a,b)` - -Examples: -- `a+b` -- `sin(a)` -- `sin(a)+cos(b)` -- `sin(cos(a))` -- `sin(a+b)*cos(c)` -- `#\pi` -- `#sign(a)` -- `#add(a,b)` -- `sin(#add(a,b))*#\pi` -- `#clamp(x,a,b)` - -## LaTeX-rendered node labels - -Node labels are typeset with [KaTeX](https://katex.org/) (loaded from a CDN, -so no build step is required) for both the interactive canvas and the -exported PNG / SVG files. A few conventions make common symbols render -nicely out of the box: - -- **Operators**: `*` renders as `·` (`\cdot`), `/` as `÷` (`\div`); `+` - and `-` are left as-is. -- **Unary functions**: `sin`, `cos`, `tan`, `log`, `ln`, `exp` render in - upright math style; `sqrt` shows a radical (`√`) and `abs` shows - `|·|`. -- **Custom token symbols**: well-known LaTeX command names can be - reached with a leading backslash after `#`, e.g. `#\pi` → π, - `#\theta` → θ, `#\alpha` → α, `#\beta` → β, `#\gamma` → γ, - `#\lambda` → λ, `#\mu` → μ, `#\sigma` → σ, `#\phi` → φ, - `#\omega` → ω (plus uppercase variants like `#\Gamma`, `#\Delta`, - `#\Sigma`, `#\Omega`, …), `#\infty` / `#\infinity` → ∞, `#\sum` → ∑, - `#\prod` → ∏, `#\int` → ∫, `#\nabla` → ∇, `#\partial` → ∂, - `#\emptyset` → ∅. Any other `#\name` is forwarded to KaTeX as the - raw `\name` command. Without the backslash, `#phi` simply renders - as the upright text `phi`. -- **Subscripts**: identifiers ending in digits are rendered with the - digits as a subscript — `x1` → *x*₁, `#\theta12` → θ₁₂. An - underscore also introduces a subscript — `#a_foo` → - *a*foo. -- **Unknown custom names** without a backslash fall back to upright - `\mathrm{name}` so they remain legible without looking like variable - products. - -If KaTeX fails to load or a label cannot be parsed, the node falls back -to rendering the raw token text, so expressions will always be -displayed. - -## Credits: - -* This [article](https://llimllib.github.io/pymag-trees/) which helped me a lot introducing me to Knuth's algorithm for the layout of the tree. -* This [article](http://ice-web.cc.gatech.edu/ce21/1/static/audio/static/pythonds/BasicDS/InfixPrefixandPostfixExpressions.html) which introduced me to Dijkstra's Shunting-yard algorithm. -* The [mycodeschool](https://www.youtube.com/user/mycodeschool) youtube channel with great videos explaning the algorithms said above. +# Expression Tree Generator + +A small, dependency-light web app that parses an arithmetic expression and draws its **expression tree** live on a canvas as you type. Node labels are typeset with LaTeX (via KaTeX), and the rendered tree can be exported to PNG or SVG. + +

+ Expression tree for sin(#add(a,b))*#\pi, light theme + Expression tree for sin(#add(a,b))*#\pi, dark theme +

+ +**Try it live (this fork): https://psaegert.github.io/expression-tree-gen/** + +> **This is a maintained fork.** The original *Expression Tree Generator* was created by **Lucas Nogueira** ([@lnogueir](https://github.com/lnogueir)). This repository builds on that work with a rewritten real-time UI, LaTeX-rendered node labels, PNG / SVG export, light / dark theming, and a few syntax extensions (numbers, text and LaTeX literals). See [Credits](#credits) and [License and attribution](#license-and-attribution). + +| | Live site | Repository | +| ------------------------- | ----------------------------------------------- | ------------------------------------------------ | +| **This fork** (maintained) | https://psaegert.github.io/expression-tree-gen/ | https://github.com/psaegert/expression-tree-gen | +| Original (Lucas Nogueira) | https://lnogueir.github.io/expression-tree-gen/ | https://github.com/lnogueir/expression-tree-gen | + +## What it does + +Type an expression and the app validates it, converts it to a syntax tree, and renders that tree as labelled circular nodes connected by edges. There is no generate or submit step: the drawing updates automatically as you type (debounced by 150 ms). Invalid input shows an inline error message plus a small warning indicator while keeping the last valid drawing on screen, so the canvas is never abruptly cleared. A random sample expression is pre-filled on first load. + +## Features + +- **Real-time rendering.** The tree redraws on every keystroke (150 ms debounce); there is no generate button. +- **LaTeX-rendered labels.** Node labels are typeset with [KaTeX](https://katex.org/), so operators, Greek letters, subscripts, radicals, and similar symbols render properly. See [Node label rendering](#node-label-rendering) for the conventions. +- **Export to PNG or SVG.** An export menu offers PNG (at 1x, 2x, 3x, or 4x scale) or SVG. Each export is cropped to the tree's bounding box with a small margin. PNGs are rendered crisp on HiDPI displays; SVGs stay vector by embedding the KaTeX output, so they remain editable. +- **Download or copy.** Either download the export as a file (`expression-tree.png` / `expression-tree.svg`) or copy it straight to the clipboard. +- **Copy the expression.** A one-click button copies the current expression text. +- **Light / dark theme.** A toggle switches themes; the choice is saved in `localStorage` and defaults to your operating system preference on the first visit. Labels and edges re-render to match the active theme. +- **Helpful errors.** Invalid input produces a specific message (for example a mismatched parenthesis or an unknown identifier) rather than failing silently. +- **Responsive and accessible.** The canvas resizes with the window and exposes an `aria-label` describing the current expression. + +## Supported syntax + +- **Binary operators:** `+`, `-`, `*`, `/` (standard precedence, with parentheses for grouping) +- **Unary functions:** `sin`, `cos`, `tan`, `log`, `ln`, `sqrt`, `exp`, `abs` +- **Variables:** a single lowercase letter, `a` to `z` +- **Numbers:** integer or decimal literals, for example `2` or `3.14` +- **Custom tokens:** `#name`, for example `#pi`, `#add(a, b)` (see below) +- **Text and LaTeX literals:** `"label"` and `$\latex$` (see below) + +A run of lowercase letters is only accepted if it is a single variable or an exact match of one of the unary function names above. Anything else (an unknown multi-letter identifier, an uppercase letter, an unexpected character) is reported as an error. + +### Custom tokens (`#name`) + +A custom token is a leading `#` followed by an identifier (first character a letter or `_`, then letters, digits, or `_`). Arity is **inferred from usage**, not declared: + +- A custom token **not** followed by `(` is an arity-0 leaf, for example `#pi`. +- A custom token **followed by** `(` is a call whose arity is the number of comma-separated arguments, for example `#add(a, b)` has arity 2. +- Arity is **capped at 3**: a call with more than three arguments is rejected. + +Adding a backslash after the `#` marks the identifier as a LaTeX command, so `#\pi` renders as the symbol π while `#pi` renders as the upright text `pi`. See [Node label rendering](#node-label-rendering). + +### Text and LaTeX literals + +For labels that are not valid identifiers, you can quote them: + +- `"label"` renders the quoted text as a plain-text node label. +- `$...$` renders the quoted content as raw LaTeX, for example `$\frac{a}{b}$`. + +Like custom tokens, a literal followed by `(` becomes a function node whose arity is inferred from its arguments (capped at 3), for example `"relu"(x)` or `$\sigma$(a, b)`. + +### Examples + +```text +sin(#add(a,b))*#\pi +#clamp(x, #min(a,b), c) +(a + b)*c - (x - y)/z +sqrt(a*a + b*b) +2*x + 3.14 +$\Sigma$(a, b, c) +"relu"(x) +``` + +## Node label rendering + +Labels are typeset with [KaTeX](https://katex.org/), loaded from a CDN, so no build step is required. A few conventions make common symbols render nicely out of the box: + +- **Operators:** `*` renders as `·` (`\cdot`), `/` as `÷` (`\div`); `+` and `-` are left as-is. +- **Unary functions:** `sin`, `cos`, `tan`, `log`, `ln`, `exp` render in upright math style; `sqrt` shows a radical (√) and `abs` shows `|·|`. +- **Custom token symbols:** well-known LaTeX command names can be reached with a leading backslash after `#`, for example `#\pi` to π, `#\theta` to θ, `#\alpha` to α, `#\lambda` to λ, `#\sigma` to σ, `#\omega` to ω, plus uppercase variants such as `#\Gamma`, `#\Sigma`, `#\Omega`, and a handful of operators (`#\infty`, `#\sum`, `#\prod`, `#\int`, `#\nabla`, `#\partial`, `#\emptyset`). Any other `#\name` is forwarded to KaTeX as the raw `\name` command. Without the backslash, `#phi` simply renders as the upright text `phi`. +- **Subscripts:** identifiers ending in digits are rendered with the digits as a subscript, so `x1` becomes *x*₁ and `#\theta12` becomes θ₁₂. An underscore also introduces a subscript, so `#a_foo` becomes *a* with subscript foo. +- **Unknown custom names** without a backslash fall back to upright `\mathrm{name}` so they stay legible. + +If KaTeX cannot load or a label cannot be parsed, the node falls back to its raw token text, so expressions are always displayed. + +## How it works + +1. **Tokenize.** The input is scanned into variables, numbers, the binary operators, unary function names, parentheses, commas, `#`-prefixed custom tokens, and quoted literals. Anything outside this grammar raises a descriptive error. +2. **Validate.** A hand-rolled recursive-descent parser checks that the token stream forms a well-formed expression and that parentheses match. There is no external math library. +3. **Infix to postfix.** A valid expression is converted to postfix using Dijkstra's shunting-yard algorithm, extended to count the arguments of custom and literal function calls so the right arity is attached. +4. **Build the tree.** The postfix stream is consumed with a stack, popping each operator or function's children according to its arity. +5. **Lay out.** Layout is a top-down recursive partition: vertical position is set by depth, and each node's horizontal range is split among its children in proportion to their leaf counts. +6. **Render.** Each node is drawn as a circle whose label is a KaTeX rasterization (with a plain-text fallback); edges are trimmed to the node boundaries. SVG export reuses the same geometry but embeds the KaTeX output as vector content. + +## Running locally + +The app is a static site with **no build step**. Its only runtime dependency is **KaTeX**, loaded from a CDN, which is used purely to typeset labels: parsing, layout, and export all run offline, and labels gracefully fall back to plain text if KaTeX cannot load. + +```bash +git clone https://github.com/psaegert/expression-tree-gen.git +cd expression-tree-gen + +# Option A: open index.html directly in your browser +# Option B (recommended): serve the folder, then open http://localhost:8000 +python3 -m http.server 8000 +``` + +## Credits + +This fork is built on the original **Expression Tree Generator** by **Lucas Nogueira** ([lnogueir/expression-tree-gen](https://github.com/lnogueir/expression-tree-gen)). + +Educational references that informed the original project: + +- **Tree layout:** [Drawing Presentable Trees](https://llimllib.github.io/pymag-trees/) by Bill Mill, a tour of tree-layout algorithms from Knuth onward. +- **Infix to postfix:** [Infix, Prefix, and Postfix Expressions](https://runestone.academy/ns/books/published/pythonds3/BasicDS/InfixPrefixandPostfixExpressions.html), the chapter on Dijkstra's shunting-yard conversion from *Problem Solving with Algorithms and Data Structures using Python* by Miller and Ranum. +- **[mycodeschool](https://www.youtube.com/user/mycodeschool)** on YouTube, for clear explanations of the algorithms above. + +## License and attribution + +Neither this fork nor the original repository ships a LICENSE file, so no license is asserted here. If you reuse this code, please credit the original author and link back to the source: + +- Original author: **Lucas Nogueira** ([github.com/lnogueir](https://github.com/lnogueir)) +- Original repository: [github.com/lnogueir/expression-tree-gen](https://github.com/lnogueir/expression-tree-gen) diff --git a/app.css b/app.css index 2976305..e23a65c 100644 --- a/app.css +++ b/app.css @@ -180,12 +180,28 @@ canvas { left: 50%; transform: translateX(-50%); display: flex; + flex-wrap: wrap; align-items: center; justify-content: center; - gap: 0.75rem; + gap: 0.4rem 0.75rem; + max-width: calc(100vw - 1.5rem); z-index: 1; } +.credit { + font-size: 0.8rem; + color: var(--muted); +} + +.credit a { + color: inherit; + text-decoration: underline; +} + +.credit a:hover { + color: var(--fg); +} + .theme-icon { display: none; } diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc6a9a36e781baef258fce5d2d79e840595ad30 GIT binary patch literal 68714 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 literal 0 HcmV?d00001 diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png new file mode 100644 index 0000000000000000000000000000000000000000..044c6ff02d23a4ff9ea8366433dd50f084ec846c GIT binary patch literal 68599 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˝vtA!YhIqXH|aPhvyj{cGRFtC5JzM zZh-2mC<9!`79~}NzvfebJ|G+ZnudJyiQ-M1TI~n;U%>)+y=2FBeH8 zhxbID%vw~sBqrhuMM`kYiJK^!2-VA^J_Z&*w?GGM99Ud|F^%Gwn3wz)U4b1fe0+$Z z04InZ5#O-XewSXzb1^Hdp-ilAVbK}2ZWMV!_LGh4QhtS>GFlvXCXv7u5;i}IkAKDQ zYmY}=>*+=YS%1Y9J!ONr(CT66>fRtld=xS{R-+IZiGQHAwbcVP z27{!f`D!~o>W`qDFhZ*VBmOIL?nqX9a%-LgYLFi*0;je7138R5@;$V|@h6>i#vaUh zM!BRT`;%uXND!3Jp;MV~^jW)l^~Qn@f}kt&hYJUr_s z4$Oy6w=4B>1(Tk=jYyxkw0mO4MG-2v`4}q~9p~!okdl(0=s;yjWR=xwWedmrThvRD zn*0?9gw%OeM@rZDj=Wkojv@4Z%mvqCzbP6!&!s)Q%gJN7=i^76{TW0it^D*IX@KPE9X(lAE!CBt`rY*c*7wS+MrxzQquXof1auQ14@7)O_j|}t*=*TY@U9))8_G!hZcVoe25pRc&q$YuLWE~zS
45rYfjfep zHl#rYU56xeF-L1-R*V@~Lo_-N2KB*X;G=@P5TjxdRzbi*TFM|F>=ujQG7$d(5otug z(13^VnC5eVAI&y6xC0*H<`|*wL&XS)V3xEv4bec_}9k~0g)Q{j@JRu73V;yE0!!BsAGZ? z1=?aH_c?Aitb1Z;y|+324&u`7HUGw?%Wx^gvdYe&O|w{Hh{4y#nqhGrWYw^SG+-*# zsDZnN-&o+9zc~{c5@NV--;1jyk_?i`ue|n7s0C6XnIMyiZw0)_hl51A6mB;BXtjP0 z&bb^EWCxf)#4{)(QjP{dw5AVDen*H+AtyJ* z7uhACkU%Y8+M{+D76t%om9yGgeqOygDpW{;0}=_)QxDt&_?nP$^7tt_mnSoP-vFT! zZ6psk|AOqzA3K0h=#<|t9{a;<__ApuqM)S0#d8UZ{RKD#TRg4;NiN$I{&8nvr~vQ= z1Y*nJ#HL;yA=_l`#?U#n^}oOgf*w?qUwCqvAUN?1lO{QQ)2%ucy`wp2v--6RK;r>V zALI;_(DOo$1Xu|;eK~MQR8+JN61cJwlheb}YHFQNirergZ46uAyyvPaHM|jy#?S_8 zi{IRXHHT)t8VK9-Wo=SZvw#fCeMB*z*K?|E9?wDC^@+@nh!BNCtJ>38g6)0Q+3k{$oUB7G>A;glsI0(? zRE6!*Msb5?-G;Fc+6(c8WCV%0gGQVSnkxa#w!l)8%14)#Z^S->LS*2%PI^9uL*@0x!)VzeXT(v&$g9r^WJFL;INuTxY zVu2zpNF7baQ^;~TTo=VjNvGTMnC5tnS^nIQ0BnV2q~e)lRR$P+*{T11EF$$C;ROr^ zB_Ihi$DUYn4}k}5q_G35RlLqK7>!6pJh)z2B$Foz$6gSNjcBJeBG{wtfpL?>`1k_= z1!c7tFpaaO!*D??46PCnM?pUJVGRT&zmbDOGT2YlV~Dq6<5gmEF2ae@85k~lE!OV) z@s>6P>a@=@n36E`^D7|B5r+S!W;pnLKegLwKlR>Qe)6??u>!KLn0T(M(85#HrEttrTX)%4(#Inn&%cz2@=DCZ9nE~?P)idkA?t=S%Ne6c?}(L1@UXF zJABck7L(D(OQ@a?Z#M;8D4p7aOp8^52FfB)YHga7-F|MfMP a<6V00k3V