diff --git a/README.md b/README.md
index 56d4f59..53deb8a 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +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.
+# Expression Tree Generator
-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.
+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.
-Visit this [website](https://lnogueir.github.io/expression-tree-gen/) to simulate an expression yourself.
+
+
+
+
-## Credits:
+**Try it live (this fork): https://psaegert.github.io/expression-tree-gen/**
-* 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.
+> **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 e697ba4..e23a65c 100644
--- a/app.css
+++ b/app.css
@@ -1,211 +1,315 @@
-*{
+* {
+ 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;
+ 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);
+ display: flex;
+ flex-direction: column;
+}
+
+.app {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.controls {
+ width: min(720px, calc(100vw - 2rem));
+ /* 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;
+ gap: 0.65rem;
+ 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;
- }
-
- .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;
+ 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;
+}
+
+#expression-input {
+ width: 100%;
+ border: 1px solid var(--input-border);
+ border-radius: 10px;
+ background: transparent;
+ color: var(--fg);
+ padding: 0.8rem 6.5rem 0.8rem 0.85rem;
+ font-size: 1rem;
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;
- }
-
- .buttons > * {
- margin-right: 15px;
- }
-
- .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;
+}
+
+#expression-input::placeholder {
+ color: var(--muted);
+}
+
+.warning {
+ position: absolute;
+ right: 4.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.icon-btn {
+ border: none;
+ background: transparent;
+ color: var(--fg);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
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;
- }
+ 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;
+ top: 50%;
+ transform: translateY(-50%);
+ padding: 0.25rem;
+}
- .end-row{
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- margin: auto;
- color: white;
- }
+.input-icon-clear {
+ right: 0.5rem;
+}
- .end-1-text{
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- }
+.input-icon-copy {
+ right: 2rem;
+}
- .end-1-text h4{
- font-weight: 400;
- padding: 0 0 .25rem 0;
- }
+.input-icon-copy .copy-done {
+ display: none;
+}
- .end-1-text p{
- font-size: 10px;
- width: 75%;
- }
+.input-icon-copy.copied .copy-default {
+ display: none;
+}
+
+.input-icon-copy.copied .copy-done {
+ display: inline-flex;
+}
- .end-2-text p a{
- text-decoration: none;
- color: #FFF;
+.save-btn {
+ padding: 0.25rem;
}
-body{
- margin:0;
+#canvas-container {
+ position: relative;
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 0;
+ padding: 0;
+ z-index: 0;
}
- .end-1{
- flex: 1;
- }
+canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
- .end-2-text{
+.bottom-bar {
+ position: fixed;
+ bottom: 0.8rem;
+ left: 50%;
+ transform: translateX(-50%);
display: flex;
- flex-direction: column;
- justify-content: center;
+ flex-wrap: wrap;
align-items: center;
+ justify-content: center;
+ gap: 0.4rem 0.75rem;
+ max-width: calc(100vw - 1.5rem);
+ z-index: 1;
}
-.end-2-text h4{
- padding: 0 0 .25rem 0;
- font-weight: 400;
+.credit {
+ font-size: 0.8rem;
+ color: var(--muted);
}
-.end-2-text p{
- font-size: 10px;
- width: 75%;
+.credit a {
+ color: inherit;
+ text-decoration: underline;
}
-.end-2{
- flex: 1;
+.credit a:hover {
+ color: var(--fg);
}
-
- @media screen and (max-width:700px){
- .contact-section{
- background: #001f3f;
- }
- }
-
-
- @media screen and (max-width:600px){
- .name,.email{
- width: 100%;
- }
- }
-
-#canvas-container{
- outline: none;
- border: none;
+
+.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;
+}
+
+.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;
- flex-direction: column;
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: space-between;
+ gap: 0.5rem;
+ font-size: 0.9rem;
}
-canvas{
- border: 1px solid lightgray;
- width: 100%;
- height: 100%;
-}
\ No newline at end of file
+#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;
+}
+
+.export-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.export-actions .download-btn {
+ flex: 1;
+}
+
+.export-status {
+ font-size: 0.8rem;
+ color: var(--muted);
+ min-height: 1em;
+ text-align: center;
+}
+
+.export-status.error {
+ color: #e53935;
+}
+
+[data-theme="dark"] .export-status.error {
+ color: #ff6b68;
+}
diff --git a/canvas.js b/canvas.js
index adebbfe..ce68ed9 100644
--- a/canvas.js
+++ b/canvas.js
@@ -1,73 +1,577 @@
-
-
-
(function () {
const SAMPLE_EXPRESSIONS = [
'(a + b)*c - (x - y)/z',
'(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',
- '(a * b) - (x / y)'
+ '(a - b) * (c + d) / z'
]
+ const THEME_STORAGE_KEY = 'etg-theme'
+ const DEBOUNCE_MS = 150
+ const EXPORT_PADDING = 16
+
var canvas = document.querySelector('canvas')
- var c = canvas.getContext('2d')
- function clearCanvas() { c.clearRect(0, 0, canvas.width, canvas.height) }
-
- document.getElementById('generate-tree').addEventListener('click', () => {
- 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 {
- var root = constructTree(postfix)
- setCoordinates(root)
- clearCanvas()
- canvas.height = document.getElementById('canvas-container').offsetHeight;
- canvas.width = document.getElementById('canvas-container').offsetWidth;
- drawTree(root, c)
- } catch (e) {
- displayErrorMessage()
+ var context = canvas.getContext('2d')
+ 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')
+ var pngScale = document.getElementById('png-scale')
+ var formatInputs = document.querySelectorAll('input[name="export-format"]')
+ var downloadExport = document.getElementById('download-export')
+ var copyExport = document.getElementById('copy-export')
+ var copyInput = document.getElementById('copy-input')
+ var exportStatus = document.getElementById('export-status')
+
+ var debounceId = null
+ var currentRoot = null
+ var cssWidth = 0
+ var cssHeight = 0
+
+ function resizeCanvas() {
+ 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) {
+ 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) {
+ return
+ }
+ setCoordinates(root)
+ drawTree(root, context)
+ }
+
+ function renderExpression() {
+ var expression = input.value
+ if (typeof expression === 'undefined' || expression === null) {
+ clearError()
+ return
+ }
+
+ if (expression.trim().length === 0) {
+ currentRoot = null
+ clearError()
+ clearCanvas()
+ updateCanvasAccessibility('')
+ return
+ }
+
+ try {
+ var postfix = infixToPostfix(expression)
+ var root = constructTree(postfix)
+ currentRoot = root
+ clearError()
+ renderRoot(root)
+ updateCanvasAccessibility(expression)
+ } catch (error) {
+ showError((error && error.message) ? error.message : 'Invalid expression')
+ }
+ }
+
+ function debouncedRender() {
+ clearTimeout(debounceId)
+ debounceId = setTimeout(renderExpression, DEBOUNCE_MS)
+ }
+
+ function triggerDownload(url, filename) {
+ var link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ link.click()
+ }
+
+ function computeTreeBounds(root) {
+ if (!root) {
+ return null
+ }
+ // All nodes share a single radius (from getNodeRadius()).
+ 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)
+ var children = node.children || []
+ for (var i = 0; i < children.length; i++) {
+ walk(children[i])
+ }
+ }
+
+ walk(root)
+ if (!isFinite(minX)) {
+ return null
+ }
+ 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)
+ }
+ var exportScale = Math.max(1, scale || 1)
+ var colors = getThemeColors()
+ var warmPromise = (typeof warmLatexCache === 'function')
+ ? warmLatexCache(currentRoot, colors.text)
+ : Promise.resolve()
+ return warmPromise.then(function () {
+ var bounds = computeTreeBounds(currentRoot)
+ if (!bounds) {
+ return null
+ }
+ var size = getPaddedBoundsSize(bounds)
+ var offscreen = document.createElement('canvas')
+ 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)
+ drawTree(currentRoot, offscreenContext)
+ return new Promise(function (resolve) {
+ if (typeof offscreen.toBlob === 'function') {
+ offscreen.toBlob(function (blob) { resolve(blob) }, 'image/png')
+ } else {
+ // 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 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' }))
}
+ })
+ })
+ }
+
+ function exportPng(scale) {
+ return buildPngBlob(scale).then(function (blob) {
+ if (!blob) { return }
+ var url = URL.createObjectURL(blob)
+ triggerDownload(url, 'expression-tree.png')
+ setTimeout(function () { URL.revokeObjectURL(url) }, 1000)
+ })
+ }
+
+ 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, cssText) {
+ if (!root) {
+ return ''
+ }
+ var radius = getNodeRadius()
+ var bounds = computeTreeBounds(root)
+ if (!bounds) {
+ return ''
+ }
+ var viewBoxX = bounds.minX - EXPORT_PADDING
+ var viewBoxY = bounds.minY - EXPORT_PADDING
+ var size = getPaddedBoundsSize(bounds)
+ var viewBoxWidth = size.width
+ var viewBoxHeight = size.height
+ 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 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 {
- displayErrorMessage()
+ var fontSize = getNodeFontSize(node.rawLabel, measureContextForSvg())
+ nodeSvg += '' +
+ escapeXml(node.rawLabel) + ''
}
+ nodeSvg += ''
+ nodes.push(nodeSvg)
+ }
+
+ walk(root)
+ var styleBlock = cssText ? '' : ''
+ return '' +
+ ''
+ }
+ var svgMeasureCanvas = null
+ function measureContextForSvg() {
+ if (!svgMeasureCanvas) {
+ svgMeasureCanvas = document.createElement('canvas')
+ }
+ return svgMeasureCanvas.getContext('2d')
+ }
+
+ function buildSvgString() {
+ if (!currentRoot) {
+ return Promise.resolve('')
+ }
+ var cssPromise = (typeof getKatexCss === 'function') ? getKatexCss() : Promise.resolve('')
+ return cssPromise.then(function (cssText) {
+ return treeToSvg(currentRoot, cssText)
+ })
+ }
+
+ function exportSvg() {
+ return buildSvgString().then(function (svgString) {
+ if (!svgString) { return }
+ 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 statusTimeoutId = null
+ function setExportStatus(message, isError) {
+ if (!exportStatus) { return }
+ exportStatus.textContent = message || ''
+ if (isError) {
+ exportStatus.classList.add('error')
+ } else {
+ exportStatus.classList.remove('error')
+ }
+ if (statusTimeoutId) { clearTimeout(statusTimeoutId) }
+ if (message) {
+ statusTimeoutId = setTimeout(function () {
+ exportStatus.textContent = ''
+ exportStatus.classList.remove('error')
+ }, 2000)
+ }
+ }
+
+ function canWriteClipboardItems() {
+ return typeof navigator !== 'undefined' &&
+ navigator.clipboard &&
+ typeof navigator.clipboard.write === 'function' &&
+ typeof window !== 'undefined' &&
+ typeof window.ClipboardItem === 'function'
+ }
+
+ function copyPngToClipboard(scale) {
+ if (!canWriteClipboardItems()) {
+ setExportStatus('Clipboard image copy unsupported', true)
+ return Promise.resolve()
+ }
+ return buildPngBlob(scale).then(function (blob) {
+ if (!blob) { return }
+ var item = new window.ClipboardItem({ 'image/png': blob })
+ return navigator.clipboard.write([item]).then(function () {
+ setExportStatus('Copied!', false)
+ })
+ }).catch(function () {
+ setExportStatus('Copy failed', true)
+ })
+ }
+
+ function copySvgToClipboard() {
+ return buildSvgString().then(function (svgString) {
+ if (!svgString) { return }
+ // Prefer ClipboardItem so paste targets can pick up the SVG MIME
+ // type, but fall back to plain text which works everywhere.
+ if (canWriteClipboardItems()) {
+ var blob = new Blob([svgString], { type: 'image/svg+xml' })
+ var textBlob = new Blob([svgString], { type: 'text/plain' })
+ var item = new window.ClipboardItem({
+ 'image/svg+xml': blob,
+ 'text/plain': textBlob
+ })
+ return navigator.clipboard.write([item]).then(function () {
+ setExportStatus('Copied!', false)
+ }, function () {
+ return navigator.clipboard.writeText(svgString).then(function () {
+ setExportStatus('Copied as text', false)
+ })
+ })
+ }
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ return navigator.clipboard.writeText(svgString).then(function () {
+ setExportStatus('Copied as text', false)
+ })
+ }
+ setExportStatus('Clipboard unsupported', true)
+ }).catch(function () {
+ setExportStatus('Copy failed', true)
+ })
+ }
+
+ 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') {
+ 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)
+ }
+ // Invalidate the rasterized LaTeX cache so labels re-render in the new --fg color.
+ if (typeof invalidateLatexCache === 'function') {
+ invalidateLatexCache()
+ }
+ if (currentRoot) {
+ renderRoot(currentRoot)
+ } else {
+ clearCanvas()
+ }
+ }
+
+ 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 {
- displayErrorMessage()
+ canvas.removeAttribute('role')
+ canvas.removeAttribute('aria-label')
+ }
+ }
+
+ resizeCanvas()
+
+ window.addEventListener('resize', function () {
+ resizeCanvas()
+ if (currentRoot) {
+ renderRoot(currentRoot)
+ } else {
+ 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
+ clearError()
clearCanvas()
+ updateCanvasAccessibility('')
+ input.focus()
})
- document.getElementById('expression-input').value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)]
- setTimeout(() => {
- document.getElementById('generate-tree').click()
- }, 500)
-})();
+ 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
+ })
+
+ if (copyExport) {
+ copyExport.addEventListener('click', function () {
+ if (!currentRoot) {
+ setExportStatus('Nothing to copy', true)
+ return
+ }
+ if (getSelectedExportFormat() === 'svg') {
+ copySvgToClipboard()
+ } else {
+ copyPngToClipboard(parseInt(pngScale.value, 10) || 1)
+ }
+ })
+ }
+ if (copyInput) {
+ var copyInputResetId = null
+ copyInput.addEventListener('click', function () {
+ var text = input.value || ''
+ var showCopied = function () {
+ copyInput.classList.add('copied')
+ if (copyInputResetId) { clearTimeout(copyInputResetId) }
+ copyInputResetId = setTimeout(function () {
+ copyInput.classList.remove('copied')
+ }, 1500)
+ }
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ navigator.clipboard.writeText(text).then(showCopied, function () {
+ // Ignore clipboard rejection (e.g. insecure context).
+ })
+ } 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()
+ document.execCommand('copy')
+ showCopied()
+ } catch (e) {
+ // No clipboard available; silently ignore.
+ }
+ }
+ })
+ }
+ document.addEventListener('click', function (event) {
+ if (!exportPopover.open) {
+ return
+ }
+ if (!exportPopover.contains(event.target)) {
+ exportPopover.open = false
+ }
+ })
-function displayErrorMessage() {
- Swal.fire({
- icon: 'error',
- title: 'Invalid expression',
- html: `
-
- - You may only use these brackets ( ).
- - Use * for multiplication and / for division.
- - Valid operators and operands are:
-