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. +

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

-## 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 '' + + '' + + styleBlock + + '' + lines.join('') + '' + + nodes.join('') + + '' + } + 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:
-
- Operators: [+ - * / ]
- Operands: Any alphabetic letter. -
-
- `, - footer: 'Learn more' + themeToggle.addEventListener('click', function () { + var nextTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark' + applyTheme(nextTheme, true) }) -} + + applyTheme(getPreferredTheme(), false) + syncExportOptionsUi() + + input.value = SAMPLE_EXPRESSIONS[Math.floor(Math.random() * SAMPLE_EXPRESSIONS.length)] + renderExpression() +})(); diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png new file mode 100644 index 0000000..0fc6a9a Binary files /dev/null and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png new file mode 100644 index 0000000..044c6ff Binary files /dev/null and b/docs/screenshot-light.png differ diff --git a/index.html b/index.html index 47fc397..2110b4f 100644 --- a/index.html +++ b/index.html @@ -5,45 +5,107 @@ Expression Tree Generator - - - + + - -
-
-

Expression Tree Generator

-
- Current Expression: - +
+
+
+ + + +
-
- - -
+ -
-
+
+ + + +
+
+ Format + + +
+ +
+ + +
+ +
+
+ + -
- -
+
+ +
- - - + + + + + - - - - - - \ No newline at end of file + diff --git a/infixToPostfix.js b/infixToPostfix.js index 402a6f8..d2bf199 100644 --- a/infixToPostfix.js +++ b/infixToPostfix.js @@ -1,70 +1,357 @@ -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 +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 NUMBER_BODY = /[0-9]/ + +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 isLiteralToken(token) { + return token && typeof token === 'object' && token.type === 'literal' && + (token.kind === 'text' || token.kind === 'latex') && typeof token.value === 'string' +} + +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) || isLiteralToken(token) +} + +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) && (typeof token.arity !== 'number' || token.arity === 0)) || + (isCustomToken(token) && typeof token.arity === 'number' && token.arity === 0) +} + +function tokenize(expression) { + var tokens = [] + for (var i = 0; i < expression.length; i++) { + var current = expression[i] + if (/\s/.test(current)) { + continue + } + if (BINARY_OPERATORS.indexOf(current) !== -1 || current === '(' || current === ')') { + tokens.push(current) + continue + } + if (current === ',') { + 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 + // (e.g. "#\phi" -> LaTeX symbol, vs "#phi" -> plain text "phi"). + if (i + 1 < expression.length && expression[i + 1] === '\\') { + customName = '\\' + i++ + } + if (i + 1 >= expression.length || !CUSTOM_IDENTIFIER_START.test(expression[i + 1])) { + throw new Error("Expected an identifier after '#" + customName + "'") + } + 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) { + ident += expression[i + 1] + i++ + } + if (ident.length === 1) { + tokens.push(ident) + } else if (UNARY_FUNCTIONS.indexOf(ident) !== -1) { + tokens.push(ident) + } else { + throw new Error("Unknown identifier '" + ident + "' (use '#" + ident + "' for a custom function)") + } + continue + } + throw new Error("Invalid character '" + current + "' in expression") } - return false; + return tokens } -function isValidExpression(expr) { - if (expr.length < 3) { - return false; +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) || isNumberToken(token)) { + return { nextIndex: index + 1 } } - for (var i = 0; i < expr.length; i++) { - if ('abcdefghijklmnopqrstuvwxyz*()/+-'.indexOf(expr[i]) === -1) { - return false; + if (token === '(') { + var grouped = parseAddSubtract(tokens, index + 1, stopOnComma) + if (!grouped || tokens[grouped.nextIndex] !== ')') { + return null } + return { nextIndex: grouped.nextIndex + 1 } } - try { - while ("(" === expr[0] && ")" === expr[expr.length - 1]) { - if (isBracketed(expr)) { - expr = expr.substring(1, expr.length - 1); - } else break; + if (UNARY_FUNCTIONS.indexOf(token) !== -1) { + if (tokens[index + 1] !== '(') { + return null } - var res = math.parse(expr); - if ((typeof res.implicit === 'undefined') || res.fn.indexOf('unary') !== -1) { - return false; + var unaryArg = parseAddSubtract(tokens, index + 2, false) + if (!unaryArg || tokens[unaryArg.nextIndex] !== ')') { + return null } - return true; + return { nextIndex: unaryArg.nextIndex + 1 } } - catch (ex) { - return false; + if (isCustomToken(token) || isLiteralToken(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 (isStopToken(tokens[argsIndex], false)) { + 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 describeToken(token) { + if (typeof token === 'undefined') { + return 'end of expression' + } + if (isCustomToken(token)) { + return "'#" + token.name + "'" + } + if (isLiteralToken(token)) { + var quote = token.kind === 'latex' ? '$' : '"' + return quote + token.value + quote + } + 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 infixToPostfix(expression) { - if (!isValidExpression(expression)) { - return null; + var tokens = tokenize(expression) + if (tokens.length === 0) { + throw new Error('Empty expression') } + validateTokens(tokens) const prec = { "*": 3, "/": 3, "-": 2, "+": 2, "(": 1 } - op_stack = [] - postfixList = [] - tokens = expression.split('') - for (const token of tokens) { - if ("abcdefghijklmnopqrstuvwxyz".indexOf(token) !== -1) { + var op_stack = [] + var customCallStack = [] + var postfixList = [] + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i] + if (isVariableToken(token) || isNumberToken(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 (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]) || isLiteralToken(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') { + throw new Error("Unexpected ',' outside of a function call") + } + postfixList.push(commaTop) + commaTop = op_stack.pop() + } + op_stack.push('(') + if (customCallStack.length === 0) { + 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') { + throw new Error("Mismatched ')' with no matching '('") + } postfixList.push(top_op_token) top_op_token = op_stack.pop() } + var function_token = op_stack.slice(-1)[0] + if (isFunctionToken(function_token)) { + var emittedFunction = op_stack.pop() + if (isCustomToken(emittedFunction)) { + var customFrame = customCallStack.pop() + 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) + } + } } else { var peek_elem = op_stack.slice(-1)[0]; - while (op_stack.length > 0 && (prec[peek_elem] >= prec[token])) { + while ( + op_stack.length > 0 && + (isFunctionToken(peek_elem) || prec[peek_elem] >= prec[token]) + ) { postfixList.push(op_stack.pop()) peek_elem = op_stack.slice(-1)[0]; } @@ -72,7 +359,17 @@ function infixToPostfix(expression) { } } while (op_stack.length > 0) { + var tail = op_stack.slice(-1)[0] + if (tail === '(') { + throw new Error("Missing closing ')'") + } + 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 -} \ No newline at end of file +} diff --git a/tree.js b/tree.js index 2578aa9..ab47210 100644 --- a/tree.js +++ b/tree.js @@ -1,101 +1,503 @@ -function Node(value) { - const radius = 32.5 +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 with an +// explicit backslash prefix. Keys are the LaTeX command name (without the +// leading '\'), so '#\pi' -> lookup 'pi' -> '\pi'. +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{...}. +// 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) { + 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. +// 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 } + } + // Detect an optional leading backslash that marks the identifier as a + // LaTeX command. It is stripped while splitting on "_" / trailing digits + // and re-attached before looking up the base identifier. + var prefix = '' + var bare = name + if (bare.charAt(0) === '\\') { + prefix = '\\' + bare = bare.slice(1) + } + // Explicit underscore split: everything after the first `_` becomes the subscript. + var underscoreIdx = bare.indexOf('_') + if (underscoreIdx > 0 && underscoreIdx < bare.length - 1) { + var base = bare.slice(0, underscoreIdx) + var sub = bare.slice(underscoreIdx + 1) + var baseLatex = baseIdentifierToLatex(prefix + base) + return { latex: baseLatex + '_{' + escapeLatexText(sub) + '}', handled: true } + } + // Trailing digits: "x1" -> x_{1}, "theta12" -> \theta_{12}. + var match = bare.match(/^([A-Za-z]+)(\d+)$/) + if (match) { + var baseLatex2 = baseIdentifierToLatex(prefix + 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) +// - "\name" with name in symbol map -> mapped LaTeX command (e.g. "\pi") +// - "\name" not in symbol map -> passed through as raw "\name" command +// - anything else -> \mathrm{name} +function baseIdentifierToLatex(name) { + if (name.charAt(0) === '\\') { + var bare = name.slice(1) + if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOL_MAP, bare)) { + return LATEX_SYMBOL_MAP[bare] + } + // Pass any other "\foo" through so users can reach arbitrary LaTeX + // commands; KaTeX will render it or fall back gracefully. + return name + } + if (name.length === 1) { + return name + } + return '\\mathrm{' + escapeLatexText(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) + '}' + } + 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') { + // 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 + } + return '\\text{' + escapeLatexText(token) + '}' + } + return '\\text{' + escapeLatexText(String(token)) + '}' +} + +function isBinaryOperator(token) { + return TREE_BINARY_OPERATORS.includes(token) +} + +function isUnaryFunction(token) { + return TREE_UNARY_FUNCTIONS.includes(token) +} + +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 + } + if (isUnaryFunction(token)) { + return 1 + } + if (isCustomToken(token) || isLiteralToken(token)) { + return token.arity || 0 + } + return 0 +} + +function getTokenLabel(token) { + if (isCustomToken(token)) { + return token.name + } + if (isLiteralToken(token)) { + return token.value + } + 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))) +} + +// --------------------------------------------------------------------------- +// 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 + } +} + +// Extra pixels added to each side of the measured LaTeX bounding box when +// building the rasterized SVG. getBoundingClientRect() on KaTeX output does +// not account for the italic overshoot of math letters (e.g. the curved +// right edge of an italic `b`), which otherwise gets clipped at the SVG +// boundary. A small padding buffer keeps the glyph fully visible. +const LATEX_RASTER_PADDING_X = 4 +const LATEX_RASTER_PADDING_Y = 2 + +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) + LATEX_RASTER_PADDING_X * 2), + height: Math.max(1, Math.ceil(rect.height) + LATEX_RASTER_PADDING_Y * 2) + } +} + +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 becomes available. + if (typeof window !== 'undefined' && typeof katex === 'undefined') { + var retry = function () { + if (latexImageCache.get(key) === entry && !entry.ready) { + latexImageCache.delete(key) + if (latexRerenderHandler) { latexRerenderHandler() } + } + } + 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 + } + + 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 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) { finish() } + else { pollId = setTimeout(check, 30) } + } + // Cap the wait so a broken image never blocks export forever. + timeoutId = setTimeout(finish, 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.right = null; - this.left = null; + this.children = children || []; - this.isLeaf = () => this.right == null && this.left == null; + Object.defineProperty(this, 'left', { + get: function () { + return self.children[0] || null + }, + set: function (node) { + self.children[0] = node + } + }) + Object.defineProperty(this, 'right', { + get: function () { + return self.children[1] || null + }, + set: function (node) { + self.children[1] = node + } + }) - this.drawEdge = function (context, x, y, left_way, resolve) { - 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) + this.isLeaf = () => this.children.length === 0; + + this.getRadius = function () { + return getNodeRadius() + } + + this.getFontSize = function (context) { + return getNodeFontSize(this.value, context) + } + + this.drawEdge = function (context, childNode) { + const styles = getComputedStyle(document.documentElement) + const radius = this.getRadius(context) + const childRadius = childNode.getRadius(context) + 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) { + 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 + context.beginPath() + context.moveTo(startX, startY) + context.lineTo(endX, endY) + 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) + 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() - context.font = '25px Times New Roman' - context.textAlign = 'center' - context.textBaseline = 'middle' - context.fillStyle = "#212121"; - 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++; + 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) } - return animate } - - requestAnimationFrame(resolveCallback(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; + 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(label, 0, [], latex, label)) } else { - if (shift) { - current.left = new Node(postfix[i]) - current = current.left - shift = false - } else { - current.right = new Node(postfix[i]) - current = current.right + if (stack.length < arity) { + throw new Error('Invalid postfix expression') } + var children = stack.splice(stack.length - arity, arity) + stack.push(new Node(label, arity, children, latex, label)) } - if (OPERATORS.includes(postfix[i])) { - stack.push(current); - } else { - current = stack.pop(); - shift = true - } } - return root; + if (stack.length !== 1) { + throw new Error('Invalid postfix expression') + } + return stack[0]; } function getSize(root) { @@ -103,8 +505,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); @@ -113,40 +516,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) + const HORIZONTAL_SPACING = 80 + 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) + 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 * HORIZONTAL_SPACING + 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) { +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)) + for (var i = 0; i < root.children.length; i++) { + var child = root.children[i] + root.drawEdge(context, child) + drawTree(child, context) } - drawTree(root.left, context) - if (null != root.right) { - await new Promise(resolve => root.drawEdge(context, root.right.x, root.right.y, false, resolve)) - } - drawTree(root.right, context) } -} \ No newline at end of file +}