From 18180c359a682d3481212fd5cf2f1d241aca378a Mon Sep 17 00:00:00 2001 From: maceip Date: Sun, 6 Sep 2026 09:36:17 -0700 Subject: [PATCH 1/2] Render Mermaid diagrams in prose with a kawaii treatment Fenced ```mermaid blocks in agent turns and comments render as inline SVG through a vendored copy of beautiful-mermaid (Craft, MIT) with surgical edits marked AXP: leaf-shaped arrowheads with a faint midrib, rounded corners on every rectangle and polygon vertex, rounded edge bends with round caps, sticker-soft node shadows, dashed rounded subgraphs, pill edge labels, and no remote font @import (the workspace loads nothing third-party). - ui/src/Diagram.tsx renders synchronously, refuses script-like output and falls back to the source with the parse error - Loaded lazily from the Markdown pre renderer; ELK is its own 1.5 MB chunk - Vendored sources live in ui/vendor and are typed through a small .d.ts boundary so the workspace's strict tsconfig applies to our code only - Palette in ui/src/diagram-theme.ts; samples in docs/design/diagrams regenerated by scripts/design/render-diagrams.mts - Demo fixture: the email-task session now includes a flowchart - Notices cover beautiful-mermaid, ELK (EPL-2.0) and entities (BSD-2) --- .prettierignore | 2 + THIRD_PARTY_NOTICES.md | 5 + docs/design/diagrams.md | 67 + docs/design/diagrams/lease.mmd | 10 + docs/design/diagrams/lease.svg | 97 ++ docs/design/diagrams/mail-task.mmd | 15 + docs/design/diagrams/mail-task.svg | 129 ++ docs/design/diagrams/review.mmd | 15 + docs/design/diagrams/review.svg | 97 ++ eslint.config.js | 1 + package-lock.json | 22 + package.json | 2 + scripts/design/render-diagrams.mts | 23 + scripts/ui-notices.mjs | 2 + test/workspace-fixture.ts | 2 +- ui/src/Diagram.tsx | 53 + ui/src/components.tsx | 30 +- ui/src/diagram-theme.ts | 13 + ui/src/style.css | 19 + ui/tsconfig.json | 5 +- ui/types/beautiful-mermaid-axp.d.ts | 27 + ui/vendor/beautiful-mermaid/LICENSE | 21 + ui/vendor/beautiful-mermaid/ascii/ansi.ts | 447 ++++++ ui/vendor/beautiful-mermaid/ascii/canvas.ts | 431 +++++ .../beautiful-mermaid/ascii/class-diagram.ts | 697 ++++++++ .../beautiful-mermaid/ascii/converter.ts | 271 ++++ ui/vendor/beautiful-mermaid/ascii/draw.ts | 1373 ++++++++++++++++ .../beautiful-mermaid/ascii/edge-bundling.ts | 328 ++++ .../beautiful-mermaid/ascii/edge-routing.ts | 296 ++++ .../beautiful-mermaid/ascii/er-diagram.ts | 435 +++++ ui/vendor/beautiful-mermaid/ascii/grid.ts | 578 +++++++ ui/vendor/beautiful-mermaid/ascii/index.ts | 171 ++ .../ascii/multiline-utils.ts | 77 + .../beautiful-mermaid/ascii/pathfinder.ts | 215 +++ ui/vendor/beautiful-mermaid/ascii/sequence.ts | 451 ++++++ .../beautiful-mermaid/ascii/shapes/circle.ts | 27 + .../beautiful-mermaid/ascii/shapes/corners.ts | 127 ++ .../beautiful-mermaid/ascii/shapes/diamond.ts | 27 + .../beautiful-mermaid/ascii/shapes/hexagon.ts | 27 + .../beautiful-mermaid/ascii/shapes/index.ts | 101 ++ .../ascii/shapes/rectangle.ts | 173 ++ .../beautiful-mermaid/ascii/shapes/rounded.ts | 27 + .../beautiful-mermaid/ascii/shapes/special.ts | 293 ++++ .../beautiful-mermaid/ascii/shapes/stadium.ts | 112 ++ .../beautiful-mermaid/ascii/shapes/state.ts | 192 +++ .../beautiful-mermaid/ascii/shapes/types.ts | 73 + ui/vendor/beautiful-mermaid/ascii/types.ts | 273 ++++ ui/vendor/beautiful-mermaid/ascii/validate.ts | 120 ++ ui/vendor/beautiful-mermaid/ascii/xychart.ts | 863 ++++++++++ ui/vendor/beautiful-mermaid/class/layout.ts | 211 +++ ui/vendor/beautiful-mermaid/class/parser.ts | 290 ++++ ui/vendor/beautiful-mermaid/class/renderer.ts | 397 +++++ ui/vendor/beautiful-mermaid/class/types.ts | 121 ++ ui/vendor/beautiful-mermaid/elk-instance.ts | 113 ++ ui/vendor/beautiful-mermaid/er/layout.ts | 161 ++ ui/vendor/beautiful-mermaid/er/parser.ts | 181 +++ ui/vendor/beautiful-mermaid/er/renderer.ts | 420 +++++ ui/vendor/beautiful-mermaid/er/types.ts | 91 ++ ui/vendor/beautiful-mermaid/index.ts | 177 ++ ui/vendor/beautiful-mermaid/layout-engine.ts | 1421 +++++++++++++++++ ui/vendor/beautiful-mermaid/layout.ts | 8 + .../beautiful-mermaid/multiline-utils.ts | 219 +++ ui/vendor/beautiful-mermaid/parser.ts | 645 ++++++++ ui/vendor/beautiful-mermaid/renderer.ts | 667 ++++++++ .../beautiful-mermaid/sequence/layout.ts | 410 +++++ .../beautiful-mermaid/sequence/parser.ts | 207 +++ .../beautiful-mermaid/sequence/renderer.ts | 346 ++++ ui/vendor/beautiful-mermaid/sequence/types.ts | 146 ++ ui/vendor/beautiful-mermaid/shape-clipping.ts | 179 +++ ui/vendor/beautiful-mermaid/styles.ts | 106 ++ ui/vendor/beautiful-mermaid/text-metrics.ts | 246 +++ ui/vendor/beautiful-mermaid/theme.ts | 305 ++++ ui/vendor/beautiful-mermaid/types.ts | 164 ++ ui/vendor/beautiful-mermaid/xychart/colors.ts | 140 ++ ui/vendor/beautiful-mermaid/xychart/layout.ts | 440 +++++ ui/vendor/beautiful-mermaid/xychart/parser.ts | 115 ++ .../beautiful-mermaid/xychart/renderer.ts | 549 +++++++ ui/vendor/beautiful-mermaid/xychart/types.ts | 150 ++ ui/vite.config.ts | 9 + 79 files changed, 17492 insertions(+), 4 deletions(-) create mode 100644 docs/design/diagrams.md create mode 100644 docs/design/diagrams/lease.mmd create mode 100644 docs/design/diagrams/lease.svg create mode 100644 docs/design/diagrams/mail-task.mmd create mode 100644 docs/design/diagrams/mail-task.svg create mode 100644 docs/design/diagrams/review.mmd create mode 100644 docs/design/diagrams/review.svg create mode 100644 scripts/design/render-diagrams.mts create mode 100644 ui/src/Diagram.tsx create mode 100644 ui/src/diagram-theme.ts create mode 100644 ui/types/beautiful-mermaid-axp.d.ts create mode 100644 ui/vendor/beautiful-mermaid/LICENSE create mode 100644 ui/vendor/beautiful-mermaid/ascii/ansi.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/canvas.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/class-diagram.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/converter.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/draw.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/edge-bundling.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/edge-routing.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/er-diagram.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/grid.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/index.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/multiline-utils.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/pathfinder.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/sequence.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/circle.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/corners.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/diamond.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/hexagon.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/index.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/rectangle.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/rounded.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/special.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/stadium.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/state.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/shapes/types.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/types.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/validate.ts create mode 100644 ui/vendor/beautiful-mermaid/ascii/xychart.ts create mode 100644 ui/vendor/beautiful-mermaid/class/layout.ts create mode 100644 ui/vendor/beautiful-mermaid/class/parser.ts create mode 100644 ui/vendor/beautiful-mermaid/class/renderer.ts create mode 100644 ui/vendor/beautiful-mermaid/class/types.ts create mode 100644 ui/vendor/beautiful-mermaid/elk-instance.ts create mode 100644 ui/vendor/beautiful-mermaid/er/layout.ts create mode 100644 ui/vendor/beautiful-mermaid/er/parser.ts create mode 100644 ui/vendor/beautiful-mermaid/er/renderer.ts create mode 100644 ui/vendor/beautiful-mermaid/er/types.ts create mode 100644 ui/vendor/beautiful-mermaid/index.ts create mode 100644 ui/vendor/beautiful-mermaid/layout-engine.ts create mode 100644 ui/vendor/beautiful-mermaid/layout.ts create mode 100644 ui/vendor/beautiful-mermaid/multiline-utils.ts create mode 100644 ui/vendor/beautiful-mermaid/parser.ts create mode 100644 ui/vendor/beautiful-mermaid/renderer.ts create mode 100644 ui/vendor/beautiful-mermaid/sequence/layout.ts create mode 100644 ui/vendor/beautiful-mermaid/sequence/parser.ts create mode 100644 ui/vendor/beautiful-mermaid/sequence/renderer.ts create mode 100644 ui/vendor/beautiful-mermaid/sequence/types.ts create mode 100644 ui/vendor/beautiful-mermaid/shape-clipping.ts create mode 100644 ui/vendor/beautiful-mermaid/styles.ts create mode 100644 ui/vendor/beautiful-mermaid/text-metrics.ts create mode 100644 ui/vendor/beautiful-mermaid/theme.ts create mode 100644 ui/vendor/beautiful-mermaid/types.ts create mode 100644 ui/vendor/beautiful-mermaid/xychart/colors.ts create mode 100644 ui/vendor/beautiful-mermaid/xychart/layout.ts create mode 100644 ui/vendor/beautiful-mermaid/xychart/parser.ts create mode 100644 ui/vendor/beautiful-mermaid/xychart/renderer.ts create mode 100644 ui/vendor/beautiful-mermaid/xychart/types.ts diff --git a/.prettierignore b/.prettierignore index cc7eafa..c3d03a0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ package-lock.json schema test/upstream docs/DOMAIN-DEPLOYMENT-HANDOFF-*.md +ui/vendor/beautiful-mermaid +docs/design/diagrams/*.svg diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b27122f..9b11b14 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,6 +17,11 @@ - Lucide, Lucide contributors, ISC: https://github.com/lucide-icons/lucide - React Markdown and remark-gfm, unified contributors, MIT: https://github.com/remarkjs/react-markdown and https://github.com/remarkjs/remark-gfm +- beautiful-mermaid, Craft Docs, MIT: https://github.com/lukilabs/beautiful-mermaid. + Vendored with the AXP "kawaii" edits in `ui/vendor/beautiful-mermaid`; + its license sits beside the source. It depends on ELK (Eclipse Layout + Kernel, EPL-2.0: https://github.com/kieler/elkjs) and entities (BSD-2-Clause), + whose texts the UI build collects. - DM Sans and IBM Plex Mono font packages, SIL Open Font License 1.1: https://fontsource.org/fonts/dm-sans and https://fontsource.org/fonts/ibm-plex-mono diff --git a/docs/design/diagrams.md b/docs/design/diagrams.md new file mode 100644 index 0000000..1ec71ae --- /dev/null +++ b/docs/design/diagrams.md @@ -0,0 +1,67 @@ +# Diagrams + +## What changed + +Fenced ` ```mermaid ` blocks in any prose (agent turns, discussion comments) +now render as inline SVG. The renderer is +[beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) by Craft +(MIT), vendored at `ui/vendor/beautiful-mermaid` with a handful of surgical +edits that give every diagram the AXP look. Rendering is synchronous and +DOM-free, so the same code draws the review samples in +`docs/design/diagrams/*.svg` from Node. + +Open `docs/design/diagrams/mail-task.svg`, `lease.svg` and `review.svg` to see +a flowchart, a state diagram and nested subgraphs. + +## The treatment + +The goal was "clean and beautiful, and unmistakably ours" without redrawing the +renderer. Each change is a few lines in `renderer.ts` / `styles.ts` / `theme.ts`, +all marked `AXP:` in the source. + +- **Leaf arrowheads.** The spec's triangle became a leaf: pointed tip, two + curved sides, a softly concave base where it meets the stem, and a faint + midrib in the page colour. It is the one detail nobody else has, and it + reads at 100% zoom without shouting. +- **Rounded everything.** Rectangles get a 10px radius, `()` rounded nodes + 14px, and every polygon vertex (diamonds, hexagons, trapezoids, flags) is + rounded with a quadratic curve clamped to the shorter adjacent edge, so + small shapes stay sane. +- **Edges as drawn lines.** ELK's orthogonal routing is kept, but each bend is + rounded (10px) and strokes have round caps. Connectors are 1.75px instead of + a 1px hairline; boxes 1.5px. +- **Sticker nodes.** A soft drop shadow (`#axp-soft`, 1.5px down, 13% ink) sits + every node on the page. +- **Garden-bed groups.** Subgraphs are rounded and dashed, with a header band + whose top corners follow the outline. +- **Pill labels** on edges. +- **No remote font `@import`.** Upstream pulls Inter from Google Fonts inside + the SVG; the workspace never loads third-party resources, so the style block + now inherits the page's self-hosted font instead. + +Colours come from `ui/src/diagram-theme.ts` and mirror the workspace tokens +(paper background, warm charcoal text, sage lines, leaf-green accent). + +## How it is wired + +- `ui/src/Diagram.tsx` renders one block with `useMemo`, refuses output that + contains anything script-like, and falls back to the source with the parse + error underneath. +- `ui/src/components.tsx` overrides the Markdown `pre` renderer: a + `language-mermaid` code block becomes ``, loaded lazily. The layout + engine (ELK) is 1.5 MB, so it is a separate chunk fetched only when prose + contains a diagram. +- `ui/types/beautiful-mermaid-axp.d.ts` is the type boundary. Vite resolves + `beautiful-mermaid-axp` to the vendored sources; TypeScript resolves it to + this declaration, so the workspace's strict settings apply to our code + without rewriting upstream's (which fails only `noUnusedLocals`). +- `scripts/design/render-diagrams.mts` regenerates the sample SVGs. + +## Not done yet + +- Sequence, class and ER diagrams render through their own sub-renderers and + have not received the treatment; flowcharts and state diagrams have. +- The ASCII renderer is vendored but unused. It would let `axp inspect` or + the AAMP result emails draw the same diagrams in plain text. +- A "kawaii" dial (blush dots on decision nodes, a sprout on the start node) + was sketched and left out; the leaf arrowhead carries the identity on its own. diff --git a/docs/design/diagrams/lease.mmd b/docs/design/diagrams/lease.mmd new file mode 100644 index 0000000..883f406 --- /dev/null +++ b/docs/design/diagrams/lease.mmd @@ -0,0 +1,10 @@ +stateDiagram-v2 + [*] --> Connecting + Connecting --> Parked: claim accepted + Parked --> Working: prompt + Working --> Parked: turn settled + Working --> Reconnecting: socket lost + Reconnecting --> Parked: same epoch + Reconnecting --> Stopped: budget revoked + Parked --> Stopped: Ctrl-C + Stopped --> [*] diff --git a/docs/design/diagrams/lease.svg b/docs/design/diagrams/lease.svg new file mode 100644 index 0000000..a8d0cda --- /dev/null +++ b/docs/design/diagrams/lease.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + claim accepted + + + + prompt + + + + turn settled + + + + socket lost + + + + same epoch + + + + budget revoked + + + + Ctrl-C + + + + + + + Connecting + + + + Parked + + + + Working + + + + Reconnecting + + + + Stopped + + + + + + \ No newline at end of file diff --git a/docs/design/diagrams/mail-task.mmd b/docs/design/diagrams/mail-task.mmd new file mode 100644 index 0000000..8a13df5 --- /dev/null +++ b/docs/design/diagrams/mail-task.mmd @@ -0,0 +1,15 @@ +graph TD + A[Email arrives] --> B{Sender allowed?} + B -->|no| C[Warn locally] + B -->|yes| D[Save task] + D --> E([Acknowledge by email]) + D --> F{Session free?} + F -->|no| G[Queue behind current turn] + G --> F + F -->|yes| H[Start AHP turn] + H --> I[[Agent works in worktree]] + I --> J{Tool needs approval?} + J -->|yes| K[Ask a maintainer in AXP] + K --> I + J -->|no| L[(Checkpoint saved)] + L --> M([Reply with result]) diff --git a/docs/design/diagrams/mail-task.svg b/docs/design/diagrams/mail-task.svg new file mode 100644 index 0000000..0baa4df --- /dev/null +++ b/docs/design/diagrams/mail-task.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + no + + + + yes + + + + no + + + + yes + + + + yes + + + + no + + + + Email arrives + + + + Sender allowed? + + + + Warn locally + + + + Save task + + + + Acknowledge by email + + + + Session free? + + + + Queue behind current turn + + + + Start AHP turn + + + + + + Agent works in worktree + + + + Tool needs approval? + + + + Ask a maintainer in AXP + + + + + + + + Checkpoint saved + + + + Reply with result + + \ No newline at end of file diff --git a/docs/design/diagrams/review.mmd b/docs/design/diagrams/review.mmd new file mode 100644 index 0000000..8178dd0 --- /dev/null +++ b/docs/design/diagrams/review.mmd @@ -0,0 +1,15 @@ +graph LR + subgraph Contributor + A[Agent edits worktree] --> B[Checkpoint bundle] + B --> C>Sign manifest] + end + subgraph Maintainer + C --> D{Review changes} + D -->|approve| E[Countersign] + D -->|changes needed| A + end + subgraph Verifier + E --> F[/Restore exact commit\] + F --> G[Run tests] + G --> H(((Verified))) + end diff --git a/docs/design/diagrams/review.svg b/docs/design/diagrams/review.svg new file mode 100644 index 0000000..e0b7e9a --- /dev/null +++ b/docs/design/diagrams/review.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + Contributor + + + + + Maintainer + + + + + Verifier + + + + + + + + + + + + changes needed + + + + approve + + + + Agent edits worktree + + + + Checkpoint bundle + + + + Sign manifest + + + + Review changes + + + + Countersign + + + + Restore exact commit + + + + Run tests + + + + + Verified + + \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 349c231..bceae7c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import js from "@eslint/js"; import ts from "typescript-eslint"; export default ts.config( + { ignores: ["ui/vendor/beautiful-mermaid/**"] }, js.configs.recommended, ...ts.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index fb6270b..443b243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "@types/ws": "^8.18.0", "aamp-sdk": "0.1.24", "c8": "^11.0.0", + "elkjs": "^0.11.0", + "entities": "^7.0.1", "eslint": "^10.0.0", "lucide-react": "1.41.0", "prettier": "^3.9.0", @@ -2124,6 +2126,13 @@ "node": ">=0.3.1" } }, + "node_modules/elkjs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.0.tgz", + "integrity": "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==", + "dev": true, + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2131,6 +2140,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", diff --git a/package.json b/package.json index 880335d..93eb2c2 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "@types/ws": "^8.18.0", "aamp-sdk": "0.1.24", "c8": "^11.0.0", + "elkjs": "^0.11.0", + "entities": "^7.0.1", "eslint": "^10.0.0", "lucide-react": "1.41.0", "prettier": "^3.9.0", diff --git a/scripts/design/render-diagrams.mts b/scripts/design/render-diagrams.mts new file mode 100644 index 0000000..f52f41c --- /dev/null +++ b/scripts/design/render-diagrams.mts @@ -0,0 +1,23 @@ +/* Render the sample diagrams in docs/design/diagrams/*.mmd to SVG with the AXP + * palette, so the kawaii treatment can be reviewed without running the app. + * + * npx tsx scripts/design/render-diagrams.mts + */ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { renderMermaidSVG } from "../../ui/vendor/beautiful-mermaid/index.ts"; +import { AXP_DIAGRAM_COLORS } from "../../ui/src/diagram-theme.ts"; + +const dir = join(import.meta.dirname, "../../docs/design/diagrams"); +const files = (await readdir(dir)).filter((f) => f.endsWith(".mmd")).sort(); +for (const file of files) { + const source = await readFile(join(dir, file), "utf8"); + const svg = renderMermaidSVG(source, { + ...AXP_DIAGRAM_COLORS, + font: "AXP Runde", + transparent: true, + }); + const out = join(dir, file.replace(/\.mmd$/, ".svg")); + await writeFile(out, svg); + console.log(`${file} → ${out.split("/").at(-1)} (${svg.length} bytes)`); +} diff --git a/scripts/ui-notices.mjs b/scripts/ui-notices.mjs index cef32e4..7821d20 100644 --- a/scripts/ui-notices.mjs +++ b/scripts/ui-notices.mjs @@ -12,6 +12,8 @@ const roots = [ "remark-gfm", "@fontsource-variable/dm-sans", "@fontsource/ibm-plex-mono", + "elkjs", + "entities", ]; const packages = new Map(); async function visit(name, parent = process.cwd()) { diff --git a/test/workspace-fixture.ts b/test/workspace-fixture.ts index f95634b..ecc8612 100644 --- a/test/workspace-fixture.ts +++ b/test/workspace-fixture.ts @@ -114,7 +114,7 @@ export async function workspaceFixture() { ? "The parser rejects empty input with a generic error. The patch explains how to fix the input without changing the return type.\n\nThe patch is ready for review." : i === 1 ? "I found the first-run entry point. Before changing it, I need permission to edit the welcome screen." - : "I am checking that tasks survive a restart and that retrying a message does not start the same task twice.", + : "I am checking that tasks survive a restart and that retrying a message does not start the same task twice. Here is the path a task takes:\n\n```mermaid\ngraph TD\n A[Email arrives] --> B{Sender allowed?}\n B -->|no| C[Warn locally]\n B -->|yes| D[Save task]\n D --> E([Acknowledge])\n D --> F{Session free?}\n F -->|no| G[Queue]\n G --> F\n F -->|yes| H[Start turn]\n H --> I[(Checkpoint)]\n I --> J([Reply with result])\n```", }, }, ], diff --git a/ui/src/Diagram.tsx b/ui/src/Diagram.tsx new file mode 100644 index 0000000..f78218f --- /dev/null +++ b/ui/src/Diagram.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { renderMermaidSVG } from "beautiful-mermaid-axp"; +import { AXP_DIAGRAM_COLORS } from "./diagram-theme.js"; + +/* Renders a ```mermaid block as an inline SVG using the vendored + * beautiful-mermaid renderer with the AXP kawaii treatment. Rendering is + * synchronous, so the diagram appears with the rest of the prose; the ELK + * layout engine is large, so this module is loaded lazily by Prose. + * + * The renderer escapes every label and attribute it emits. As a second line + * of defence the output is refused (and the source shown instead) if it + * contains anything script-like. */ + +const UNSAFE = / { + try { + const svg = renderMermaidSVG(code, { + ...AXP_DIAGRAM_COLORS, + font: "AXP Runde", + transparent: true, + }); + if (UNSAFE.test(svg)) return { error: "Diagram output was refused." }; + return { svg }; + } catch (failure) { + return { + error: + failure instanceof Error + ? failure.message + : "Diagram could not be drawn.", + }; + } + }, [code]); + if ("error" in result) { + return ( +
+
+          {code}
+        
+
{result.error}
+
+ ); + } + return ( +
+ ); +} diff --git a/ui/src/components.tsx b/ui/src/components.tsx index 926de37..ee19094 100644 --- a/ui/src/components.tsx +++ b/ui/src/components.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef } from "react"; +import { isValidElement, lazy, memo, Suspense, useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { ArrowUpRight, @@ -15,6 +15,18 @@ import type { WorkspaceView, } from "../../src/workspace-contract.js"; +// The diagram renderer bundles a layout engine; only fetch it when prose has one. +const Diagram = lazy(() => import("./Diagram.js")); + +/** A fenced ```mermaid block arrives as
. */
+function mermaidSource(children: ReactNode): string | null {
+  if (!isValidElement<{ className?: string; children?: ReactNode }>(children))
+    return null;
+  const { className, children: code } = children.props;
+  if (!className?.split(" ").includes("language-mermaid")) return null;
+  return typeof code === "string" ? code : null;
+}
+
 export function Mark({ small = false }: { small?: boolean }) {
   return (
      (
             [Image: {alt || "attachment"}]
           ),
+          pre: ({ children, ...rest }) => {
+            delete (rest as { node?: unknown }).node;
+            const code = mermaidSource(children);
+            if (code === null) return 
{children}
; + return ( + + {code} +
+ } + > + + + ); + }, }} > {text} diff --git a/ui/src/diagram-theme.ts b/ui/src/diagram-theme.ts new file mode 100644 index 0000000..6bc5347 --- /dev/null +++ b/ui/src/diagram-theme.ts @@ -0,0 +1,13 @@ +/* Colours for diagrams rendered in the workspace. Values are literal rather + * than var() references because the same theme is used to render review + * samples outside the browser (scripts/design/render-diagrams.ts). They mirror + * the Huabu-derived tokens in ui/src/vendor/huabu/tokens.css. */ +export const AXP_DIAGRAM_COLORS = { + bg: "#f6f7f4", + fg: "#252c28", + line: "#8aa392", + accent: "#397452", + muted: "#626b65", + surface: "#ffffff", + border: "#cfd9cc", +} as const; diff --git a/ui/src/style.css b/ui/src/style.css index 8ddbe3e..48b59b8 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -1358,6 +1358,25 @@ h1 { border: 1px solid var(--edge-default); text-align: left; } +.diagram { + margin: 14px 0; + padding: 16px 18px; + background: #fbfcf9; + border: 1px solid #e3e9dc; + border-radius: 14px; + overflow: auto; +} +.diagram svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} +.diagram--source figcaption { + font-size: 10px; + color: var(--warning); + margin-top: 8px; +} .prose blockquote { margin-left: 0; border-left: 2px solid #b9c9aa; diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 6e163cd..4488ced 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -12,7 +12,8 @@ "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "noEmit": true, - "types": ["vite/client"] + "types": ["vite/client"], + "paths": { "beautiful-mermaid-axp": ["./types/beautiful-mermaid-axp.d.ts"] } }, - "include": ["src"] + "include": ["src", "types"] } diff --git a/ui/types/beautiful-mermaid-axp.d.ts b/ui/types/beautiful-mermaid-axp.d.ts new file mode 100644 index 0000000..446bd5b --- /dev/null +++ b/ui/types/beautiful-mermaid-axp.d.ts @@ -0,0 +1,27 @@ +/* Type boundary for the vendored renderer in ui/vendor/beautiful-mermaid. + * Vite resolves the same specifier to the TypeScript sources (see + * ui/vite.config.ts); TypeScript resolves it here, so the workspace's strict + * settings apply to our code without rewriting upstream's. */ +declare module "beautiful-mermaid-axp" { + export interface RenderOptions { + bg?: string; + fg?: string; + line?: string; + accent?: string; + muted?: string; + surface?: string; + border?: string; + font?: string; + padding?: number; + nodeSpacing?: number; + layerSpacing?: number; + componentSpacing?: number; + transparent?: boolean; + interactive?: boolean; + } + /** Synchronously render Mermaid text to an SVG string. Throws on parse errors. */ + export function renderMermaidSVG( + text: string, + options?: RenderOptions, + ): string; +} diff --git a/ui/vendor/beautiful-mermaid/LICENSE b/ui/vendor/beautiful-mermaid/LICENSE new file mode 100644 index 0000000..d223453 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Craft Docs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/vendor/beautiful-mermaid/ascii/ansi.ts b/ui/vendor/beautiful-mermaid/ascii/ansi.ts new file mode 100644 index 0000000..fc9b97e --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/ansi.ts @@ -0,0 +1,447 @@ +// ============================================================================ +// ASCII renderer — color utilities +// +// Provides color output for themed ASCII diagrams. +// Supports ANSI terminal modes (16/256/truecolor) and HTML tags +// for browser rendering. +// ============================================================================ + +import type { CharRole, AsciiTheme, ColorMode } from './types.ts' +import type { DiagramColors } from '../theme.ts' +import { MIX } from '../theme.ts' + +declare const document: unknown + +// ============================================================================ +// Default theme — matches SVG theme colors for consistency +// ============================================================================ + +/** + * Default ASCII theme derived from the SVG renderer's color palette. + * Uses the same mixing ratios to maintain visual consistency. + */ +export const DEFAULT_ASCII_THEME: AsciiTheme = { + fg: '#27272a', // zinc-800 — primary text + border: '#a1a1aa', // zinc-400 — node borders (12% mix) + line: '#71717a', // zinc-500 — edge lines (35% mix) + arrow: '#52525b', // zinc-600 — arrowheads (60% mix) + corner: '#71717a', // same as line + junction: '#a1a1aa', // same as border +} + +// ============================================================================ +// DiagramColors → AsciiTheme bridge +// +// Converts SVG DiagramColors into an AsciiTheme using the same MIX ratios +// that the SVG renderer uses via CSS color-mix(). This ensures visual +// consistency between SVG and ASCII output for any theme. +// ============================================================================ + +/** Mix fg into bg at a given percentage (replicates CSS color-mix(in srgb)). */ +function mixColors(fg: string, bg: string, pct: number): string { + const f = parseHex(fg), b = parseHex(bg) + const mix = (a: number, z: number) => Math.round(a * (pct / 100) + z * (1 - pct / 100)) + const r = mix(f.r, b.r), g = mix(f.g, b.g), bl = mix(f.b, b.b) + return '#' + [r, g, bl].map(c => c.toString(16).padStart(2, '0')).join('') +} + +/** + * Derive an AsciiTheme from SVG DiagramColors using the same mixing ratios. + * Honors optional enrichment colors (line, accent, border) when present, + * otherwise falls back to color-mix derivation — matching SVG behavior. + */ +export function diagramColorsToAsciiTheme(colors: DiagramColors): AsciiTheme { + const line = colors.line ?? mixColors(colors.fg, colors.bg, MIX.line) + const border = colors.border ?? mixColors(colors.fg, colors.bg, MIX.nodeStroke) + return { + fg: colors.fg, + border, + line, + arrow: colors.accent ?? mixColors(colors.fg, colors.bg, MIX.arrow), + accent: colors.accent, + bg: colors.bg, + corner: line, + junction: border, + } +} + +// ============================================================================ +// Color mode detection +// ============================================================================ + +/** + * Detect the best color mode for the current environment. + * + * Terminal detection order: + * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor + * 2. TERM contains "256color" → ansi256 + * 3. TERM is set and not "dumb" → ansi16 + * + * Browser: returns 'html' (uses tags with inline styles). + * Unknown/piped: returns 'none'. + */ +export function detectColorMode(): ColorMode { + // Check if we're in a Node.js-like environment with process object + // Use globalThis to safely check for process without TypeScript errors + const proc = (globalThis as { process?: { stdout?: { isTTY?: boolean }, env?: Record } }).process + + if (proc) { + // Check if stdout is a TTY (not piped/redirected) + if (!proc.stdout?.isTTY) { + return 'none' + } + + const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? '' + const term = proc.env?.TERM?.toLowerCase() ?? '' + + // True color support + if (colorTerm === 'truecolor' || colorTerm === '24bit') { + return 'truecolor' + } + + // 256 color support + if (term.includes('256color') || term.includes('256')) { + return 'ansi256' + } + + // Basic color support + if (term && term !== 'dumb') { + return 'ansi16' + } + + return 'none' + } + + // No process object → browser environment → use HTML color output + if (typeof document !== 'undefined') { + return 'html' + } + + return 'none' +} + +// ============================================================================ +// Hex color parsing +// ============================================================================ + +/** + * Parse a hex color string to RGB values. + * Supports both 3-char (#RGB) and 6-char (#RRGGBB) formats. + */ +function parseHex(hex: string): { r: number; g: number; b: number } { + const h = hex.replace('#', '') + if (h.length === 3) { + return { + r: parseInt(h[0]! + h[0]!, 16), + g: parseInt(h[1]! + h[1]!, 16), + b: parseInt(h[2]! + h[2]!, 16), + } + } + return { + r: parseInt(h.substring(0, 2), 16), + g: parseInt(h.substring(2, 4), 16), + b: parseInt(h.substring(4, 6), 16), + } +} + +// ============================================================================ +// ANSI escape code generation +// ============================================================================ + +/** ANSI escape sequence prefix */ +const ESC = '\x1b[' +/** Reset all attributes */ +const RESET = `${ESC}0m` + +/** + * Generate ANSI foreground color escape sequence for 24-bit true color. + * Format: ESC[38;2;R;G;Bm + */ +function truecolorFg(hex: string): string { + const { r, g, b } = parseHex(hex) + return `${ESC}38;2;${r};${g};${b}m` +} + +/** + * Find the closest 256-color palette index for an RGB color. + * The 256-color palette has: + * - 0-15: Standard colors (duplicates of 16-color) + * - 16-231: 6x6x6 color cube (216 colors) + * - 232-255: Grayscale ramp (24 shades) + */ +function rgbTo256(r: number, g: number, b: number): number { + // Check if it's close to grayscale + const avg = (r + g + b) / 3 + const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg)) + + if (maxDiff < 10) { + // Use grayscale ramp (232-255) + // Each step is ~10.625 (256/24) + const gray = Math.round((avg / 255) * 23) + return 232 + Math.min(23, Math.max(0, gray)) + } + + // Use 6x6x6 color cube (16-231) + // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255 + const toIndex = (v: number): number => { + if (v < 48) return 0 + if (v < 115) return 1 + return Math.min(5, Math.floor((v - 35) / 40)) + } + + const ri = toIndex(r) + const gi = toIndex(g) + const bi = toIndex(b) + + return 16 + (36 * ri) + (6 * gi) + bi +} + +/** + * Generate ANSI foreground color escape sequence for 256-color mode. + * Format: ESC[38;5;Nm + */ +function ansi256Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const index = rgbTo256(r, g, b) + return `${ESC}38;5;${index}m` +} + +/** + * Map an RGB color to the closest 16-color ANSI code. + * Returns the foreground color escape sequence. + * + * Standard 16 colors: + * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white + * 8-15 = bright versions + */ +function ansi16Fg(hex: string): string { + const { r, g, b } = parseHex(hex) + const luma = 0.299 * r + 0.587 * g + 0.114 * b + + // Determine brightness (use bright colors for better visibility) + const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset + + // Determine base color based on dominant channel + let code: number + if (r > 180 && g < 100 && b < 100) code = 31 // red + else if (g > 180 && r < 100 && b < 100) code = 32 // green + else if (r > 150 && g > 150 && b < 100) code = 33 // yellow + else if (b > 180 && r < 100 && g < 100) code = 34 // blue + else if (r > 150 && b > 150 && g < 100) code = 35 // magenta + else if (g > 150 && b > 150 && r < 100) code = 36 // cyan + else if (luma > 200) code = 37 // white + else if (luma < 50) code = 30 // black + else code = 37 // default to white for grays + + return `${ESC}${code + bright}m` +} + +// ============================================================================ +// HTML color output (for browser rendering) +// ============================================================================ + +/** Escape characters that would break HTML output. */ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +/** Wrap text in a with an inline color style. */ +function htmlSpan(hex: string, text: string): string { + return `${escapeHtml(text)}` +} + +// ============================================================================ +// Role → color mapping +// ============================================================================ + +/** + * Get the color for a character role from the theme. + */ +function getRoleColor(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +/** + * Generate the ANSI escape sequence for a role color. + */ +export function getAnsiColor(role: CharRole, theme: AsciiTheme, mode: ColorMode): string { + if (mode === 'none') return '' + + const hex = getRoleColor(role, theme) + + switch (mode) { + case 'truecolor': return truecolorFg(hex) + case 'ansi256': return ansi256Fg(hex) + case 'ansi16': return ansi16Fg(hex) + default: return '' + } +} + +/** + * Get the ANSI reset sequence. + */ +export function getAnsiReset(mode: ColorMode): string { + return mode === 'none' ? '' : RESET +} + +/** + * Wrap a character with ANSI color codes based on its role. + */ +export function colorizeChar( + char: string, + role: CharRole | null, + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none' || role === null || char === ' ') { + return char + } + + const colorCode = getAnsiColor(role, theme, mode) + return `${colorCode}${char}${RESET}` +} + +/** + * Colorize an entire line efficiently by grouping consecutive same-role characters. + * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output. + */ +export function colorizeLine( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') { + return chars.join('') + } + + if (mode === 'html') { + return colorizeLineHtml(chars, roles, theme) + } + + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + // Whitespace doesn't need coloring + if (char === ' ') { + // Flush any buffered characters (with or without color) + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + buffer = '' + currentRole = null + } + result += char + continue + } + + // Same role as previous — accumulate + if (role === currentRole) { + buffer += char + continue + } + + // Role changed — flush buffer (with or without color) and start new + if (buffer.length > 0) { + if (currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else { + result += buffer + } + } + buffer = char + currentRole = role + } + + // Flush remaining buffer + if (buffer.length > 0 && currentRole !== null) { + result += getAnsiColor(currentRole, theme, mode) + buffer + RESET + } else if (buffer.length > 0) { + result += buffer + } + + return result +} + +/** + * HTML-specific line colorization. + * Groups consecutive same-role characters into tags with inline color styles. + * Whitespace is emitted bare (no wrapping) to keep output compact. + */ +function colorizeLineHtml( + chars: string[], + roles: (CharRole | null)[], + theme: AsciiTheme, +): string { + let result = '' + let currentRole: CharRole | null = null + let buffer = '' + + const flush = () => { + if (buffer.length === 0) return + if (currentRole !== null) { + result += htmlSpan(getRoleColor(currentRole, theme), buffer) + } else { + result += escapeHtml(buffer) + } + buffer = '' + currentRole = null + } + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + const role = roles[i] ?? null + + if (char === ' ') { + flush() + result += ' ' + continue + } + + if (role === currentRole) { + buffer += char + continue + } + + flush() + buffer = char + currentRole = role + } + + flush() + return result +} + +/** + * Colorize a text string with a direct hex color. + * Used by renderers that need per-cell color control (e.g. multi-series xychart). + * Handles all output modes: ANSI (16/256/truecolor) and HTML. + */ +export function colorizeText(text: string, hex: string, mode: ColorMode): string { + if (mode === 'none' || text.length === 0) return text + if (mode === 'html') return htmlSpan(hex, text) + let code: string + switch (mode) { + case 'truecolor': code = truecolorFg(hex); break + case 'ansi256': code = ansi256Fg(hex); break + case 'ansi16': code = ansi16Fg(hex); break + default: return text + } + return `${code}${text}${RESET}` +} diff --git a/ui/vendor/beautiful-mermaid/ascii/canvas.ts b/ui/vendor/beautiful-mermaid/ascii/canvas.ts new file mode 100644 index 0000000..e9949fe --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/canvas.ts @@ -0,0 +1,431 @@ +// ============================================================================ +// ASCII renderer — 2D text canvas +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go. +// The canvas is a column-major 2D array of single-character strings. +// canvas[x][y] gives the character at column x, row y. +// ============================================================================ + +import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' +import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts' + +/** + * Create a blank canvas filled with spaces. + * Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2). + */ +export function mkCanvas(x: number, y: number): Canvas { + const canvas: Canvas = [] + for (let i = 0; i <= x; i++) { + const col: string[] = [] + for (let j = 0; j <= y; j++) { + col.push(' ') + } + canvas.push(col) + } + return canvas +} + +/** Create a blank canvas with the same dimensions as the given canvas. */ +export function copyCanvas(source: Canvas): Canvas { + const [maxX, maxY] = getCanvasSize(source) + return mkCanvas(maxX, maxY) +} + +// ============================================================================ +// Role canvas creation and management +// ============================================================================ + +/** + * Create a blank role canvas filled with nulls. + * Same dimensions as mkCanvas — column-major, roleCanvas[x][y]. + */ +export function mkRoleCanvas(x: number, y: number): RoleCanvas { + const roleCanvas: RoleCanvas = [] + for (let i = 0; i <= x; i++) { + const col: (CharRole | null)[] = [] + for (let j = 0; j <= y; j++) { + col.push(null) + } + roleCanvas.push(col) + } + return roleCanvas +} + +/** Create a blank role canvas with the same dimensions as the given role canvas. */ +export function copyRoleCanvas(source: RoleCanvas): RoleCanvas { + const maxX = source.length - 1 + const maxY = (source[0]?.length ?? 1) - 1 + return mkRoleCanvas(maxX, maxY) +} + +/** + * Grow the role canvas to fit at least (newX, newY), preserving existing roles. + * Mutates the role canvas in place and returns it. + */ +export function increaseRoleCanvasSize(roleCanvas: RoleCanvas, newX: number, newY: number): RoleCanvas { + const currX = roleCanvas.length - 1 + const currY = (roleCanvas[0]?.length ?? 1) - 1 + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkRoleCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < roleCanvas.length && y < roleCanvas[0]!.length) { + grown[x]![y] = roleCanvas[x]![y]! + } + } + } + roleCanvas.length = 0 + roleCanvas.push(...grown) + return roleCanvas +} + +/** + * Set a role at a specific coordinate. + * Expands the role canvas if necessary. + */ +export function setRole(roleCanvas: RoleCanvas, x: number, y: number, role: CharRole): void { + if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) { + increaseRoleCanvasSize(roleCanvas, x, y) + } + roleCanvas[x]![y] = role +} + +/** + * Merge role canvases — same logic as mergeCanvases but for roles. + * Non-null roles in overlays overwrite null roles in base. + */ +export function mergeRoleCanvases( + base: RoleCanvas, + offset: DrawingCoord, + ...overlays: RoleCanvas[] +): RoleCanvas { + let maxX = base.length - 1 + let maxY = (base[0]?.length ?? 1) - 1 + + for (const overlay of overlays) { + const oX = overlay.length - 1 + const oY = (overlay[0]?.length ?? 1) - 1 + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkRoleCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const role = overlay[x]?.[y] + if (role !== null && role !== undefined) { + const mx = x + offset.x + const my = y + offset.y + merged[mx]![my] = role + } + } + } + } + + return merged +} + +/** Returns [maxX, maxY] — the highest valid indices in each dimension. */ +export function getCanvasSize(canvas: Canvas): [number, number] { + return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1] +} + +/** + * Grow the canvas to fit at least (newX, newY), preserving existing content. + * Mutates the canvas in place and returns it. + */ +export function increaseSize(canvas: Canvas, newX: number, newY: number): Canvas { + const [currX, currY] = getCanvasSize(canvas) + const targetX = Math.max(newX, currX) + const targetY = Math.max(newY, currY) + const grown = mkCanvas(targetX, targetY) + for (let x = 0; x < grown.length; x++) { + for (let y = 0; y < grown[0]!.length; y++) { + if (x < canvas.length && y < canvas[0]!.length) { + grown[x]![y] = canvas[x]![y]! + } + } + } + // Mutate in place: splice old contents and replace with grown + canvas.length = 0 + canvas.push(...grown) + return canvas +} + +// ============================================================================ +// Junction merging — Unicode box-drawing character compositing +// ============================================================================ + +/** All Unicode box-drawing characters that participate in junction merging. */ +const JUNCTION_CHARS = new Set([ + '─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╴', '╵', '╶', '╷', +]) + +export function isJunctionChar(c: string): boolean { + return JUNCTION_CHARS.has(c) +} + +/** Check if a character is alphanumeric (part of a label). */ +function isAlphanumeric(c: string): boolean { + return /^[a-zA-Z0-9]$/.test(c) +} + +/** + * When two junction characters overlap during canvas merging, + * resolve them to the correct combined junction. + * E.g., '─' overlapping '│' becomes '┼'. + */ +const JUNCTION_MAP: Record> = { + '─': { '│': '┼', '┌': '┬', '┐': '┬', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┬', '┴': '┴' }, + '│': { '─': '┼', '┌': '├', '┐': '┤', '└': '├', '┘': '┤', '├': '├', '┤': '┤', '┬': '┼', '┴': '┼' }, + '┌': { '─': '┬', '│': '├', '┐': '┬', '└': '├', '┘': '┼', '├': '├', '┤': '┼', '┬': '┬', '┴': '┼' }, + '┐': { '─': '┬', '│': '┤', '┌': '┬', '└': '┼', '┘': '┤', '├': '┼', '┤': '┤', '┬': '┬', '┴': '┼' }, + '└': { '─': '┴', '│': '├', '┌': '├', '┐': '┼', '┘': '┴', '├': '├', '┤': '┼', '┬': '┼', '┴': '┴' }, + '┘': { '─': '┴', '│': '┤', '┌': '┼', '┐': '┤', '└': '┴', '├': '┼', '┤': '┤', '┬': '┼', '┴': '┴' }, + '├': { '─': '┼', '│': '├', '┌': '├', '┐': '┼', '└': '├', '┘': '┼', '┤': '┼', '┬': '┼', '┴': '┼' }, + '┤': { '─': '┼', '│': '┤', '┌': '┼', '┐': '┤', '└': '┼', '┘': '┤', '├': '┼', '┬': '┼', '┴': '┼' }, + '┬': { '─': '┬', '│': '┼', '┌': '┬', '┐': '┬', '└': '┼', '┘': '┼', '├': '┼', '┤': '┼', '┴': '┼' }, + '┴': { '─': '┴', '│': '┼', '┌': '┼', '┐': '┼', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┼' }, +} + +export function mergeJunctions(c1: string, c2: string): string { + return JUNCTION_MAP[c1]?.[c2] ?? c1 +} + +// ============================================================================ +// Canvas merging — composite multiple canvases with offset +// ============================================================================ + +/** + * Merge overlay canvases onto a base canvas at the given offset. + * Non-space characters in overlays overwrite the base. + * When both characters are Unicode junction chars, they're merged intelligently. + */ +export function mergeCanvases( + base: Canvas, + offset: DrawingCoord, + useAscii: boolean, + ...overlays: Canvas[] +): Canvas { + let [maxX, maxY] = getCanvasSize(base) + for (const overlay of overlays) { + const [oX, oY] = getCanvasSize(overlay) + maxX = Math.max(maxX, oX + offset.x) + maxY = Math.max(maxY, oY + offset.y) + } + + const merged = mkCanvas(maxX, maxY) + + // Copy base + for (let x = 0; x <= maxX; x++) { + for (let y = 0; y <= maxY; y++) { + if (x < base.length && y < base[0]!.length) { + merged[x]![y] = base[x]![y]! + } + } + } + + // Apply overlays + for (const overlay of overlays) { + for (let x = 0; x < overlay.length; x++) { + for (let y = 0; y < overlay[0]!.length; y++) { + const c = overlay[x]![y]! + if (c !== ' ') { + const mx = x + offset.x + const my = y + offset.y + const current = merged[mx]![my]! + if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) { + merged[mx]![my] = mergeJunctions(current, c) + } else if (isAlphanumeric(current) && isAlphanumeric(c)) { + // Don't overwrite existing label text with new label text + // This prevents label collisions (first label wins) + } else { + merged[mx]![my] = c + } + } + } + } + } + + return merged +} + +// ============================================================================ +// Canvas → string conversion +// ============================================================================ + +/** Options for converting canvas to string with optional coloring. */ +export interface CanvasToStringOptions { + /** Role canvas for applying colors. If not provided, output is plain text. */ + roleCanvas?: RoleCanvas + /** Color mode for terminal output. Default: 'none' */ + colorMode?: ColorMode + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: AsciiTheme +} + +/** + * Convert the canvas to a multi-line string (row by row, left to right). + * Optionally applies ANSI color codes based on character roles. + */ +export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): string { + const [maxX, maxY] = getCanvasSize(canvas) + const lines: string[] = [] + + const roleCanvas = options?.roleCanvas + const colorMode = options?.colorMode ?? 'none' + const theme = options?.theme ?? DEFAULT_ASCII_THEME + + for (let y = 0; y <= maxY; y++) { + if (colorMode === 'none' || !roleCanvas) { + // Plain text output — no colors + let line = '' + for (let x = 0; x <= maxX; x++) { + line += canvas[x]![y]! + } + lines.push(line) + } else { + // Colored output — collect chars and roles for this row + const chars: string[] = [] + const roles: (CharRole | null)[] = [] + for (let x = 0; x <= maxX; x++) { + chars.push(canvas[x]![y]!) + roles.push(roleCanvas[x]?.[y] ?? null) + } + lines.push(colorizeLine(chars, roles, theme, colorMode)) + } + } + + return lines.join('\n') +} + +// ============================================================================ +// Canvas vertical flip — used for BT (bottom-to-top) direction support. +// +// The ASCII renderer lays out graphs top-down (TD). For BT direction, we +// flip the finished canvas vertically and remap directional characters so +// arrows point upward and corners are mirrored correctly. +// ============================================================================ + +/** + * Characters that change meaning when the Y-axis is flipped. + * Symmetric characters (─, │, ├, ┤, ┼) are unchanged. + */ +const VERTICAL_FLIP_MAP: Record = { + // Unicode arrows + '▲': '▼', '▼': '▲', + '◤': '◣', '◣': '◤', + '◥': '◢', '◢': '◥', + // ASCII arrows + '^': 'v', 'v': '^', + // Unicode corners + '┌': '└', '└': '┌', + '┐': '┘', '┘': '┐', + // Unicode junctions (T-pieces flip vertically) + '┬': '┴', '┴': '┬', + // Box-start junctions (exit points from node boxes) + '╵': '╷', '╷': '╵', +} + +/** + * Flip the canvas vertically (mirror across the horizontal center). + * Reverses row order within each column and remaps directional characters + * (arrows, corners, junctions) so they point the correct way after flip. + * + * Used to transform a TD-rendered canvas into BT output. + * Mutates the canvas in place and returns it. + */ +export function flipCanvasVertically(canvas: Canvas): Canvas { + // Reverse each column array (Y-axis flip in column-major layout) + for (const col of canvas) { + col.reverse() + } + + // Remap directional characters that change meaning after vertical flip + for (const col of canvas) { + for (let y = 0; y < col.length; y++) { + const flipped = VERTICAL_FLIP_MAP[col[y]!] + if (flipped) col[y] = flipped + } + } + + return canvas +} + +/** + * Flip the role canvas vertically to match flipCanvasVertically. + * Mutates the role canvas in place and returns it. + */ +export function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas { + for (const col of roleCanvas) { + col.reverse() + } + return roleCanvas +} + +/** + * Draw text string onto the canvas starting at the given coordinate. + * By default, preserves existing non-space characters (labels don't overwrite each other). + * Set forceOverwrite=true to always overwrite (for box content). + */ +export function drawText( + canvas: Canvas, + start: DrawingCoord, + text: string, + forceOverwrite = false +): void { + increaseSize(canvas, start.x + text.length, start.y) + for (let i = 0; i < text.length; i++) { + const x = start.x + i + const current = canvas[x]![start.y]! + // Only write if target is empty or we're forcing overwrite + if (forceOverwrite || current === ' ') { + canvas[x]![start.y] = text[i]! + } + } +} + +/** + * Set the canvas size to fit all grid columns and rows. + * Called after layout to ensure the canvas covers the full drawing area. + */ +export function setCanvasSizeToGrid( + canvas: Canvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseSize(canvas, maxX - 1, maxY - 1) +} + +/** + * Set the role canvas size to match the grid dimensions. + * Should be called alongside setCanvasSizeToGrid. + */ +export function setRoleCanvasSizeToGrid( + roleCanvas: RoleCanvas, + columnWidth: Map, + rowHeight: Map, +): void { + let maxX = 0 + let maxY = 0 + for (const w of columnWidth.values()) maxX += w + for (const h of rowHeight.values()) maxY += h + increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/class-diagram.ts b/ui/vendor/beautiful-mermaid/ascii/class-diagram.ts new file mode 100644 index 0000000..95e2c76 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/class-diagram.ts @@ -0,0 +1,697 @@ +// ============================================================================ +// ASCII renderer — class diagrams +// +// Renders classDiagram text to ASCII/Unicode art. +// Each class is a multi-compartment box (header | attributes | methods). +// Relationships are drawn as lines between classes with UML markers. +// +// Layout: level-based top-down. "From" classes are placed above "to" classes +// for all relationship types, matching ELK/mermaid.com behavior. +// Relationship lines use simple Manhattan routing (vertical + horizontal). +// ============================================================================ + +import { parseClassDiagram } from '../class/parser.ts' +import type { ClassDiagram, ClassNode, ClassMember, ClassRelationship, RelationshipType } from '../class/types.ts' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' +import { drawMultiBox } from './draw.ts' +import { splitLines } from './multiline-utils.ts' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Class member formatting +// ============================================================================ + +/** Format a class member as a display string: visibility + name + optional type */ +function formatMember(m: ClassMember): string { + const vis = m.visibility || '' + const type = m.type ? `: ${m.type}` : '' + return `${vis}${m.name}${type}` +} + +/** Build the text sections for a class box: [header], [attributes], [methods] */ +function buildClassSections(cls: ClassNode): string[][] { + // Header section: optional annotation + class name (may be multi-line) + const header: string[] = [] + if (cls.annotation) header.push(`<<${cls.annotation}>>`) + // Support multi-line class names + const nameLines = splitLines(cls.label) + header.push(...nameLines) + + // Attributes section + const attrs = cls.attributes.map(formatMember) + + // Methods section + const methods = cls.methods.map(formatMember) + + // If no attrs and no methods, just return header (1-section box) + if (attrs.length === 0 && methods.length === 0) return [header] + // If no methods, return header + attrs (2-section box) + if (methods.length === 0) return [header, attrs] + // Full 3-section box + return [header, attrs, methods] +} + +// ============================================================================ +// Relationship marker characters +// ============================================================================ + +interface RelMarker { + /** Relationship type (determines marker shape) */ + type: RelationshipType + /** Which end the marker is placed at */ + markerAt: 'from' | 'to' + /** Whether the line is dashed */ + dashed: boolean +} + +/** + * Build the marker metadata for a relationship. + * The actual marker character will be determined at placement time based on line direction. + */ +function getRelMarker(type: RelationshipType, markerAt: 'from' | 'to'): RelMarker { + const dashed = type === 'dependency' || type === 'realization' + return { type, markerAt, dashed } +} + +/** + * Get the UML marker shape character for a relationship type. + * For directional arrows (association/dependency), the direction parameter + * specifies which way the arrow should point. + */ +function getMarkerShape( + type: RelationshipType, + useAscii: boolean, + direction?: 'up' | 'down' | 'left' | 'right' +): string { + switch (type) { + case 'inheritance': + case 'realization': + // Hollow triangle - rotate based on line direction + // Triangle points TOWARD the parent class + if (direction === 'down') { + // Line goes down (parent above, child below) - triangle points UP + return useAscii ? '^' : '△' + } else if (direction === 'up') { + // Line goes up (parent below, child above) - triangle points DOWN + return useAscii ? 'v' : '▽' + } else if (direction === 'left') { + // Line goes left - triangle points LEFT + return useAscii ? '>' : '◁' + } else { + // Default: line goes right - triangle points RIGHT + return useAscii ? '<' : '▷' + } + case 'composition': + // Filled diamond - omnidirectional shape + return useAscii ? '*' : '◆' + case 'aggregation': + // Hollow diamond - omnidirectional shape + return useAscii ? 'o' : '◇' + case 'association': + case 'dependency': + // Directional arrow - rotate based on line direction + if (direction === 'down') { + return useAscii ? 'v' : '▼' + } else if (direction === 'up') { + return useAscii ? '^' : '▲' + } else if (direction === 'left') { + return useAscii ? '<' : '◀' + } else { + // Default to right (or when direction not specified) + return useAscii ? '>' : '▶' + } + } +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** Positioned class node on the canvas */ +interface PlacedClass { + cls: ClassNode + sections: string[][] + x: number + y: number + width: number + height: number +} + +/** + * Render a Mermaid class diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string. + */ +export function renderClassAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseClassDiagram(lines) + + if (diagram.classes.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 4 // horizontal gap between class boxes + const vGap = 3 // vertical gap between levels (enough for relationship lines) + + // --- Build box dimensions for each class --- + const classSections = new Map() + const classBoxW = new Map() + const classBoxH = new Map() + + for (const cls of diagram.classes) { + const sections = buildClassSections(cls) + classSections.set(cls.id, sections) + + // Compute box dimensions from drawMultiBox logic + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, line.length) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 // section lines + dividers + top/bottom border + + classBoxW.set(cls.id, boxW) + classBoxH.set(cls.id, boxH) + } + + // --- Assign levels: topological sort based on directed relationships --- + // All relationship types place "from" above "to" in the layout, matching + // ELK's layered algorithm and the official mermaid.com renderer behavior. + // For "Animal <|-- Dog": from="Animal", to="Dog" → Animal above Dog. + // + // Every relationship type (including association and dependency) forces nodes + // to different levels. Same-row routing for mixed diagrams causes collisions: + // detour lines overlap with cross-level routing, and labels overwrite box borders. + + const classById = new Map() + for (const cls of diagram.classes) classById.set(cls.id, cls) + + const parents = new Map>() // child → set of parent IDs + const children = new Map>() // parent → set of child IDs + + for (const rel of diagram.relationships) { + // For inheritance/realization, the marker (hollow triangle) points to the parent. + // - `Animal <|-- Dog` (markerAt='from'): Animal is parent, Dog is child + // - `Bird ..|> Flyable` (markerAt='to'): Flyable is parent, Bird is child + // For other relationships, use the default from→to direction. + const isHierarchical = rel.type === 'inheritance' || rel.type === 'realization' + const parentId = isHierarchical && rel.markerAt === 'to' ? rel.to : rel.from + const childId = isHierarchical && rel.markerAt === 'to' ? rel.from : rel.to + + if (!parents.has(childId)) parents.set(childId, new Set()) + parents.get(childId)!.add(parentId) + if (!children.has(parentId)) children.set(parentId, new Set()) + children.get(parentId)!.add(childId) + } + + // BFS from roots (classes that have no parents) to assign levels. + // Cap at classes.length - 1 to prevent infinite loops on cyclic graphs + // (e.g. View --> Model and Model ..> View would otherwise push levels + // upward forever). In a DAG the longest path has at most N-1 edges. + const level = new Map() + const roots = diagram.classes.filter(c => !parents.has(c.id) || parents.get(c.id)!.size === 0) + const queue: string[] = roots.map(c => c.id) + for (const id of queue) level.set(id, 0) + + const levelCap = diagram.classes.length - 1 + let qi = 0 + while (qi < queue.length) { + const id = queue[qi++]! + const childSet = children.get(id) + if (!childSet) continue + for (const childId of childSet) { + const newLevel = (level.get(id) ?? 0) + 1 + if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop + if (!level.has(childId) || level.get(childId)! < newLevel) { + level.set(childId, newLevel) + queue.push(childId) + } + } + } + + // Assign remaining (unconnected) classes to level 0 + for (const cls of diagram.classes) { + if (!level.has(cls.id)) level.set(cls.id, 0) + } + + // --- Position classes by level --- + // Group classes by level + const maxLevel = Math.max(...[...level.values()], 0) + const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => []) + for (const cls of diagram.classes) { + levelGroups[level.get(cls.id)!]!.push(cls.id) + } + + // Compute positions: each level is a row, classes in a row are spaced horizontally + const placed = new Map() + let currentY = 0 + + for (let lv = 0; lv <= maxLevel; lv++) { + const group = levelGroups[lv]! + if (group.length === 0) continue + + let currentX = 0 + let maxH = 0 + + for (const id of group) { + const cls = classById.get(id)! + const w = classBoxW.get(id)! + const h = classBoxH.get(id)! + placed.set(id, { + cls, + sections: classSections.get(id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + currentX += w + hGap + maxH = Math.max(maxH, h) + } + + currentY += maxH + vGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + + // Extra space for relationship lines that may go below/beside + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw class boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + // Copy box onto main canvas at (p.x, p.y) with role tracking + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Build occupancy map for collision avoidance --- + // Track which x positions are occupied at each y level (to avoid routing through boxes) + const boxOccupancy: { x1: number; x2: number; y1: number; y2: number }[] = [] + for (const p of placed.values()) { + boxOccupancy.push({ + x1: p.x, + x2: p.x + p.width - 1, + y1: p.y, + y2: p.y + p.height - 1, + }) + } + + /** Check if a point (x, y) is inside any class box */ + function isInsideBox(x: number, y: number, excludeIds?: Set): boolean { + for (const [id, p] of placed.entries()) { + if (excludeIds?.has(id)) continue + if (x >= p.x && x <= p.x + p.width - 1 && y >= p.y && y <= p.y + p.height - 1) { + return true + } + } + return false + } + + /** Find a clear vertical column for routing that doesn't pass through any boxes */ + function findClearColumn(startX: number, y1: number, y2: number, excludeIds: Set): number { + // Try the original column first + let clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(startX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return startX + + // Try columns to the left and right, alternating + for (let offset = 1; offset < totalW + 10; offset++) { + // Try right + const rightX = startX + offset + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(rightX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return rightX + + // Try left + const leftX = startX - offset + if (leftX >= 0) { + clear = true + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + if (isInsideBox(leftX, y, excludeIds)) { + clear = false + break + } + } + if (clear) return leftX + } + } + + // Fallback to right edge of canvas + some extra space + return totalW + 2 + } + + // --- Draw relationship lines --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const fromP = placed.get(rel.from) + const toP = placed.get(rel.to) + if (!fromP || !toP) continue + + const marker = getRelMarker(rel.type, rel.markerAt) + const lineH = marker.dashed ? dashH : H + const lineV = marker.dashed ? dashV : V + + // Exclude source and target boxes from collision detection + const excludeIds = new Set([rel.from, rel.to]) + + // Connection points: center-bottom of source → center-top of target + const fromCX = fromP.x + Math.floor(fromP.width / 2) + const fromBY = fromP.y + fromP.height - 1 + const toCX = toP.x + Math.floor(toP.width / 2) + const toTY = toP.y + + // Route: Manhattan routing with collision avoidance + // If target is below source: vertical down from source, horizontal if needed, vertical down to target + // If same row: horizontal line with a small vertical detour above or below + if (fromBY < toTY) { + // Target is below source — routing with collision avoidance + // Find a clear vertical column for the ENTIRE path from source to target + const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds) + const needsDetour = routeX !== fromCX + + // Expand canvas if needed to accommodate routing column + if (routeX >= totalW) { + increaseSize(canvas, routeX + 2, totalH) + } + + if (needsDetour) { + // COLLISION CASE: Route around intermediate boxes + // Path: source center → horizontal to routeX → vertical to entry → horizontal to target center + + const exitY = fromBY + 1 + const entryY = toTY - 1 + + // 1. Horizontal from source center to route column + const lx1 = Math.min(fromCX, routeX) + const rx1 = Math.max(fromCX, routeX) + for (let x = lx1; x <= rx1; x++) { + setC(x, exitY, lineH, 'line') + } + if (!useAscii && exitY < (canvas[0]?.length ?? 0)) { + if (fromCX < routeX) { + setC(fromCX, exitY, '└', 'corner') + setC(routeX, exitY, '┐', 'corner') + } else { + setC(fromCX, exitY, '┘', 'corner') + setC(routeX, exitY, '┌', 'corner') + } + } + + // 2. Vertical at routeX from exit to entry + for (let y = exitY + 1; y <= entryY; y++) { + setC(routeX, y, lineV, 'line') + } + + // 3. Horizontal from routeX to target center at entry + if (routeX !== toCX) { + const lx2 = Math.min(routeX, toCX) + const rx2 = Math.max(routeX, toCX) + for (let x = lx2; x <= rx2; x++) { + setC(x, entryY, lineH, 'line') + } + if (!useAscii && entryY < (canvas[0]?.length ?? 0)) { + if (routeX < toCX) { + setC(routeX, entryY, '└', 'corner') + setC(toCX, entryY, '┐', 'corner') + } else { + setC(routeX, entryY, '┘', 'corner') + setC(toCX, entryY, '┌', 'corner') + } + } + } + + // Markers for detour case + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(toCX, entryY, markerChar, 'arrow') + } + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + setC(fromCX, fromBY + 1, markerChar, 'arrow') + } + } else { + // NO COLLISION CASE: Use original midpoint-based routing + // Path: source center → vertical to midY → horizontal at midY → vertical to target + + const midY = fromBY + Math.floor((toTY - fromBY) / 2) + + // 1. Vertical from source bottom to midY + for (let y = fromBY + 1; y <= midY; y++) { + setC(fromCX, y, lineV, 'line') + } + + // 2. Horizontal from fromCX to toCX at midY (if needed) + if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii) { + setC(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner') + setC(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner') + } + } + + // 3. Vertical from midY to target top + for (let y = midY + 1; y < toTY; y++) { + setC(toCX, y, lineV, 'line') + } + + // Markers for no-collision case + if (marker.markerAt === 'to') { + setC(toCX, toTY - 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + if (marker.markerAt === 'from') { + setC(fromCX, fromBY + 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow') + } + } + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target is ABOVE source — draw upward from source top to target bottom + const fromTY = fromP.y + const toBY = toP.y + toP.height - 1 + const midY = toBY + Math.floor((fromTY - toBY) / 2) + + for (let y = fromTY - 1; y >= midY; y--) { + setC(fromCX, y, lineV, 'line') + } + + if (fromCX !== toCX) { + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + if (!useAscii && midY >= 0 && midY < totalH) { + setC(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner') + setC(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner') + } + } + + for (let y = midY - 1; y > toBY; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - arrows point in the direction of the vertical segment (upward) + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = fromTY - 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const isHierarchical = marker.type === 'inheritance' || marker.type === 'realization' + const markerDir = isHierarchical ? 'down' : 'up' + const markerChar = getMarkerShape(marker.type, useAscii, markerDir) + const my = toBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } else { + // Same level — draw horizontal line with a detour below both boxes + const detourY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + increaseSize(canvas, totalW, detourY + 1) + increaseRoleCanvasSize(rc, totalW, detourY + 1) + + // Vertical down from source + for (let y = fromBY + 1; y <= detourY; y++) { + setC(fromCX, y, lineV, 'line') + } + // Horizontal + const lx = Math.min(fromCX, toCX) + const rx = Math.max(fromCX, toCX) + for (let x = lx; x <= rx; x++) { + setC(x, detourY, lineH, 'line') + } + // Vertical up to target + for (let y = detourY - 1; y >= toP.y + toP.height; y--) { + setC(toCX, y, lineV, 'line') + } + + // Draw markers - same-level routing uses vertical segments at both ends + if (marker.markerAt === 'from') { + const markerChar = getMarkerShape(marker.type, useAscii, 'down') + const my = fromBY + 1 + for (let i = 0; i < markerChar.length; i++) { + setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + if (marker.markerAt === 'to') { + const markerChar = getMarkerShape(marker.type, useAscii, 'up') + const my = toP.y + toP.height + for (let i = 0; i < markerChar.length; i++) { + setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow') + } + } + } + + // Draw relationship label at midpoint if present (supports multi-line) + // Add padding around the label for readability + if (rel.label) { + const lines = splitLines(rel.label) + const maxLabelWidth = Math.max(...lines.map(l => l.length)) + 2 // +2 for padding + + // Calculate ideal label position based on routing direction + let baseMidY: number + let idealMidX: number + + if (fromBY < toTY) { + // Target below source: place in gap between source bottom and target top + baseMidY = Math.floor((fromBY + 1 + toTY - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else if (toP.y + toP.height - 1 < fromP.y) { + // Target above source: place in gap between target bottom and source top + const toBY = toP.y + toP.height - 1 + baseMidY = Math.floor((toBY + 1 + fromP.y - 1) / 2) + idealMidX = Math.floor((fromCX + toCX) / 2) + } else { + // Same level: place label at midpoint of the detour line + baseMidY = Math.max(fromBY, toP.y + toP.height - 1) + 2 + idealMidX = Math.floor((fromCX + toCX) / 2) + } + + // Find a clear vertical position for the label (not inside any box) + let labelY = baseMidY + const halfHeight = Math.floor(lines.length / 2) + + // Check if any label line would be inside a box + let labelInBox = false + for (let i = 0; i < lines.length; i++) { + const y = labelY - halfHeight + i + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + // Check if this line overlaps any box + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + labelInBox = true + break + } + } + if (labelInBox) break + } + + // If label is inside a box, find the gap between boxes + if (labelInBox) { + // Find the gap between source and target boxes + const gapTop = fromBY + 1 + const gapBottom = toTY - 1 + + // Place label in the middle of the gap, outside any intermediate box + for (let y = gapTop; y <= gapBottom; y++) { + let clearRow = true + const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2) + const labelStart = Math.max(0, idealLabelStart) + for (let x = labelStart; x < labelStart + maxLabelWidth; x++) { + if (isInsideBox(x, y, excludeIds)) { + clearRow = false + break + } + } + if (clearRow) { + labelY = y + break + } + } + } + + // Center lines vertically around labelY + const startY = labelY - halfHeight + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const paddedLine = ` ${lines[lineIdx]!} ` // Add space padding on both sides + // Calculate label start, but ensure it doesn't go negative + const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2) + const labelStart = Math.max(0, idealLabelStart) + const y = startY + lineIdx + // Ensure canvas is wide enough for the label + const labelEnd = labelStart + paddedLine.length + if (labelEnd > 0 && y >= 0) { + increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1)) + } + // Clear the area first (overwrite line characters) then draw the padded label + for (let i = 0; i < paddedLine.length; i++) { + const lx = labelStart + i + if (lx >= 0 && y >= 0) { + setC(lx, y, paddedLine[i]!, 'text') + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/converter.ts b/ui/vendor/beautiful-mermaid/ascii/converter.ts new file mode 100644 index 0000000..ac533d5 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/converter.ts @@ -0,0 +1,271 @@ +// ============================================================================ +// ASCII renderer — MermaidGraph → AsciiGraph converter +// +// Bridges the existing TypeScript parser output to the ASCII renderer's +// internal graph structure. This avoids maintaining a separate parser +// for ASCII rendering — we reuse parseMermaid() and convert its output. +// ============================================================================ + +import type { MermaidGraph, MermaidSubgraph } from '../types.ts' +import type { + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiConfig, +} from './types.ts' +import { EMPTY_STYLE } from './types.ts' +import { mkCanvas, mkRoleCanvas } from './canvas.ts' + +/** + * Convert a parsed MermaidGraph into an AsciiGraph ready for grid layout. + * + * Key mappings: + * - MermaidGraph.nodes (Map) → ordered AsciiNode[] preserving insertion order + * - MermaidGraph.edges → AsciiEdge[] with resolved node references + * - MermaidGraph.subgraphs → AsciiSubgraph[] with parent/child tree + * - Node labels are used as display names (not raw IDs) + */ +export function convertToAsciiGraph(parsed: MermaidGraph, config: AsciiConfig): AsciiGraph { + // Build node list preserving Map insertion order + const nodeMap = new Map() + let index = 0 + + for (const [id, mNode] of parsed.nodes) { + const asciiNode: AsciiNode = { + // Use the parser ID as the unique identity key to avoid collisions + // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]). + name: id, + // The label is used for rendering inside the box. + displayLabel: mNode.label, + // Preserve shape from parser for shape-aware rendering + shape: mNode.shape, + index, + gridCoord: null, + drawingCoord: null, + drawing: null, + drawn: false, + styleClassName: '', + styleClass: EMPTY_STYLE, + } + nodeMap.set(id, asciiNode) + index++ + } + + const nodes = [...nodeMap.values()] + + // Build edges with resolved node references + const edges: AsciiEdge[] = [] + for (const mEdge of parsed.edges) { + const from = nodeMap.get(mEdge.source) + const to = nodeMap.get(mEdge.target) + if (!from || !to) continue + + edges.push({ + from, + to, + text: mEdge.label ?? '', + path: [], + labelLine: [], + startDir: { x: 0, y: 0 }, + endDir: { x: 0, y: 0 }, + style: mEdge.style, + hasArrowStart: mEdge.hasArrowStart, + hasArrowEnd: mEdge.hasArrowEnd, + }) + } + + // Convert subgraphs recursively + const subgraphs: AsciiSubgraph[] = [] + for (const mSg of parsed.subgraphs) { + convertSubgraph(mSg, null, nodeMap, subgraphs) + } + + // Deduplicate subgraph node membership to match Go parser behavior. + // In Go, a node belongs only to the subgraph where it was FIRST DEFINED. + // The TS parser adds referenced nodes to all subgraphs they appear in, + // which causes incorrect bounding boxes when nodes span subgraph boundaries. + deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed) + + // Apply class definitions + for (const [nodeId, className] of parsed.classAssignments) { + const node = nodeMap.get(nodeId) + const classDef = parsed.classDefs.get(className) + if (node && classDef) { + node.styleClassName = className + node.styleClass = { name: className, styles: classDef } + } + } + + return { + nodes, + edges, + canvas: mkCanvas(0, 0), + roleCanvas: mkRoleCanvas(0, 0), + grid: new Map(), + columnWidth: new Map(), + rowHeight: new Map(), + subgraphs, + config, + offsetX: 0, + offsetY: 0, + bundles: [], // Populated by analyzeEdgeBundles() during layout + } +} + +/** + * Recursively convert a MermaidSubgraph to AsciiSubgraph. + * Flattens the tree into the subgraphs array while maintaining parent/child references. + * This matches the Go implementation where all subgraphs are in a flat list + * but linked via parent/children pointers. + */ +function convertSubgraph( + mSg: MermaidSubgraph, + parent: AsciiSubgraph | null, + nodeMap: Map, + allSubgraphs: AsciiSubgraph[], +): AsciiSubgraph { + // Normalize subgraph direction: BT→TD, RL→LR (same as root graph normalization) + let normalizedDirection: 'LR' | 'TD' | undefined + if (mSg.direction) { + normalizedDirection = (mSg.direction === 'LR' || mSg.direction === 'RL') ? 'LR' : 'TD' + } + + const sg: AsciiSubgraph = { + name: mSg.label, + nodes: [], + parent, + children: [], + minX: 0, minY: 0, maxX: 0, maxY: 0, + direction: normalizedDirection, + } + + // Resolve node references + for (const nodeId of mSg.nodeIds) { + const node = nodeMap.get(nodeId) + if (node) sg.nodes.push(node) + } + + allSubgraphs.push(sg) + + // Recurse into children + for (const childMSg of mSg.children) { + const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs) + sg.children.push(child) + + // Child nodes are also part of parent subgraphs (Go behavior). + // The Go parser adds nodes to ALL subgraphs in the stack, so a nested + // node belongs to both the inner and outer subgraph. + for (const childNode of child.nodes) { + if (!sg.nodes.includes(childNode)) { + sg.nodes.push(childNode) + } + } + } + + return sg +} + +/** + * Deduplicate subgraph node membership to match Go parser behavior. + * + * The Go parser only adds a node to the subgraph that was active when the node + * was FIRST CREATED. If a node is later referenced inside a different subgraph, + * it is NOT added to that subgraph. The TS parser is more permissive — it adds + * referenced nodes to whichever subgraph they appear in. + * + * This function fixes the discrepancy by: + * 1. Walking the edges to determine which nodes were first created inside each subgraph + * 2. Removing nodes from subgraphs where they weren't first created + */ +function deduplicateSubgraphNodes( + mermaidSubgraphs: MermaidSubgraph[], + asciiSubgraphs: AsciiSubgraph[], + nodeMap: Map, + parsed: MermaidGraph, +): void { + // Build a map from MermaidSubgraph to its corresponding AsciiSubgraph. + // The ordering matches since we convert them in the same order. + const sgMap = new Map() + buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap) + + // Determine which subgraph each node was "first defined" in. + // A node is first defined in the subgraph where it first appears as a NEW node + // in the ordered edge/node list. We approximate this by checking the global + // node insertion order against subgraph membership. + const nodeOwner = new Map() // nodeId → owning subgraph + + // Walk all mermaid subgraphs in document order. For each subgraph, + // claim nodes that haven't been claimed yet by any previous subgraph. + function claimNodes(mSg: MermaidSubgraph): void { + const asciiSg = sgMap.get(mSg) + if (!asciiSg) return + + // Recurse into children first (they appear before parent in the Go parser stack, + // but nodes defined in children are added to parent too — this is handled by + // the convertSubgraph function which propagates child nodes to parents). + // For dedup, we process children first so their claims propagate up correctly. + for (const child of mSg.children) { + claimNodes(child) + } + + // Claim unclaimed nodes in this subgraph + for (const nodeId of mSg.nodeIds) { + if (!nodeOwner.has(nodeId)) { + nodeOwner.set(nodeId, asciiSg) + } + } + } + + for (const mSg of mermaidSubgraphs) { + claimNodes(mSg) + } + + // Now remove nodes from subgraphs that don't own them. + // A node should remain in: its owner subgraph + all ancestors of the owner. + for (const asciiSg of asciiSubgraphs) { + asciiSg.nodes = asciiSg.nodes.filter(node => { + // Find this node's ID in the nodeMap + let nodeId: string | undefined + for (const [id, n] of nodeMap) { + if (n === node) { nodeId = id; break } + } + if (!nodeId) return false + + const owner = nodeOwner.get(nodeId) + if (!owner) return true // not in any subgraph claim — keep as-is + + // Keep the node if this subgraph is the owner or an ancestor of the owner + return isAncestorOrSelf(asciiSg, owner) + }) + } +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** Build a mapping from MermaidSubgraph → AsciiSubgraph (matching by position). */ +function buildSgMap( + mSgs: MermaidSubgraph[], + aSgs: AsciiSubgraph[], + result: Map, +): void { + // The asciiSubgraphs array is flat (all subgraphs including nested ones), + // while mermaidSubgraphs is hierarchical. We need to flatten the mermaid tree + // in the same order the converter processes them (pre-order DFS). + const flatMermaid: MermaidSubgraph[] = [] + function flatten(sgs: MermaidSubgraph[]): void { + for (const sg of sgs) { + flatMermaid.push(sg) + flatten(sg.children) + } + } + flatten(mSgs) + + for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) { + result.set(flatMermaid[i]!, aSgs[i]!) + } +} diff --git a/ui/vendor/beautiful-mermaid/ascii/draw.ts b/ui/vendor/beautiful-mermaid/ascii/draw.ts new file mode 100644 index 0000000..f8020f5 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/draw.ts @@ -0,0 +1,1373 @@ +// ============================================================================ +// ASCII renderer — drawing operations +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go + cmd/arrow.go. +// Contains all visual rendering: boxes, lines, arrows, corners, +// subgraphs, labels, and the top-level draw orchestrator. +// ============================================================================ + +import type { + Canvas, DrawingCoord, GridCoord, Direction, + AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiEdgeStyle, EdgeBundle, +} from './types.ts' +import { + Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, + drawingCoordEquals, +} from './types.ts' +import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas.ts' +import type { RoleCanvas, CharRole } from './types.ts' +import { determineDirection, dirEquals } from './edge-routing.ts' +import { gridToDrawingCoord, lineToDrawing } from './grid.ts' +import { splitLines } from './multiline-utils.ts' +import { getCorners } from './shapes/corners.ts' +import { getShapeAttachmentPoint } from './shapes/index.ts' + +// ============================================================================ +// Node drawing — renders a node using shape-aware rendering +// ============================================================================ + +/** + * Draw a node using its shape type. + * Returns a standalone canvas containing the rendered shape. + * + * For basic shapes (rectangle, rounded), uses grid-determined dimensions + * to ensure consistent sizing across nodes in the same column. + * For special shapes (diamond, circle, state pseudo-states, etc.), + * uses shape-specific dimension calculation but centers the content + * within the grid cell dimensions to ensure proper vertical alignment. + */ +export function drawNode(node: AsciiNode, graph: AsciiGraph): Canvas { + // All shapes use grid-determined dimensions to fill their allocated space. + // This ensures consistent sizing across nodes and eliminates gaps between + // nodes and subgraph borders. All shapes are rectangles with distinctive + // corner characters (defined in corners.ts) to indicate shape type. + return drawBoxWithGridDimensions(node, graph) +} + +/** + * Draw a box shape using grid-determined dimensions. + * This ensures consistent sizing when multiple nodes share a column, + * and eliminates gaps between nodes and subgraph borders by filling + * the entire allocated grid space. + * + * All shapes are rendered as rectangles with distinctive corner characters + * (defined in corners.ts) to indicate shape type. + */ +function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { + const gc = node.gridCoord! + const useAscii = graph.config.useAscii + + // Width spans 2 columns (border + content) - matching original behavior + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + // Height spans 2 rows (border + content) + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: w, y: h } + const box = mkCanvas(Math.max(from.x, to.x), Math.max(from.y, to.y)) + + // Get corner characters for this shape type + const corners = getCorners(node.shape, useAscii) + + // State-end uses double border to differentiate from state-start + const isDoubleBox = node.shape === 'state-end' + const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─') + const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│') + + // Double-box corners (for state-end) + const doubleCorners = useAscii + ? { tl: '#', tr: '#', bl: '#', br: '#' } + : { tl: '╔', tr: '╗', bl: '╚', br: '╝' } + const effectiveCorners = isDoubleBox ? doubleCorners : corners + + // Draw box border with shape-specific corners + for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar + for (let x = from.x + 1; x < to.x; x++) box[x]![to.y] = hChar + for (let y = from.y + 1; y < to.y; y++) box[from.x]![y] = vChar + for (let y = from.y + 1; y < to.y; y++) box[to.x]![y] = vChar + box[from.x]![from.y] = effectiveCorners.tl + box[to.x]![from.y] = effectiveCorners.tr + box[from.x]![to.y] = effectiveCorners.bl + box[to.x]![to.y] = effectiveCorners.br + + // Center the multi-line display label inside the box + const label = node.displayLabel + const lines = splitLines(label) + const textCenterY = from.y + Math.floor(h / 2) + const startY = textCenterY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 + for (let j = 0; j < line.length; j++) { + if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0]!.length) { + box[textX + j]![startY + i] = line[j]! + } + } + } + + return box +} + +/** + * Draw a node box with centered label text. + * Returns a standalone canvas containing just the box. + * Box size is determined by the grid column/row sizes for the node's position. + */ +export function drawBox(node: AsciiNode, graph: AsciiGraph): Canvas { + return drawNode(node, graph) +} + +// ============================================================================ +// Multi-section box drawing — for class and ER diagram nodes +// ============================================================================ + +/** + * Draw a multi-section box with horizontal dividers between sections. + * Used by class diagrams (header | attributes | methods) and ER diagrams (header | attributes). + * Each section is an array of text lines to render left-aligned with padding. + * + * @param sections - Array of sections, each section is an array of text lines + * @param useAscii - true for ASCII chars, false for Unicode box-drawing + * @param padding - horizontal padding inside the box (default 1) + * @returns A standalone Canvas containing the multi-section box + */ +export function drawMultiBox( + sections: string[][], + useAscii: boolean, + padding: number = 1, +): Canvas { + // Compute width: widest line across all sections + 2*padding + 2 border chars + let maxTextWidth = 0 + for (const section of sections) { + for (const line of section) { + maxTextWidth = Math.max(maxTextWidth, line.length) + } + } + const innerWidth = maxTextWidth + 2 * padding + const boxWidth = innerWidth + 2 // +2 for left/right border + + // Compute height: sum of all section line counts + dividers + 2 border rows + let totalLines = 0 + for (const section of sections) { + totalLines += Math.max(section.length, 1) // at least 1 row per section + } + const numDividers = sections.length - 1 + const boxHeight = totalLines + numDividers + 2 // +2 for top/bottom border + + // Box-drawing characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + const tl = useAscii ? '+' : '┌' + const tr = useAscii ? '+' : '┐' + const bl = useAscii ? '+' : '└' + const br = useAscii ? '+' : '┘' + const divL = useAscii ? '+' : '├' + const divR = useAscii ? '+' : '┤' + + const canvas = mkCanvas(boxWidth - 1, boxHeight - 1) + + // Top border + canvas[0]![0] = tl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![0] = hLine + canvas[boxWidth - 1]![0] = tr + + // Bottom border + canvas[0]![boxHeight - 1] = bl + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![boxHeight - 1] = hLine + canvas[boxWidth - 1]![boxHeight - 1] = br + + // Left and right borders (full height) + for (let y = 1; y < boxHeight - 1; y++) { + canvas[0]![y] = vLine + canvas[boxWidth - 1]![y] = vLine + } + + // Render sections with dividers + let row = 1 // current y position (starts after top border) + for (let s = 0; s < sections.length; s++) { + const section = sections[s]! + const lines = section.length > 0 ? section : [''] + + // Draw section text lines + for (const line of lines) { + const startX = 1 + padding + for (let i = 0; i < line.length; i++) { + canvas[startX + i]![row] = line[i]! + } + row++ + } + + // Draw divider after each section except the last + if (s < sections.length - 1) { + canvas[0]![row] = divL + for (let x = 1; x < boxWidth - 1; x++) canvas[x]![row] = hLine + canvas[boxWidth - 1]![row] = divR + row++ + } + } + + return canvas +} + +// ============================================================================ +// Line drawing — 8-directional lines on the canvas +// ============================================================================ + +/** + * Line character sets for different edge styles. + * Each style has horizontal, vertical, and diagonal characters for both + * Unicode (box-drawing) and ASCII (basic punctuation) modes. + * + * Unicode dotted: ┄ (horizontal), ┆ (vertical) — U+2504, U+2506 + * Unicode thick: ━ (horizontal), ┃ (vertical) — U+2501, U+2503 + */ +/** + * Line character sets for different edge styles. + * Only horizontal and vertical characters - no diagonals. + * All edges use orthogonal Manhattan routing (90° bends only). + */ +const LINE_CHARS = { + solid: { + h: { unicode: '─', ascii: '-' }, + v: { unicode: '│', ascii: '|' }, + }, + dotted: { + h: { unicode: '┄', ascii: '.' }, + v: { unicode: '┆', ascii: ':' }, + }, + thick: { + h: { unicode: '━', ascii: '=' }, + v: { unicode: '┃', ascii: '‖' }, + }, +} as const + +/** + * Draw a line between two drawing coordinates using orthogonal Manhattan routing. + * Returns the list of coordinates that were drawn on. + * offsetFrom/offsetTo control how many cells to skip at the start/end. + * + * All lines use 90° bends only - no diagonal lines are produced. + * For diagonal directions, uses horizontal-first routing (draws horizontal + * segment, then vertical segment). + */ +export function drawLine( + canvas: Canvas, + from: DrawingCoord, + to: DrawingCoord, + offsetFrom: number, + offsetTo: number, + useAscii: boolean, + style: AsciiEdgeStyle = 'solid', +): DrawingCoord[] { + const dir = determineDirection(from, to) + const drawnCoords: DrawingCoord[] = [] + + // Select character set based on style (horizontal and vertical only) + const chars = LINE_CHARS[style] + const hChar = useAscii ? chars.h.ascii : chars.h.unicode + const vChar = useAscii ? chars.v.ascii : chars.v.unicode + + // Pure vertical directions + if (dirEquals(dir, Up)) { + for (let y = from.y - offsetFrom; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else if (dirEquals(dir, Down)) { + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } + // Pure horizontal directions + else if (dirEquals(dir, Left)) { + for (let x = from.x - offsetFrom; x >= to.x - offsetTo; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } else if (dirEquals(dir, Right)) { + for (let x = from.x + offsetFrom; x <= to.x + offsetTo; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + } + // Diagonal directions: use Manhattan routing (horizontal-first, then vertical) + // UpperLeft: go left first, then up + else if (dirEquals(dir, UpperLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // UpperRight: go right first, then up + else if (dirEquals(dir, UpperRight)) { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going up) + for (let y = from.y - 1; y >= to.y - offsetTo; y--) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerLeft: go left first, then down + else if (dirEquals(dir, LowerLeft)) { + // Horizontal segment: from.x -> to.x (going left) + for (let x = from.x - offsetFrom; x >= to.x; x--) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + // LowerRight: go right first, then down + // Special case: if x difference is small (1), draw straight vertical at from.x + // This keeps edges visually aligned with the source node + else if (dirEquals(dir, LowerRight)) { + const dx = to.x - from.x + if (dx <= 1) { + // Draw vertical line at from.x (source's x-coordinate) + for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: from.x, y }) + canvas[from.x]![y] = vChar + } + } else { + // Horizontal segment: from.x -> to.x (going right) + for (let x = from.x + offsetFrom; x <= to.x; x++) { + drawnCoords.push({ x, y: from.y }) + canvas[x]![from.y] = hChar + } + // Vertical segment: from.y -> to.y (going down) + for (let y = from.y + 1; y <= to.y + offsetTo; y++) { + drawnCoords.push({ x: to.x, y }) + canvas[to.x]![y] = vChar + } + } + } + + return drawnCoords +} + +// ============================================================================ +// Arrow drawing — path, corners, arrowheads, box-start junctions, labels +// ============================================================================ + +/** + * Draw a complete arrow (edge) between two nodes. + * Returns 6 separate canvases for layered compositing: + * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label] + * + * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd. + */ +export function drawArrow( + graph: AsciiGraph, + edge: AsciiEdge, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + if (edge.path.length === 0) { + const empty = copyCanvas(graph.canvas) + return [empty, empty, empty, empty, empty, empty] + } + + const labelCanvas = drawArrowLabel(graph, edge) + const [pathCanvas, linesDrawn, lineDirs] = drawPath(graph, edge.path, edge.style) + const boxStartCanvas = drawBoxStart(graph, edge.path, linesDrawn[0]!, edge.from.shape) + + // Draw end arrowhead only if hasArrowEnd is true (default behavior) + let arrowHeadEndCanvas: Canvas + if (edge.hasArrowEnd) { + arrowHeadEndCanvas = drawArrowHead( + graph, + linesDrawn[linesDrawn.length - 1]!, + lineDirs[lineDirs.length - 1]!, + ) + } else { + arrowHeadEndCanvas = copyCanvas(graph.canvas) + } + + // Draw start arrowhead for bidirectional edges + // The start arrowhead needs to be at the box connector position (one step back + // from the first line point), pointing into the source node. + let arrowHeadStartCanvas: Canvas + if (edge.hasArrowStart && linesDrawn.length > 0) { + const firstLine = linesDrawn[0]! + const firstPoint = firstLine[0]! + const startDir = reverseDirection(lineDirs[0]!) + + // Calculate the box connector position (one step back from first point) + const arrowPos: DrawingCoord = { x: firstPoint.x, y: firstPoint.y } + if (dirEquals(lineDirs[0]!, Right)) arrowPos.x = firstPoint.x - 1 + else if (dirEquals(lineDirs[0]!, Left)) arrowPos.x = firstPoint.x + 1 + else if (dirEquals(lineDirs[0]!, Down)) arrowPos.y = firstPoint.y - 1 + else if (dirEquals(lineDirs[0]!, Up)) arrowPos.y = firstPoint.y + 1 + + // Create a synthetic line ending at the arrow position for drawArrowHead + const syntheticLine: DrawingCoord[] = [firstPoint, arrowPos] + arrowHeadStartCanvas = drawArrowHead(graph, syntheticLine, startDir) + } else { + arrowHeadStartCanvas = copyCanvas(graph.canvas) + } + + const cornersCanvas = drawCorners(graph, edge.path) + + return [pathCanvas, boxStartCanvas, arrowHeadEndCanvas, arrowHeadStartCanvas, cornersCanvas, labelCanvas] +} + +/** + * Reverse a direction (for bidirectional arrow start heads). + */ +function reverseDirection(dir: Direction): Direction { + if (dirEquals(dir, Up)) return Down + if (dirEquals(dir, Down)) return Up + if (dirEquals(dir, Left)) return Right + if (dirEquals(dir, Right)) return Left + if (dirEquals(dir, UpperLeft)) return LowerRight + if (dirEquals(dir, UpperRight)) return LowerLeft + if (dirEquals(dir, LowerLeft)) return UpperRight + if (dirEquals(dir, LowerRight)) return UpperLeft + return Middle +} + +/** + * Draw the path lines for an edge. + * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment. + */ +function drawPath( + graph: AsciiGraph, + path: GridCoord[], + style: AsciiEdgeStyle = 'solid', +): [Canvas, DrawingCoord[][], Direction[]] { + const canvas = copyCanvas(graph.canvas) + let previousCoord = path[0]! + const linesDrawn: DrawingCoord[][] = [] + const lineDirs: Direction[] = [] + + for (let i = 1; i < path.length; i++) { + const nextCoord = path[i]! + const prevDC = gridToDrawingCoord(graph, previousCoord) + const nextDC = gridToDrawingCoord(graph, nextCoord) + + if (drawingCoordEquals(prevDC, nextDC)) { + previousCoord = nextCoord + continue + } + + const dir = determineDirection(previousCoord, nextCoord) + const segment = drawLine(canvas, prevDC, nextDC, 1, -1, graph.config.useAscii, style) + if (segment.length === 0) segment.push(prevDC) + linesDrawn.push(segment) + lineDirs.push(dir) + previousCoord = nextCoord + } + + return [canvas, linesDrawn, lineDirs] +} + +/** + * Draw the junction character where an edge exits the source node's box. + * Only applies to Unicode mode (ASCII mode just uses the line characters). + * Skips drawing for state pseudo-states which have their own visual borders. + */ +function drawBoxStart( + graph: AsciiGraph, + path: GridCoord[], + firstLine: DrawingCoord[], + sourceShape: string, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (graph.config.useAscii) return canvas + + // Skip box start connectors for state pseudo-states (they have their own bordered design) + if (sourceShape === 'state-start' || sourceShape === 'state-end') { + return canvas + } + + const from = firstLine[0]! + const dir = determineDirection(path[0]!, path[1]!) + + if (dirEquals(dir, Up)) canvas[from.x]![from.y + 1] = '┴' + else if (dirEquals(dir, Down)) canvas[from.x]![from.y - 1] = '┬' + else if (dirEquals(dir, Left)) canvas[from.x + 1]![from.y] = '┤' + else if (dirEquals(dir, Right)) canvas[from.x - 1]![from.y] = '├' + + return canvas +} + +/** + * Draw the arrowhead at the end of an edge path. + * Uses triangular Unicode symbols (▲▼◄►) or ASCII symbols (^v<>). + */ +function drawArrowHead( + graph: AsciiGraph, + lastLine: DrawingCoord[], + fallbackDir: Direction, +): Canvas { + const canvas = copyCanvas(graph.canvas) + if (lastLine.length === 0) return canvas + + const from = lastLine[0]! + const lastPos = lastLine[lastLine.length - 1]! + let dir = determineDirection(from, lastPos) + if (lastLine.length === 1 || dirEquals(dir, Middle)) dir = fallbackDir + + let char: string + + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else if (dirEquals(dir, UpperRight)) char = '◥' + else if (dirEquals(dir, UpperLeft)) char = '◤' + else if (dirEquals(dir, LowerRight)) char = '◢' + else if (dirEquals(dir, LowerLeft)) char = '◣' + else { + // Fallback + if (dirEquals(fallbackDir, Up)) char = '▲' + else if (dirEquals(fallbackDir, Down)) char = '▼' + else if (dirEquals(fallbackDir, Left)) char = '◄' + else if (dirEquals(fallbackDir, Right)) char = '►' + else if (dirEquals(fallbackDir, UpperRight)) char = '◥' + else if (dirEquals(fallbackDir, UpperLeft)) char = '◤' + else if (dirEquals(fallbackDir, LowerRight)) char = '◢' + else if (dirEquals(fallbackDir, LowerLeft)) char = '◣' + else char = '●' + } + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else { + if (dirEquals(fallbackDir, Up)) char = '^' + else if (dirEquals(fallbackDir, Down)) char = 'v' + else if (dirEquals(fallbackDir, Left)) char = '<' + else if (dirEquals(fallbackDir, Right)) char = '>' + else char = '*' + } + } + + canvas[lastPos.x]![lastPos.y] = char + return canvas +} + +/** + * Draw corner characters at path bends (where the direction changes). + * Uses ┌┐└┘ in Unicode mode, + in ASCII mode. + */ +function drawCorners(graph: AsciiGraph, path: GridCoord[]): Canvas { + const canvas = copyCanvas(graph.canvas) + + for (let idx = 1; idx < path.length - 1; idx++) { + const coord = path[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(path[idx - 1]!, coord) + const nextDir = determineDirection(coord, path[idx + 1]!) + + let corner: string + if (!graph.config.useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + canvas[dc.x]![dc.y] = corner + } + + return canvas +} + +/** Draw edge label text centered on the widest path segment. */ +function drawArrowLabel(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + if (edge.text.length === 0) return canvas + + const drawingLine = lineToDrawing(graph, edge.labelLine) + + // Determine if this is an upward edge (target is above source in the path) + // This is used to offset labels on bidirectional edges to prevent overlap + let isUpwardEdge: boolean | undefined + if (edge.path.length >= 2) { + const startY = edge.path[0]!.y + const endY = edge.path[edge.path.length - 1]!.y + // Edge goes up if end Y is less than start Y (smaller Y = higher on screen) + if (endY < startY) { + isUpwardEdge = true + } else if (endY > startY) { + isUpwardEdge = false + } + // If endY === startY, it's horizontal, leave isUpwardEdge undefined + } + + drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge) + return canvas +} + +/** + * Draw text centered on a line segment defined by two drawing coordinates. + * Supports multi-line labels. + * + * When isUpwardEdge is provided, offsets the label vertically to prevent + * overlapping with labels from edges going the opposite direction: + * - Upward edges: label placed in lower portion of segment + * - Downward edges (isUpwardEdge=false): label placed in upper portion + * - No direction (isUpwardEdge=undefined): label centered (default) + */ +function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isUpwardEdge?: boolean): void { + if (line.length < 2) return + const minX = Math.min(line[0]!.x, line[1]!.x) + const maxX = Math.max(line[0]!.x, line[1]!.x) + const minY = Math.min(line[0]!.y, line[1]!.y) + const maxY = Math.max(line[0]!.y, line[1]!.y) + const middleX = minX + Math.floor((maxX - minX) / 2) + let middleY = minY + Math.floor((maxY - minY) / 2) + + // Offset label vertically to prevent overlap on bidirectional edges + // For vertical segments (same X), shift based on edge direction + if (isUpwardEdge !== undefined && minX === maxX) { + const segmentHeight = maxY - minY + const offset = Math.max(1, Math.floor(segmentHeight / 4)) + if (isUpwardEdge) { + // Upward edge: place label in lower portion + middleY = middleY + offset + } else { + // Downward edge: place label in upper portion + middleY = middleY - offset + } + } + + // Support multi-line labels + const lines = splitLines(label) + const startY = middleY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]! + const startX = middleX - Math.floor(lineText.length / 2) + drawText(canvas, { x: startX, y: startY + i }, lineText) + } +} + +// ============================================================================ +// Node attachment point helper +// ============================================================================ + +/** + * Get the drawing coordinate where an edge attaches to a node's border. + * Uses grid-allocated dimensions so attachment points align with the actual + * drawn box (which may be wider/taller than the intrinsic shape dimensions + * when sharing a column/row with a larger node). + */ +function getNodeAttachmentPoint( + graph: AsciiGraph, + node: AsciiNode, + dir: Direction, +): DrawingCoord { + const gc = node.gridCoord! + + // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions) + let w = 0 + for (let i = 0; i < 2; i++) { + w += graph.columnWidth.get(gc.x + i) ?? 0 + } + let h = 0 + for (let i = 0; i < 2; i++) { + h += graph.rowHeight.get(gc.y + i) ?? 0 + } + + // Build dimensions matching the actual drawn box size + const gridDimensions = { + width: w + 1, + height: h + 1, + labelArea: { x: 0, y: 0, width: 0, height: 0 }, + gridColumns: [0, 0, 0] as [number, number, number], + gridRows: [0, 0, 0] as [number, number, number], + } + + const baseCoord = node.drawingCoord! + return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord) +} + +// ============================================================================ +// Bundled edge drawing — for parallel links (A & B --> C) +// ============================================================================ + +/** + * Draw a single edge's segment in a bundle (source → junction for fan-in, + * junction → target for fan-out). + * + * Returns the same tuple format as drawArrow for consistency. + */ +function drawBundledEdgeSegment( + graph: AsciiGraph, + edge: AsciiEdge, + bundle: EdgeBundle, +): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] { + const empty = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length === 0) { + return [empty, empty, empty, empty, empty, empty] + } + + // Draw the path segment (pathToJunction) + const pathCanvas = copyCanvas(graph.canvas) + const useAscii = graph.config.useAscii + + // Convert grid coords to drawing coords + // For fan-in: first point is at source node border (use attachment point) + // For fan-out: last point is at target node border (use attachment point) + const drawingPath = edge.pathToJunction.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === 0) { + // First point: use source node's actual border position + return getNodeAttachmentPoint(graph, edge.from, edge.startDir) + } + if (bundle.type === 'fan-out' && idx === edge.pathToJunction!.length - 1) { + // Last point: use target node's actual border position + return getNodeAttachmentPoint(graph, edge.to, edge.endDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints of every segment (offset 1, -1), + // matching non-bundled drawPath behavior. This leaves endpoint + // characters to corner/junction/boxStart canvases, preventing + // line characters from corrupting them via mergeJunctions. + drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style) + } + } + + // Draw corners at path bends + const cornersCanvas = copyCanvas(graph.canvas) + for (let idx = 1; idx < edge.pathToJunction.length - 1; idx++) { + const coord = edge.pathToJunction[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(edge.pathToJunction[idx - 1]!, coord) + const nextDir = determineDirection(coord, edge.pathToJunction[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + // Draw box start connector (for fan-in, from source node) + // The connector is placed at the first point coordinate (box border position) + // since we use offsets 1,-1 for drawLine, the line starts one step past this point + const boxStartCanvas = copyCanvas(graph.canvas) + if (bundle.type === 'fan-in' && edge.pathToJunction.length >= 2) { + const firstPoint = drawingPath[0]! + const dir = determineDirection(edge.pathToJunction[0]!, edge.pathToJunction[1]!) + + if (!useAscii) { + if (dirEquals(dir, Up)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┴' + else if (dirEquals(dir, Down)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┬' + else if (dirEquals(dir, Left)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┤' + else if (dirEquals(dir, Right)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '├' + } + } + + // Label canvas (bundled edges typically don't have labels, but handle it) + const labelCanvas = copyCanvas(graph.canvas) + + return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas] +} + +/** + * Draw the shared path segment of a bundle (junction → target for fan-in, + * source → junction for fan-out). + */ +function drawBundleSharedPath(graph: AsciiGraph, bundle: EdgeBundle): [Canvas, Canvas] { + const pathCanvas = copyCanvas(graph.canvas) + const cornersCanvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) { + return [pathCanvas, cornersCanvas] + } + + const useAscii = graph.config.useAscii + const style = bundle.edges[0]?.style ?? 'solid' + const graphDir = graph.config.graphDirection + + // Convert grid coords to drawing coords + // For fan-in: last point is at target node border + // For fan-out: first point is at source node border + const drawingPath = bundle.sharedPath.map((gc, idx) => { + if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) { + // Last point: use target node's actual border position (entry from above/left) + const entryDir = graphDir === 'TD' ? Up : Left + return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + } + if (bundle.type === 'fan-out' && idx === 0) { + // First point: use source node's actual border position (exit going down/right) + const exitDir = graphDir === 'TD' ? Down : Right + return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir) + } + return gridToDrawingCoord(graph, gc) + }) + + // Draw line segments with appropriate offsets + for (let i = 1; i < drawingPath.length; i++) { + const from = drawingPath[i - 1]! + const to = drawingPath[i]! + if (!drawingCoordEquals(from, to)) { + // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath. + drawLine(pathCanvas, from, to, 1, -1, useAscii, style) + } + } + + // Draw corners at path bends + for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) { + const coord = bundle.sharedPath[idx]! + const dc = gridToDrawingCoord(graph, coord) + const prevDir = determineDirection(bundle.sharedPath[idx - 1]!, coord) + const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]!) + + let corner: string + if (!useAscii) { + if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) { + corner = '┐' + } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) { + corner = '┘' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) || + (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) { + corner = '┌' + } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) || + (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) { + corner = '└' + } else { + corner = '+' + } + } else { + corner = '+' + } + + cornersCanvas[dc.x]![dc.y] = corner + } + + return [pathCanvas, cornersCanvas] +} + +/** + * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target). + */ +function drawBundleArrowhead(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (bundle.sharedPath.length < 2) return canvas + + // Get the last segment direction + const lastIdx = bundle.sharedPath.length - 1 + const secondLast = bundle.sharedPath[lastIdx - 1]! + const last = bundle.sharedPath[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border (not on the border itself). + // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead + // sits at the last drawn point (1 char before the border). + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the arrowhead for a single edge in a fan-out bundle. + */ +function drawBundledEdgeArrowhead(graph: AsciiGraph, edge: AsciiEdge): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas + + // Get the last segment direction + const lastIdx = edge.pathToJunction.length - 1 + const secondLast = edge.pathToJunction[lastIdx - 1]! + const last = edge.pathToJunction[lastIdx]! + const dir = determineDirection(secondLast, last) + + // Get drawing coord 1 char outside the target node's border + const graphDir = graph.config.graphDirection + const entryDir = graphDir === 'TD' ? Up : Left + const dc = getNodeAttachmentPoint(graph, edge.to, entryDir) + // Offset 1 char away from the box border so arrowhead sits outside the box + if (graphDir === 'TD') dc.y -= 1 + else dc.x -= 1 + + // Draw arrowhead + let char: string + if (!graph.config.useAscii) { + if (dirEquals(dir, Up)) char = '▲' + else if (dirEquals(dir, Down)) char = '▼' + else if (dirEquals(dir, Left)) char = '◄' + else if (dirEquals(dir, Right)) char = '►' + else char = '▼' // default + } else { + if (dirEquals(dir, Up)) char = '^' + else if (dirEquals(dir, Down)) char = 'v' + else if (dirEquals(dir, Left)) char = '<' + else if (dirEquals(dir, Right)) char = '>' + else char = 'v' // default + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +/** + * Draw the junction character where bundled edges merge/split. + * + * Analyzes actual connecting directions to choose the correct character: + * - ┼ (cross): lines from all 4 directions + * - ┬ (T down): lines from left, right, and down + * - ┴ (T up): lines from left, right, and up + * - ├ (T right): lines from up, down, and right + * - ┤ (T left): lines from up, down, and left + */ +function drawJunctionCharacter(graph: AsciiGraph, bundle: EdgeBundle): Canvas { + const canvas = copyCanvas(graph.canvas) + + if (!bundle.junctionPoint) return canvas + + const dc = gridToDrawingCoord(graph, bundle.junctionPoint) + const useAscii = graph.config.useAscii + + // Analyze what directions actually connect to the junction + let hasUp = false + let hasDown = false + let hasLeft = false + let hasRight = false + + // Check shared path direction (where the line continues to/from the shared node) + if (bundle.sharedPath.length >= 2) { + // For fan-in: shared path goes FROM junction TO target (index 0 is junction) + // For fan-out: shared path goes FROM source TO junction (last index is junction) + const junctionIdx = bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1 + const adjacentIdx = bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2 + const sharedDir = determineDirection( + bundle.sharedPath[junctionIdx]!, + bundle.sharedPath[adjacentIdx]! + ) + // This is the direction the shared path GOES from junction + if (dirEquals(sharedDir, Down)) hasDown = true + else if (dirEquals(sharedDir, Up)) hasUp = true + else if (dirEquals(sharedDir, Right)) hasRight = true + else if (dirEquals(sharedDir, Left)) hasLeft = true + } + + // Check each edge's path direction at the junction + for (const edge of bundle.edges) { + if (edge.pathToJunction && edge.pathToJunction.length >= 2) { + // For fan-in: pathToJunction goes FROM source TO junction (last is junction) + // For fan-out: pathToJunction goes FROM junction TO target (first is junction) + const junctionIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 1 + : 0 + const adjacentIdx = bundle.type === 'fan-in' + ? edge.pathToJunction.length - 2 + : 1 + + const arrivalDir = determineDirection( + edge.pathToJunction[adjacentIdx]!, + edge.pathToJunction[junctionIdx]! + ) + // This is the direction the edge ARRIVES at junction from + // e.g., if arrivalDir is Right, the line comes FROM the left + if (dirEquals(arrivalDir, Down)) hasUp = true // arrived going down = came from up + else if (dirEquals(arrivalDir, Up)) hasDown = true + else if (dirEquals(arrivalDir, Right)) hasLeft = true + else if (dirEquals(arrivalDir, Left)) hasRight = true + } + } + + // Select character based on connected directions + let char: string + if (!useAscii) { + if (hasUp && hasDown && hasLeft && hasRight) { + char = '┼' // cross - all 4 directions + } else if (hasDown && hasLeft && hasRight && !hasUp) { + char = '┬' // T pointing down + } else if (hasUp && hasLeft && hasRight && !hasDown) { + char = '┴' // T pointing up + } else if (hasUp && hasDown && hasRight && !hasLeft) { + char = '├' // T pointing right + } else if (hasUp && hasDown && hasLeft && !hasRight) { + char = '┤' // T pointing left + } else if (hasLeft && hasRight) { + char = '─' // horizontal only + } else if (hasUp && hasDown) { + char = '│' // vertical only + } else if (hasDown && hasRight) { + char = '┌' // corner + } else if (hasDown && hasLeft) { + char = '┐' + } else if (hasUp && hasRight) { + char = '└' + } else if (hasUp && hasLeft) { + char = '┘' + } else { + char = '┼' // fallback + } + } else { + char = '+' + } + + canvas[dc.x]![dc.y] = char + return canvas +} + +// ============================================================================ +// Subgraph drawing +// ============================================================================ + +/** Draw a subgraph border rectangle. */ +export function drawSubgraphBox(sg: AsciiSubgraph, graph: AsciiGraph): Canvas { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return mkCanvas(0, 0) + + const from: DrawingCoord = { x: 0, y: 0 } + const to: DrawingCoord = { x: width, y: height } + const canvas = mkCanvas(width, height) + + if (!graph.config.useAscii) { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '─' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '─' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '│' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '│' + canvas[from.x]![from.y] = '┌' + canvas[to.x]![from.y] = '┐' + canvas[from.x]![to.y] = '└' + canvas[to.x]![to.y] = '┘' + } else { + for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '-' + for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '-' + for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '|' + for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '|' + canvas[from.x]![from.y] = '+' + canvas[to.x]![from.y] = '+' + canvas[from.x]![to.y] = '+' + canvas[to.x]![to.y] = '+' + } + + return canvas +} + +/** Draw a subgraph label centered in its header area. Supports multi-line labels. */ +export function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas, DrawingCoord] { + const width = sg.maxX - sg.minX + const height = sg.maxY - sg.minY + if (width <= 0 || height <= 0) return [mkCanvas(0, 0), { x: 0, y: 0 }] + + const canvas = mkCanvas(width, height) + + // Support multi-line subgraph labels + const lines = splitLines(sg.name) + + // Start at row 1 inside subgraph, expand downward for multiple lines + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const labelY = 1 + i + let labelX = Math.floor(width / 2) - Math.floor(line.length / 2) + if (labelX < 1) labelX = 1 + + for (let j = 0; j < line.length; j++) { + if (labelX + j < width && labelY < height) { + canvas[labelX + j]![labelY] = line[j]! + } + } + } + + return [canvas, { x: sg.minX, y: sg.minY }] +} + +// ============================================================================ +// Top-level draw orchestrator +// ============================================================================ + +/** Sort subgraphs by nesting depth (shallowest first) for correct layered rendering. */ +function sortSubgraphsByDepth(subgraphs: AsciiSubgraph[]): AsciiSubgraph[] { + function getDepth(sg: AsciiSubgraph): number { + return sg.parent === null ? 0 : 1 + getDepth(sg.parent) + } + const sorted = [...subgraphs] + sorted.sort((a, b) => getDepth(a) - getDepth(b)) + return sorted +} + +// ============================================================================ +// Role tracking helpers for colored output +// ============================================================================ + +/** + * Fill roles for all non-space characters in a canvas region. + * Used after drawing a layer to record what role those characters have. + */ +function fillRolesFromCanvas( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, + role: CharRole, +): void { + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, role) + } + } + } + } +} + +/** + * Fill roles for multiple canvases with the same role. + */ +function fillRolesFromCanvases( + roleCanvas: RoleCanvas, + canvases: Canvas[], + offset: DrawingCoord, + role: CharRole, +): void { + for (const canvas of canvases) { + fillRolesFromCanvas(roleCanvas, canvas, offset, role) + } +} + +/** + * Special handling for node boxes: border chars get 'border' role, text gets 'text' role. + * Detects text by checking if character is alphanumeric or common punctuation. + */ +function fillRolesForNodeBox( + roleCanvas: RoleCanvas, + canvas: Canvas, + offset: DrawingCoord, +): void { + const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c) + + for (let x = 0; x < canvas.length; x++) { + for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { + const char = canvas[x]?.[y] + if (char && char !== ' ') { + const rx = x + offset.x + const ry = y + offset.y + // Use setRole which auto-expands the role canvas if needed + if (rx >= 0 && ry >= 0) { + setRole(roleCanvas, rx, ry, isBorderChar(char) ? 'border' : 'text') + } + } + } + } +} + +/** + * Main draw function — renders the entire graph onto the canvas. + * Drawing order matters for correct layering: + * 1. Subgraph borders (bottom layer) + * 2. Node boxes + * 3. Edge paths (lines) + * 4. Edge corners + * 5. Arrowheads + * 6. Box-start junctions + * 7. Edge labels + * 8. Subgraph labels (top layer) + * + * Also fills the roleCanvas with character roles for colored output. + */ +export function drawGraph(graph: AsciiGraph): Canvas { + const useAscii = graph.config.useAscii + const zero: DrawingCoord = { x: 0, y: 0 } + + // Draw subgraph borders + const sortedSgs = sortSubgraphsByDepth(graph.subgraphs) + for (const sg of sortedSgs) { + const sgCanvas = drawSubgraphBox(sg, graph) + const offset: DrawingCoord = { x: sg.minX, y: sg.minY } + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, sgCanvas) + // Subgraph borders get 'border' role + fillRolesFromCanvas(graph.roleCanvas, sgCanvas, offset, 'border') + } + + // Draw node boxes + for (const node of graph.nodes) { + if (!node.drawn && node.drawingCoord && node.drawing) { + graph.canvas = mergeCanvases(graph.canvas, node.drawingCoord, useAscii, node.drawing) + // Node boxes: detect border vs text characters + fillRolesForNodeBox(graph.roleCanvas, node.drawing, node.drawingCoord) + node.drawn = true + } + } + + // Collect all edge drawing layers + const lineCanvases: Canvas[] = [] + const cornerCanvases: Canvas[] = [] + const arrowHeadEndCanvases: Canvas[] = [] + const arrowHeadStartCanvases: Canvas[] = [] + const boxStartCanvases: Canvas[] = [] + const labelCanvases: Canvas[] = [] + const junctionCanvases: Canvas[] = [] + + // Track which bundles have been processed (to draw shared paths only once) + const processedBundles = new Set() + + for (const edge of graph.edges) { + // Handle bundled edges specially + if (edge.bundle && edge.pathToJunction) { + const bundle = edge.bundle + + // Draw this edge's individual path (source → junction for fan-in, junction → target for fan-out) + const [pathC, boxStartC, , , cornersC, labelC] = drawBundledEdgeSegment(graph, edge, bundle) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + + // Draw the bundle's shared path and arrowhead only once + if (!processedBundles.has(bundle)) { + processedBundles.add(bundle) + + // Draw shared path (junction → target for fan-in, source → junction for fan-out) + const [sharedPathC, sharedCornersC] = drawBundleSharedPath(graph, bundle) + lineCanvases.push(sharedPathC) + cornerCanvases.push(sharedCornersC) + + // Draw arrowhead at target for fan-in (once for all edges in bundle) + if (bundle.type === 'fan-in') { + const arrowHeadC = drawBundleArrowhead(graph, bundle) + arrowHeadEndCanvases.push(arrowHeadC) + } + + // Draw junction character + const junctionC = drawJunctionCharacter(graph, bundle) + junctionCanvases.push(junctionC) + } + + // For fan-out bundles, draw arrowhead at each target + if (bundle.type === 'fan-out' && edge.hasArrowEnd) { + const arrowHeadC = drawBundledEdgeArrowhead(graph, edge) + arrowHeadEndCanvases.push(arrowHeadC) + } + } else { + // Non-bundled edge: use standard drawing + const [pathC, boxStartC, arrowHeadEndC, arrowHeadStartC, cornersC, labelC] = drawArrow(graph, edge) + lineCanvases.push(pathC) + cornerCanvases.push(cornersC) + arrowHeadEndCanvases.push(arrowHeadEndC) + arrowHeadStartCanvases.push(arrowHeadStartC) + boxStartCanvases.push(boxStartC) + labelCanvases.push(labelC) + } + } + + // Merge edge layers in order and track roles + // Note: arrowHeadStart is merged AFTER boxStart so bidirectional arrows + // properly overwrite the box connector at the source end + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...lineCanvases) + fillRolesFromCanvases(graph.roleCanvas, lineCanvases, zero, 'line') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...cornerCanvases) + fillRolesFromCanvases(graph.roleCanvas, cornerCanvases, zero, 'corner') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...junctionCanvases) + fillRolesFromCanvases(graph.roleCanvas, junctionCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadEndCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadEndCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...boxStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, boxStartCanvases, zero, 'junction') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadStartCanvases) + fillRolesFromCanvases(graph.roleCanvas, arrowHeadStartCanvases, zero, 'arrow') + + graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...labelCanvases) + fillRolesFromCanvases(graph.roleCanvas, labelCanvases, zero, 'text') + + // Draw subgraph labels last (on top) + for (const sg of graph.subgraphs) { + if (sg.nodes.length === 0) continue + const [labelCanvas, offset] = drawSubgraphLabel(sg, graph) + graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, labelCanvas) + fillRolesFromCanvas(graph.roleCanvas, labelCanvas, offset, 'text') + } + + return graph.canvas +} diff --git a/ui/vendor/beautiful-mermaid/ascii/edge-bundling.ts b/ui/vendor/beautiful-mermaid/ascii/edge-bundling.ts new file mode 100644 index 0000000..c463a4b --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/edge-bundling.ts @@ -0,0 +1,328 @@ +// ============================================================================ +// ASCII renderer — edge bundling for parallel links +// +// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and +// groups them into bundles. Bundled edges share a visual junction point +// where they merge/split, creating cleaner diagrams. +// +// This module provides: +// - analyzeEdgeBundles(): Finds and creates bundles from graph edges +// - calculateJunctionPoint(): Computes optimal merge/split locations +// - routeBundledEdges(): Routes edges through junction points +// ============================================================================ + +import type { + AsciiGraph, AsciiNode, AsciiEdge, EdgeBundle, GridCoord, Direction, +} from './types.ts' +import { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types.ts' +import { getPath, mergePath } from './pathfinder.ts' +import { getNodeSubgraph } from './grid.ts' + +// ============================================================================ +// Bundle analysis +// ============================================================================ + +/** + * Analyze graph edges and create bundles for parallel links. + * + * Groups edges by: + * - Fan-in: Multiple edges sharing the same target (A & B --> C) + * - Fan-out: Multiple edges sharing the same source (A --> B & C) + * + * Only creates bundles when: + * - Graph direction is TD (top-down) - LR routing handles merging naturally + * - 2+ edges share the endpoint + * - All edges have the same style (solid/dotted/thick) + * - None of the edges have labels (labels would overlap at junction) + * - Edges are not self-loops + * + * @returns Array of bundles. Each edge can belong to at most one bundle. + */ +export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] { + // Only bundle in TD direction - LR routing handles merging naturally at corners + if (graph.config.graphDirection !== 'TD') { + return [] + } + const bundles: EdgeBundle[] = [] + const bundledEdges = new Set() + + // Group edges by target (fan-in candidates) + const edgesByTarget = new Map() + for (const edge of graph.edges) { + // Skip self-loops + if (edge.from === edge.to) continue + + const existing = edgesByTarget.get(edge.to) ?? [] + existing.push(edge) + edgesByTarget.set(edge.to, existing) + } + + // Create fan-in bundles + for (const [target, edges] of edgesByTarget) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + // Check if all edges are already bundled + if (edges.some(e => bundledEdges.has(e))) continue + + const bundle: EdgeBundle = { + type: 'fan-in', + edges: [...edges], + sharedNode: target, + otherNodes: edges.map(e => e.from), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + // Group edges by source (fan-out candidates) + const edgesBySource = new Map() + for (const edge of graph.edges) { + // Skip self-loops and already bundled edges + if (edge.from === edge.to) continue + if (bundledEdges.has(edge)) continue + + const existing = edgesBySource.get(edge.from) ?? [] + existing.push(edge) + edgesBySource.set(edge.from, existing) + } + + // Create fan-out bundles + for (const [source, edges] of edgesBySource) { + if (edges.length < 2) continue + if (!canBundle(edges, graph)) continue + + const bundle: EdgeBundle = { + type: 'fan-out', + edges: [...edges], + sharedNode: source, + otherNodes: edges.map(e => e.to), + junctionPoint: null, + sharedPath: [], + junctionDir: Middle, + sharedNodeDir: Middle, + } + + // Mark edges as bundled + for (const edge of edges) { + edge.bundle = bundle + bundledEdges.add(edge) + } + + bundles.push(bundle) + } + + return bundles +} + +/** + * Check if a group of edges can be bundled together. + * Returns false if edges have different styles, any have labels, + * or if the edges span subgraph boundaries (which creates complex routing). + */ +function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean { + if (edges.length < 2) return false + + const firstStyle = edges[0]!.style + const firstFromSg = getNodeSubgraph(graph, edges[0]!.from) + const firstToSg = getNodeSubgraph(graph, edges[0]!.to) + + for (const edge of edges) { + // Different styles can't be bundled (would look confusing) + if (edge.style !== firstStyle) return false + + // Edges with labels can't be bundled (labels would overlap at junction) + if (edge.text.length > 0) return false + + // Don't bundle if edges span different subgraph boundaries + // (creates complex routing that doesn't look good) + const fromSg = getNodeSubgraph(graph, edge.from) + const toSg = getNodeSubgraph(graph, edge.to) + if (fromSg !== firstFromSg || toSg !== firstToSg) return false + + // Don't bundle if source and target are in different subgraphs + // (cross-boundary edges have special routing needs) + if (fromSg !== toSg) return false + } + + return true +} + +// ============================================================================ +// Junction point calculation +// ============================================================================ + +/** + * Calculate the optimal junction point for a bundle. + * + * For fan-in (A & B --> C): + * - Junction is placed between the sources and the target + * - In TD: above the target, horizontally centered between sources + * - In LR: left of the target, vertically centered between sources + * + * For fan-out (A --> B & C): + * - Junction is placed between the source and the targets + * - In TD: below the source, horizontally centered between targets + * - In LR: right of the source, vertically centered between targets + */ +export function calculateJunctionPoint( + graph: AsciiGraph, + bundle: EdgeBundle, +): GridCoord { + const dir = graph.config.graphDirection + const sharedCoord = bundle.sharedNode.gridCoord! + const otherCoords = bundle.otherNodes.map(n => n.gridCoord!) + + if (bundle.type === 'fan-in') { + // Junction is BEFORE the shared target + // Calculate center of sources + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction above target, centered between sources + // Place it one row above the target's entry point + const junctionY = sharedCoord.y - 1 + // X is centered between sources, but clamped to shared node's X for alignment + const centerX = Math.floor((minX + maxX) / 2) + 1 // +1 for center of 3x3 block + const junctionX = sharedCoord.x + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction left of target, centered between sources + const junctionX = sharedCoord.x - 1 + const junctionY = sharedCoord.y + 1 // Align with target's center + + return { x: junctionX, y: junctionY } + } + } else { + // fan-out: Junction is AFTER the shared source + const minX = Math.min(...otherCoords.map(c => c.x)) + const maxX = Math.max(...otherCoords.map(c => c.x)) + const minY = Math.min(...otherCoords.map(c => c.y)) + const maxY = Math.max(...otherCoords.map(c => c.y)) + + if (dir === 'TD') { + // Junction below source, will then split to targets + const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block + const junctionX = sharedCoord.x + 1 // Align with source's center + + return { x: junctionX, y: junctionY } + } else { + // LR: Junction right of source + const junctionX = sharedCoord.x + 3 + const junctionY = sharedCoord.y + 1 + + return { x: junctionX, y: junctionY } + } + } +} + +// ============================================================================ +// Bundled edge routing +// ============================================================================ + +/** + * Route all edges in a bundle through the junction point. + * + * For fan-in bundles: + * 1. Route each source → junction (stored in edge.pathToJunction) + * 2. Route junction → target (stored in bundle.sharedPath) + * + * For fan-out bundles: + * 1. Route source → junction (stored in bundle.sharedPath) + * 2. Route junction → each target (stored in edge.pathToJunction) + */ +export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void { + const dir = graph.config.graphDirection + + // Calculate and store junction point + bundle.junctionPoint = calculateJunctionPoint(graph, bundle) + const junction = bundle.junctionPoint + + // Determine directions based on graph direction and bundle type + if (bundle.type === 'fan-in') { + // Sources converge to junction, then junction to target + bundle.junctionDir = dir === 'TD' ? Up : Left + bundle.sharedNodeDir = dir === 'TD' ? Down : Right + + // Route junction → target (shared path) + const targetCoord = bundle.sharedNode.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const sharedPath = getPath(graph.grid, junction, targetEntry) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [junction, targetEntry] + + // Route each source → junction + for (const edge of bundle.edges) { + const sourceCoord = edge.from.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const pathToJunction = getPath(graph.grid, sourceExit, junction) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [sourceExit, junction] + + // Set edge directions for proper drawing + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)] + } + } else { + // fan-out: Source to junction, then junction splits to targets + bundle.junctionDir = dir === 'TD' ? Down : Right + bundle.sharedNodeDir = dir === 'TD' ? Up : Left + + // Route source → junction (shared path) + const sourceCoord = bundle.sharedNode.gridCoord! + const sourceExit = dir === 'TD' + ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source + : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source + + const sharedPath = getPath(graph.grid, sourceExit, junction) + bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [sourceExit, junction] + + // Route junction → each target + for (const edge of bundle.edges) { + const targetCoord = edge.to.gridCoord! + const targetEntry = dir === 'TD' + ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target + : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target + + const pathToJunction = getPath(graph.grid, junction, targetEntry) + edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [junction, targetEntry] + + // Set edge directions + edge.startDir = dir === 'TD' ? Down : Right + edge.endDir = dir === 'TD' ? Up : Left + + // Build full path for grid size calculation: source → junction → target + edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)] + } + } +} + +/** + * Process all bundles in a graph: calculate junction points and route edges. + */ +export function processBundles(graph: AsciiGraph): void { + for (const bundle of graph.bundles) { + routeBundledEdges(graph, bundle) + } +} diff --git a/ui/vendor/beautiful-mermaid/ascii/edge-routing.ts b/ui/vendor/beautiful-mermaid/ascii/edge-routing.ts new file mode 100644 index 0000000..e9a9dd4 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/edge-routing.ts @@ -0,0 +1,296 @@ +// ============================================================================ +// ASCII renderer — direction system and edge path determination +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go. +// Handles direction constants, edge attachment point selection, +// and dual-path comparison for optimal edge routing. +// ============================================================================ + +import type { GridCoord, Direction, AsciiEdge, AsciiGraph } from './types.ts' +import { + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, + gridCoordDirection, +} from './types.ts' +import { getPath, mergePath } from './pathfinder.ts' +import { getEffectiveDirection, getNodeSubgraph } from './grid.ts' + +// ============================================================================ +// Direction utilities +// ============================================================================ + +export function getOpposite(d: Direction): Direction { + if (d === Up) return Down + if (d === Down) return Up + if (d === Left) return Right + if (d === Right) return Left + if (d === UpperRight) return LowerLeft + if (d === UpperLeft) return LowerRight + if (d === LowerRight) return UpperLeft + if (d === LowerLeft) return UpperRight + return Middle +} + +/** Compare directions by value (not reference). */ +export function dirEquals(a: Direction, b: Direction): boolean { + return a.x === b.x && a.y === b.y +} + +/** + * Determine 8-way direction from one coordinate to another. + * Uses the coordinate difference to pick one of 8 cardinal/ordinal directions. + */ +export function determineDirection(from: { x: number; y: number }, to: { x: number; y: number }): Direction { + if (from.x === to.x) { + return from.y < to.y ? Down : Up + } else if (from.y === to.y) { + return from.x < to.x ? Right : Left + } else if (from.x < to.x) { + return from.y < to.y ? LowerRight : UpperRight + } else { + return from.y < to.y ? LowerLeft : UpperLeft + } +} + +// ============================================================================ +// Start/end direction selection for edges +// ============================================================================ + +/** Self-reference routing (node points to itself). */ +function selfReferenceDirection(graphDirection: string): [Direction, Direction, Direction, Direction] { + if (graphDirection === 'LR') return [Right, Down, Down, Right] + return [Down, Right, Right, Down] +} + +/** + * Determine preferred and alternative start/end directions for an edge. + * Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd]. + * + * The edge routing tries both pairs and picks the shorter path. + * Direction selection depends on relative node positions and graph direction (LR vs TD). + */ +export function determineStartAndEndDir( + edge: AsciiEdge, + graphDirection: string, +): [Direction, Direction, Direction, Direction] { + if (edge.from === edge.to) return selfReferenceDirection(graphDirection) + + const d = determineDirection(edge.from.gridCoord!, edge.to.gridCoord!) + + let preferredDir: Direction + let preferredOppositeDir: Direction + let alternativeDir: Direction + let alternativeOppositeDir: Direction + + const isBackwards = graphDirection === 'LR' + ? (dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft)) + : (dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight)) + + if (dirEquals(d, LowerRight)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Up + } else { + preferredDir = Right; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Left + } + } else if (dirEquals(d, UpperRight)) { + if (graphDirection === 'LR') { + preferredDir = Up; preferredOppositeDir = Left + alternativeDir = Right; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Down + alternativeDir = Up; alternativeOppositeDir = Left + } + } else if (dirEquals(d, LowerLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Up + } else { + preferredDir = Left; preferredOppositeDir = Up + alternativeDir = Down; alternativeOppositeDir = Right + } + } else if (dirEquals(d, UpperLeft)) { + if (graphDirection === 'LR') { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Down + } else { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Right + } + } else if (isBackwards) { + if (graphDirection === 'LR' && dirEquals(d, Left)) { + preferredDir = Down; preferredOppositeDir = Down + alternativeDir = Left; alternativeOppositeDir = Right + } else if (graphDirection === 'TD' && dirEquals(d, Up)) { + preferredDir = Right; preferredOppositeDir = Right + alternativeDir = Up; alternativeOppositeDir = Down + } else { + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + } else { + // Default: go in the natural direction + preferredDir = d; preferredOppositeDir = getOpposite(d) + alternativeDir = d; alternativeOppositeDir = getOpposite(d) + } + + return [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] +} + +// ============================================================================ +// Edge path determination +// ============================================================================ + +/** + * Determine the path for an edge by trying two candidate routes (preferred + alternative) + * and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir. + * + * When both A* paths fail (common for edges crossing subgraph boundaries), falls back + * to a direct path using the start/end points. This ensures edges always have a path + * for arrowhead rendering. + * + * Uses the effective direction for edge routing, respecting subgraph direction overrides + * when both source and target are in the same subgraph. + */ +export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void { + // Determine effective direction for this edge + // If both nodes are in the same subgraph with a direction override, use it + // Otherwise, use the graph's direction (not source's effective direction) + const sourceSg = getNodeSubgraph(graph, edge.from) + const targetSg = getNodeSubgraph(graph, edge.to) + const effectiveDir = (sourceSg && sourceSg === targetSg && sourceSg.direction) + ? sourceSg.direction + : graph.config.graphDirection + + const [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] = + determineStartAndEndDir(edge, effectiveDir) + + // Try preferred path + const prefFrom = gridCoordDirection(edge.from.gridCoord!, preferredDir) + const prefTo = gridCoordDirection(edge.to.gridCoord!, preferredOppositeDir) + let preferredPath = getPath(graph.grid, prefFrom, prefTo) + + // Try alternative path + const altFrom = gridCoordDirection(edge.from.gridCoord!, alternativeDir) + const altTo = gridCoordDirection(edge.to.gridCoord!, alternativeOppositeDir) + let alternativePath = getPath(graph.grid, altFrom, altTo) + + // Case 1: Both paths found — pick the shorter one + if (preferredPath !== null && alternativePath !== null) { + preferredPath = mergePath(preferredPath) + alternativePath = mergePath(alternativePath) + + if (preferredPath.length <= alternativePath.length) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = preferredPath + } else { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = alternativePath + } + return + } + + // Case 2: Only preferred path found + if (preferredPath !== null) { + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = mergePath(preferredPath) + return + } + + // Case 3: Only alternative path found + if (alternativePath !== null) { + edge.startDir = alternativeDir + edge.endDir = alternativeOppositeDir + edge.path = mergePath(alternativePath) + return + } + + // Case 4: Both paths failed — create a direct fallback path + // This happens for edges crossing subgraph boundaries where A* can't find + // a clear route. We create a direct path from source to target exit points + // so arrowheads can still be rendered correctly. + edge.startDir = preferredDir + edge.endDir = preferredOppositeDir + edge.path = [prefFrom, prefTo] +} + +/** + * Find the best line segment in an edge's path to place a label on. + * Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid + * label collisions when multiple edges share initial segments. + * Falls back to the widest segment if none are suitable. + * Also increases the column width at the label position to fit the text. + */ +export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void { + if (edge.text.length === 0) return + + const lenLabel = edge.text.length + const pathLen = edge.path.length + const isVerticalFlow = graph.config.graphDirection === 'TD' + + // Collect all segments with their widths and orientation + const segments: { + line: [GridCoord, GridCoord] + width: number + index: number + isVertical: boolean + }[] = [] + + for (let i = 1; i < pathLen; i++) { + const p1 = edge.path[i - 1]! + const p2 = edge.path[i]! + const line: [GridCoord, GridCoord] = [p1, p2] + const width = calculateLineWidth(graph, line) + // A segment is vertical if X coords are same, horizontal if Y coords are same + const isVertical = p1.x === p2.x + segments.push({ line, width, index: i, isVertical }) + } + + // Find segments wide enough for the label, excluding the first segment + // The first segment is often shared between edges from the same source node + const suitableSegments = segments.filter(s => s.width >= lenLabel && s.index > 1) + + let largestLine: [GridCoord, GridCoord] + + if (suitableSegments.length > 0) { + // Prefer segments near the end of the path (closer to target) + // This avoids the shared initial segments from source + suitableSegments.sort((a, b) => b.index - a.index) + largestLine = suitableSegments[0]!.line + } else { + // Fall back to any suitable segment including the first + const fallbackSegments = segments.filter(s => s.width >= lenLabel) + if (fallbackSegments.length > 0) { + fallbackSegments.sort((a, b) => b.index - a.index) + largestLine = fallbackSegments[0]!.line + } else { + // No segment wide enough — use the widest one + segments.sort((a, b) => b.width - a.width) + largestLine = segments[0]?.line ?? [edge.path[0]!, edge.path[1]!] + } + } + + // Ensure column at midpoint is wide enough for the label + const minX = Math.min(largestLine[0].x, largestLine[1].x) + const maxX = Math.max(largestLine[0].x, largestLine[1].x) + const middleX = minX + Math.floor((maxX - minX) / 2) + + const current = graph.columnWidth.get(middleX) ?? 0 + graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2)) + + edge.labelLine = [largestLine[0], largestLine[1]] +} + +/** Calculate the total character width of a line segment by summing column widths. */ +function calculateLineWidth(graph: AsciiGraph, line: [GridCoord, GridCoord]): number { + let total = 0 + const startX = Math.min(line[0].x, line[1].x) + const endX = Math.max(line[0].x, line[1].x) + for (let x = startX; x <= endX; x++) { + total += graph.columnWidth.get(x) ?? 0 + } + return total +} diff --git a/ui/vendor/beautiful-mermaid/ascii/er-diagram.ts b/ui/vendor/beautiful-mermaid/ascii/er-diagram.ts new file mode 100644 index 0000000..7ff86b7 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/er-diagram.ts @@ -0,0 +1,435 @@ +// ============================================================================ +// ASCII renderer — ER diagrams +// +// Renders erDiagram text to ASCII/Unicode art. +// Each entity is a 2-section box (header | attributes). +// Relationships are drawn as lines with crow's foot notation at endpoints. +// +// Layout: entities are placed in a grid pattern (multiple rows if needed). +// Relationship lines use Manhattan routing between entity boxes. +// ============================================================================ + +import { parseErDiagram } from '../er/parser.ts' +import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types.ts' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' +import { drawMultiBox } from './draw.ts' +import { splitLines } from './multiline-utils.ts' + +/** Classify a character from a box drawing as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +// ============================================================================ +// Entity box content +// ============================================================================ + +/** Format an attribute line: "PK type name" or "FK type name" etc. */ +function formatAttribute(attr: ErAttribute): string { + const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' ' + return `${keyStr}${attr.type} ${attr.name}` +} + +/** Build sections for an entity box: [header], [attributes] */ +function buildEntitySections(entity: ErEntity): string[][] { + // Support multi-line entity names + const header = splitLines(entity.label) + const attrs = entity.attributes.map(formatAttribute) + if (attrs.length === 0) return [header] + return [header, attrs] +} + +// ============================================================================ +// Crow's foot notation +// ============================================================================ + +/** + * Returns the ASCII/Unicode characters for a crow's foot cardinality marker. + * Markers are drawn adjacent to entity boxes at relationship endpoints. + * + * Standard ER notation: + * one: ─┤├─ perpendicular line (exactly one) + * zero-one: ─○┤─ circle + perpendicular (zero or one) + * many: ─<>─ crow's foot (one or more) + * zero-many: ─○<─ circle + crow's foot (zero or more) + * + * @param card - The cardinality type + * @param useAscii - Use ASCII-only characters + * @param isRight - True if this marker is on the right side of the relationship + */ +function getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string { + if (useAscii) { + switch (card) { + case 'one': return '|' + case 'zero-one': return 'o|' + case 'many': return isRight ? '<' : '>' + case 'zero-many': return isRight ? 'o<' : '>o' + } + } else { + // Use cleaner Unicode characters + switch (card) { + case 'one': return '│' + case 'zero-one': return '○│' + case 'many': return isRight ? '╟' : '╢' + case 'zero-many': return isRight ? '○╟' : '╢○' + } + } +} + +// ============================================================================ +// Positioned entity +// ============================================================================ + +interface PlacedEntity { + entity: ErEntity + sections: string[][] + x: number + y: number + width: number + height: number +} + +// ============================================================================ +// Connected Component Detection +// ============================================================================ + +/** + * Find connected components in the ER diagram using DFS. + * Treats relationships as undirected edges for connectivity. + * + * Returns an array of entity ID sets, one per connected component. + */ +function findConnectedComponents(diagram: ErDiagram): Set[] { + const visited = new Set() + const components: Set[] = [] + + // Build undirected adjacency list from relationships + const neighbors = new Map>() + for (const ent of diagram.entities) { + neighbors.set(ent.id, new Set()) + } + for (const rel of diagram.relationships) { + neighbors.get(rel.entity1)?.add(rel.entity2) + neighbors.get(rel.entity2)?.add(rel.entity1) + } + + // DFS to find each component + function dfs(startId: string, component: Set): void { + const stack = [startId] + while (stack.length > 0) { + const nodeId = stack.pop()! + if (visited.has(nodeId)) continue + + visited.add(nodeId) + component.add(nodeId) + + for (const neighbor of neighbors.get(nodeId) ?? []) { + if (!visited.has(neighbor)) { + stack.push(neighbor) + } + } + } + } + + // Find all components + for (const ent of diagram.entities) { + if (!visited.has(ent.id)) { + const component = new Set() + dfs(ent.id, component) + if (component.size > 0) { + components.push(component) + } + } + } + + return components +} + +// ============================================================================ +// Layout and rendering +// ============================================================================ + +/** + * Render a Mermaid ER diagram to ASCII/Unicode text. + * + * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string. + */ +export function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseErDiagram(lines) + + if (diagram.entities.length === 0) return '' + + const useAscii = config.useAscii + const hGap = 6 // horizontal gap between entity boxes + const vGap = 4 // vertical gap between rows (for relationship lines) + const componentGap = 6 // vertical gap between disconnected components + + // --- Build entity box dimensions --- + const entitySections = new Map() + const entityBoxW = new Map() + const entityBoxH = new Map() + const entityById = new Map() + + for (const ent of diagram.entities) { + entityById.set(ent.id, ent) + const sections = buildEntitySections(ent) + entitySections.set(ent.id, sections) + + let maxTextW = 0 + for (const section of sections) { + for (const line of section) maxTextW = Math.max(maxTextW, line.length) + } + const boxW = maxTextW + 4 // 2 border + 2 padding + + let totalLines = 0 + for (const section of sections) totalLines += Math.max(section.length, 1) + const boxH = totalLines + (sections.length - 1) + 2 + + entityBoxW.set(ent.id, boxW) + entityBoxH.set(ent.id, boxH) + } + + // --- Find connected components --- + const components = findConnectedComponents(diagram) + + // --- Layout: place each component, then stack components vertically --- + const placed = new Map() + let currentY = 0 + + for (const component of components) { + // Get entities in this component (preserve original order for consistency) + const componentEntities = diagram.entities.filter(e => component.has(e.id)) + + // Layout entities within this component horizontally + // Use sqrt-based row limit for larger components + const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length))) + + let currentX = 0 + let maxRowH = 0 + let colCount = 0 + const componentStartY = currentY + + for (const ent of componentEntities) { + const w = entityBoxW.get(ent.id)! + const h = entityBoxH.get(ent.id)! + + if (colCount >= maxPerRow) { + // Wrap to next row within this component + currentY += maxRowH + vGap + currentX = 0 + maxRowH = 0 + colCount = 0 + } + + placed.set(ent.id, { + entity: ent, + sections: entitySections.get(ent.id)!, + x: currentX, + y: currentY, + width: w, + height: h, + }) + + currentX += w + hGap + maxRowH = Math.max(maxRowH, h) + colCount++ + } + + // Move to next component row (add gap between components) + currentY += maxRowH + componentGap + } + + // --- Create canvas --- + let totalW = 0 + let totalH = 0 + for (const p of placed.values()) { + totalW = Math.max(totalW, p.x + p.width) + totalH = Math.max(totalH, p.y + p.height) + } + totalW += 4 + totalH += 2 + + const canvas = mkCanvas(totalW - 1, totalH - 1) + const rc = mkRoleCanvas(totalW - 1, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // --- Draw entity boxes --- + for (const p of placed.values()) { + const boxCanvas = drawMultiBox(p.sections, useAscii) + for (let bx = 0; bx < boxCanvas.length; bx++) { + for (let by = 0; by < boxCanvas[0]!.length; by++) { + const ch = boxCanvas[bx]![by]! + if (ch !== ' ') { + const cx = p.x + bx + const cy = p.y + by + if (cx < totalW && cy < totalH) { + setC(cx, cy, ch, classifyBoxChar(ch)) + } + } + } + } + } + + // --- Draw relationships --- + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const dashH = useAscii ? '.' : '╌' + const dashV = useAscii ? ':' : '┊' + + for (const rel of diagram.relationships) { + const e1 = placed.get(rel.entity1) + const e2 = placed.get(rel.entity2) + if (!e1 || !e2) continue + + const lineH = rel.identifying ? H : dashH + const lineV = rel.identifying ? V : dashV + + // Determine connection direction based on relative position. + // Connect from right side of left entity to left side of right entity (horizontal), + // or from bottom of upper entity to top of lower entity (vertical). + const e1CX = e1.x + Math.floor(e1.width / 2) + const e1CY = e1.y + Math.floor(e1.height / 2) + const e2CX = e2.x + Math.floor(e2.width / 2) + const e2CY = e2.y + Math.floor(e2.height / 2) + + // Check if entities are on the same row (horizontal connection) + const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height) + + if (sameRow) { + // Horizontal connection: right side of left entity → left side of right entity + const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1] + const [leftCard, rightCard] = e1CX < e2CX + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startX = left.x + left.width + const endX = right.x - 1 + const lineY = left.y + Math.floor(left.height / 2) + + // Draw horizontal line + for (let x = startX; x <= endX; x++) { + setC(x, lineY, lineH, 'line') + } + + // Draw crow's foot markers at endpoints + // Left marker (at left entity's right edge) - isRight=false + const leftChars = getCrowsFootChars(leftCard, useAscii, false) + for (let i = 0; i < leftChars.length; i++) { + setC(startX + i, lineY, leftChars[i]!, 'arrow') + } + + // Right marker (at right entity's left edge) - isRight=true + const rightChars = getCrowsFootChars(rightCard, useAscii, true) + for (let i = 0; i < rightChars.length; i++) { + setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow') + } + + // Relationship label centered in the gap between the two entities, below the line. + // Clamp label to the gap region [startX, endX] to avoid overwriting box borders. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const gapMid = Math.floor((startX + endX) / 2) + + // Place lines below the relationship line (lineY + 1, lineY + 2, ...) + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! + const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2)) + const labelY = lineY + 1 + lineIdx + // Ensure canvas is tall enough + increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) + increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1)) + for (let i = 0; i < line.length; i++) { + const lx = labelStart + i + if (lx >= startX && lx <= endX) { + setC(lx, labelY, line[i]!, 'text') + } + } + } + } + } else { + // Vertical connection: bottom of upper entity → top of lower entity + const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1] + const [upperCard, lowerCard] = e1CY < e2CY + ? [rel.cardinality1, rel.cardinality2] + : [rel.cardinality2, rel.cardinality1] + + const startY = upper.y + upper.height + const endY = lower.y - 1 + const lineX = upper.x + Math.floor(upper.width / 2) + + // Vertical line + for (let y = startY; y <= endY; y++) { + setC(lineX, y, lineV, 'line') + } + + // If horizontal offset needed, add a horizontal segment + const lowerCX = lower.x + Math.floor(lower.width / 2) + if (lineX !== lowerCX) { + const midY = Math.floor((startY + endY) / 2) + // Horizontal segment at midY + const lx = Math.min(lineX, lowerCX) + const rx = Math.max(lineX, lowerCX) + for (let x = lx; x <= rx; x++) { + setC(x, midY, lineH, 'line') + } + // Vertical from midY to lower entity + for (let y = midY + 1; y <= endY; y++) { + setC(lowerCX, y, lineV, 'line') + } + } + + // Crow's foot markers (vertical direction) + // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false) + const upperChars = getCrowsFootChars(upperCard, useAscii, false) + for (let i = 0; i < upperChars.length; i++) { + setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow') + } + + // Lower marker (at lower entity's top edge) - treat as target side (isRight=true) + const targetX = lineX !== lowerCX ? lowerCX : lineX + const lowerChars = getCrowsFootChars(lowerCard, useAscii, true) + for (let i = 0; i < lowerChars.length; i++) { + setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow') + } + + // Relationship label — placed to the right of the vertical line at the midpoint. + // We expand the canvas as needed since labels can extend beyond the initial bounds. + // Supports multi-line labels. + if (rel.label) { + const lines = splitLines(rel.label) + const midY = Math.floor((startY + endY) / 2) + // Center lines vertically around midY + const startLabelY = midY - Math.floor((lines.length - 1) / 2) + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! + const labelX = lineX + 2 + const y = startLabelY + lineIdx + if (y >= 0) { + for (let i = 0; i < line.length; i++) { + const lx = labelX + i + if (lx >= 0) { + increaseSize(canvas, lx + 1, y + 1) + increaseRoleCanvasSize(rc, lx + 1, y + 1) + setC(lx, y, line[i]!, 'text') + } + } + } + } + } + } + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/grid.ts b/ui/vendor/beautiful-mermaid/ascii/grid.ts new file mode 100644 index 0000000..0f4f614 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/grid.ts @@ -0,0 +1,578 @@ +// ============================================================================ +// ASCII renderer — grid-based layout +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go. +// Places nodes on a logical grid, computes column/row sizes, +// converts grid coordinates to character-level drawing coordinates, +// and handles subgraph bounding boxes. +// ============================================================================ + +import type { + GridCoord, DrawingCoord, Direction, AsciiGraph, AsciiNode, AsciiSubgraph, +} from './types.ts' +import { gridKey } from './types.ts' +import { mkCanvas, setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas.ts' +import { determinePath, determineLabelLine } from './edge-routing.ts' +import { analyzeEdgeBundles, processBundles } from './edge-bundling.ts' +import { drawBox } from './draw.ts' +import { maxLineWidth, lineCount } from './multiline-utils.ts' +import { getShapeDimensions } from './shapes/index.ts' + +// ============================================================================ +// Grid coordinate → drawing coordinate conversion +// ============================================================================ + +/** + * Convert a grid coordinate to a drawing (character) coordinate. + * Sums column widths up to the target column, and row heights up to the target row, + * then centers within the cell. + */ +export function gridToDrawingCoord( + graph: AsciiGraph, + c: GridCoord, + dir?: Direction, +): DrawingCoord { + const target: GridCoord = dir + ? { x: c.x + dir.x, y: c.y + dir.y } + : c + + let x = 0 + for (let col = 0; col < target.x; col++) { + x += graph.columnWidth.get(col) ?? 0 + } + + let y = 0 + for (let row = 0; row < target.y; row++) { + y += graph.rowHeight.get(row) ?? 0 + } + + const colW = graph.columnWidth.get(target.x) ?? 0 + const rowH = graph.rowHeight.get(target.y) ?? 0 + return { + x: x + Math.floor(colW / 2) + graph.offsetX, + y: y + Math.floor(rowH / 2) + graph.offsetY, + } +} + +/** Convert a path of grid coords to drawing coords. */ +export function lineToDrawing(graph: AsciiGraph, line: GridCoord[]): DrawingCoord[] { + return line.map(c => gridToDrawingCoord(graph, c)) +} + +// ============================================================================ +// Node placement on the grid +// ============================================================================ + +/** + * Reserve a 3x3 block in the grid for a node. + * If the requested position is occupied, recursively shift by 4 grid units + * (in the perpendicular direction based on effective direction) until a free spot is found. + * + * @param effectiveDir - Optional direction override. If not provided, uses the node's + * effective direction (subgraph direction if in a subgraph with override, + * otherwise graph direction). + */ +export function reserveSpotInGrid( + graph: AsciiGraph, + node: AsciiNode, + requested: GridCoord, + effectiveDir?: 'LR' | 'TD', +): GridCoord { + // Determine direction for collision handling + const dir = effectiveDir ?? getEffectiveDirection(graph, node) + + if (graph.grid.has(gridKey(requested))) { + // Collision — shift perpendicular to main flow direction + if (dir === 'LR') { + return reserveSpotInGrid(graph, node, { x: requested.x, y: requested.y + 4 }, dir) + } else { + return reserveSpotInGrid(graph, node, { x: requested.x + 4, y: requested.y }, dir) + } + } + + // Reserve the 3x3 block + for (let dx = 0; dx < 3; dx++) { + for (let dy = 0; dy < 3; dy++) { + const reserved: GridCoord = { x: requested.x + dx, y: requested.y + dy } + graph.grid.set(gridKey(reserved), node) + } + } + + node.gridCoord = requested + return requested +} + +// ============================================================================ +// Column width / row height computation +// ============================================================================ + +/** + * Set column widths and row heights for a node's 3x3 grid block. + * Each node occupies 3 columns (border, content, border) and 3 rows. + * Uses shape-aware dimensions to properly size non-rectangular shapes. + */ +export function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void { + const gc = node.gridCoord! + const padding = graph.config.boxBorderPadding + + // Get shape-aware dimensions + const shapeDims = getShapeDimensions(node.shape, node.displayLabel, { + useAscii: graph.config.useAscii, + padding, + }) + + // Use shape-provided grid dimensions + const colWidths = shapeDims.gridColumns + const rowHeights = shapeDims.gridRows + + for (let idx = 0; idx < colWidths.length; idx++) { + const xCoord = gc.x + idx + const current = graph.columnWidth.get(xCoord) ?? 0 + graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!)) + } + + for (let idx = 0; idx < rowHeights.length; idx++) { + const yCoord = gc.y + idx + const current = graph.rowHeight.get(yCoord) ?? 0 + graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!)) + } + + // Padding column/row before the node (spacing between nodes) + if (gc.x > 0) { + const current = graph.columnWidth.get(gc.x - 1) ?? 0 + graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX)) + } + + if (gc.y > 0) { + let basePadding = graph.config.paddingY + // Extra vertical padding for nodes with incoming edges from outside their subgraph + if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) { + const subgraphOverhead = 4 + basePadding += subgraphOverhead + } + const current = graph.rowHeight.get(gc.y - 1) ?? 0 + graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding)) + } +} + +/** Ensure grid has width/height entries for all cells along an edge path. */ +export function increaseGridSizeForPath(graph: AsciiGraph, path: GridCoord[]): void { + for (const c of path) { + if (!graph.columnWidth.has(c.x)) { + graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2)) + } + if (!graph.rowHeight.has(c.y)) { + graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2)) + } + } +} + +// ============================================================================ +// Subgraph helpers +// ============================================================================ + +function isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + return graph.subgraphs.some(sg => sg.nodes.includes(node)) +} + +/** + * Get the innermost subgraph that directly contains this node. + * Returns null if node is not in any subgraph. + */ +export function getNodeSubgraph(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph | null { + // Find the innermost (most deeply nested) subgraph containing the node + let innermost: AsciiSubgraph | null = null + for (const sg of graph.subgraphs) { + if (sg.nodes.includes(node)) { + // Check if this subgraph is deeper (more nested) than current innermost + if (!innermost || isAncestorOrSelf(innermost, sg)) { + innermost = sg + } + } + } + return innermost +} + +/** Check if `candidate` is the same as or an ancestor of `target`. */ +function isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean { + let current: AsciiSubgraph | null = target + while (current !== null) { + if (current === candidate) return true + current = current.parent + } + return false +} + +/** + * Get the effective direction for a node's layout. + * Returns the subgraph's direction override if the node is in a subgraph with one, + * otherwise returns the graph-level direction. + */ +export function getEffectiveDirection(graph: AsciiGraph, node: AsciiNode): 'LR' | 'TD' { + const sg = getNodeSubgraph(graph, node) + if (sg?.direction) { + return sg.direction + } + return graph.config.graphDirection +} + +/** + * Check if a node has an incoming edge from outside its subgraph + * AND is the topmost such node in its subgraph. + * Used to add extra vertical padding for subgraph borders. + */ +function hasIncomingEdgeFromOutsideSubgraph(graph: AsciiGraph, node: AsciiNode): boolean { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return false + + let hasExternalEdge = false + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + hasExternalEdge = true + break + } + } + } + + if (!hasExternalEdge) return false + + // Only return true for the topmost node with an external incoming edge + for (const otherNode of nodeSg.nodes) { + if (otherNode === node || !otherNode.gridCoord) continue + let otherHasExternal = false + for (const edge of graph.edges) { + if (edge.to === otherNode) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + otherHasExternal = true + break + } + } + } + if (otherHasExternal && otherNode.gridCoord.y < node.gridCoord!.y) { + return false + } + } + + return true +} + +// ============================================================================ +// Subgraph bounding boxes +// ============================================================================ + +function calculateSubgraphBoundingBox(graph: AsciiGraph, sg: AsciiSubgraph): void { + if (sg.nodes.length === 0) return + + let minX = 1_000_000 + let minY = 1_000_000 + let maxX = -1_000_000 + let maxY = -1_000_000 + + // Include children's bounding boxes + for (const child of sg.children) { + calculateSubgraphBoundingBox(graph, child) + if (child.nodes.length > 0) { + minX = Math.min(minX, child.minX) + minY = Math.min(minY, child.minY) + maxX = Math.max(maxX, child.maxX) + maxY = Math.max(maxY, child.maxY) + } + } + + // Include node positions + for (const node of sg.nodes) { + if (!node.drawingCoord || !node.drawing) continue + const nodeMinX = node.drawingCoord.x + const nodeMinY = node.drawingCoord.y + const nodeMaxX = nodeMinX + node.drawing.length - 1 + const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1 + minX = Math.min(minX, nodeMinX) + minY = Math.min(minY, nodeMinY) + maxX = Math.max(maxX, nodeMaxX) + maxY = Math.max(maxY, nodeMaxY) + } + + const subgraphPadding = 2 + const subgraphLabelSpace = 2 + sg.minX = minX - subgraphPadding + sg.minY = minY - subgraphPadding - subgraphLabelSpace + sg.maxX = maxX + subgraphPadding + sg.maxY = maxY + subgraphPadding +} + +/** Ensure non-overlapping root subgraphs have minimum spacing. */ +function ensureSubgraphSpacing(graph: AsciiGraph): void { + const minSpacing = 1 + const rootSubgraphs = graph.subgraphs.filter(sg => sg.parent === null && sg.nodes.length > 0) + + for (let i = 0; i < rootSubgraphs.length; i++) { + for (let j = i + 1; j < rootSubgraphs.length; j++) { + const sg1 = rootSubgraphs[i]! + const sg2 = rootSubgraphs[j]! + + // Horizontal overlap → adjust vertical + if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) { + if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) { + sg2.minY = sg1.maxY + minSpacing + 1 + } else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) { + sg1.minY = sg2.maxY + minSpacing + 1 + } + } + // Vertical overlap → adjust horizontal + if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) { + if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) { + sg2.minX = sg1.maxX + minSpacing + 1 + } else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) { + sg1.minX = sg2.maxX + minSpacing + 1 + } + } + } + } +} + +export function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void { + for (const sg of graph.subgraphs) { + calculateSubgraphBoundingBox(graph, sg) + } + ensureSubgraphSpacing(graph) +} + +/** + * Offset all drawing coordinates so subgraph borders don't go negative. + * If any subgraph has negative min coordinates, shift everything positive. + */ +export function offsetDrawingForSubgraphs(graph: AsciiGraph): void { + if (graph.subgraphs.length === 0) return + + let minX = 0 + let minY = 0 + for (const sg of graph.subgraphs) { + minX = Math.min(minX, sg.minX) + minY = Math.min(minY, sg.minY) + } + + const offsetX = -minX + const offsetY = -minY + if (offsetX === 0 && offsetY === 0) return + + graph.offsetX = offsetX + graph.offsetY = offsetY + + for (const sg of graph.subgraphs) { + sg.minX += offsetX + sg.minY += offsetY + sg.maxX += offsetX + sg.maxY += offsetY + } + + for (const node of graph.nodes) { + if (node.drawingCoord) { + node.drawingCoord.x += offsetX + node.drawingCoord.y += offsetY + } + } +} + +// ============================================================================ +// Main layout orchestrator +// ============================================================================ + +/** + * createMapping performs the full grid layout: + * 1. Place root nodes on the grid + * 2. Place child nodes level by level + * 3. Compute column widths and row heights + * 4. Run A* pathfinding for all edges + * 5. Determine label placement + * 6. Convert grid coords → drawing coords + * 7. Generate node box drawings + * 8. Calculate subgraph bounding boxes + */ +export function createMapping(graph: AsciiGraph): void { + const dir = graph.config.graphDirection + const highestPositionPerLevel: number[] = new Array(100).fill(0) + + // Identify root nodes — nodes that aren't the target of any edge + const nodesFound = new Set() + const initialRoots: AsciiNode[] = [] + + for (const node of graph.nodes) { + if (!nodesFound.has(node.name)) { + initialRoots.push(node) + } + nodesFound.add(node.name) + for (const child of getChildren(graph, node)) { + nodesFound.add(child.name) + } + } + + // Filter out subgraph nodes that have incoming edges from external sources. + // This handles the case where subgraph is declared before external nodes + // (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should). + const rootNodes = initialRoots.filter(node => { + const nodeSg = getNodeSubgraph(graph, node) + if (!nodeSg) return true // external nodes: keep as roots + + // Check if this subgraph node has incoming edges from outside its subgraph + for (const edge of graph.edges) { + if (edge.to === node) { + const sourceSg = getNodeSubgraph(graph, edge.from) + if (sourceSg !== nodeSg) { + return false // has external incoming edge → not a root + } + } + } + return true + }) + + // In LR mode with both external and subgraph roots, separate them + // so subgraph roots are placed one level deeper + let hasExternalRoots = false + let hasSubgraphRootsWithEdges = false + for (const node of rootNodes) { + if (isNodeInAnySubgraph(graph, node)) { + if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true + } else { + hasExternalRoots = true + } + } + const shouldSeparate = dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges + + let externalRootNodes: AsciiNode[] + let subgraphRootNodes: AsciiNode[] = [] + + if (shouldSeparate) { + externalRootNodes = rootNodes.filter(n => !isNodeInAnySubgraph(graph, n)) + subgraphRootNodes = rootNodes.filter(n => isNodeInAnySubgraph(graph, n)) + } else { + externalRootNodes = rootNodes + } + + // Place external root nodes + for (const node of externalRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: 0, y: highestPositionPerLevel[0]! } + : { x: highestPositionPerLevel[0]!, y: 0 } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[0] = highestPositionPerLevel[0]! + 4 + } + + // Place subgraph root nodes at level 4 (one level in from the edge) + if (shouldSeparate && subgraphRootNodes.length > 0) { + const subgraphLevel = 4 + for (const node of subgraphRootNodes) { + const requested: GridCoord = dir === 'LR' + ? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel]! } + : { x: highestPositionPerLevel[subgraphLevel]!, y: subgraphLevel } + reserveSpotInGrid(graph, graph.nodes[node.index]!, requested) + highestPositionPerLevel[subgraphLevel] = highestPositionPerLevel[subgraphLevel]! + 4 + } + } + + // Place child nodes level by level + // Use subgraph direction only when both parent and child are in the same subgraph + // Multi-pass: iterate until all nodes are placed (handles non-topological node order) + // Note: when shouldSeparate, externalRootNodes + subgraphRootNodes = rootNodes + // otherwise, externalRootNodes = rootNodes and subgraphRootNodes is empty + let placedCount = externalRootNodes.length + subgraphRootNodes.length + while (placedCount < graph.nodes.length) { + const prevCount = placedCount + for (const node of graph.nodes) { + if (node.gridCoord === null) continue // skip unplaced nodes + const gc = node.gridCoord + + for (const child of getChildren(graph, node)) { + if (child.gridCoord !== null) continue // already placed + + // Determine direction for this edge (parent -> child) + // Use subgraph direction only if both are in the same subgraph with override + const parentSg = getNodeSubgraph(graph, node) + const childSg = getNodeSubgraph(graph, child) + const edgeDir = (parentSg && parentSg === childSg && parentSg.direction) + ? parentSg.direction + : graph.config.graphDirection + + const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4 + + // Determine position based on direction context + let highestPosition: number + if (edgeDir !== graph.config.graphDirection) { + // Cross-direction: use parent's perpendicular coordinate + // This keeps children aligned with parent when direction changes + highestPosition = edgeDir === 'LR' ? gc.y : gc.x + } else { + // Same direction: use level tracker + highestPosition = highestPositionPerLevel[childLevel]! + } + + const requested: GridCoord = edgeDir === 'LR' + ? { x: childLevel, y: highestPosition } + : { x: highestPosition, y: childLevel } + reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir) + + // Only update level tracker for same-direction placements + if (edgeDir === graph.config.graphDirection) { + highestPositionPerLevel[childLevel] = highestPosition + 4 + } + placedCount++ + } + } + // Safety: break if no progress made (handles disconnected nodes) + if (placedCount === prevCount) break + } + + // Compute column widths and row heights + for (const node of graph.nodes) { + setColumnWidth(graph, node) + } + + // Analyze edges for bundling (parallel links like A & B --> C) + // This groups edges that share sources or targets for cleaner visualization + graph.bundles = analyzeEdgeBundles(graph) + + // Route bundled edges through junction points + processBundles(graph) + + // Route non-bundled edges via A* and determine label positions + for (const edge of graph.edges) { + // Skip edges that were already routed as part of a bundle + if (edge.bundle && edge.path.length > 0) { + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + continue + } + + determinePath(graph, edge) + increaseGridSizeForPath(graph, edge.path) + determineLabelLine(graph, edge) + } + + // Convert grid coords → drawing coords and generate box drawings + for (const node of graph.nodes) { + node.drawingCoord = gridToDrawingCoord(graph, node.gridCoord!) + node.drawing = drawBox(node, graph) + } + + // Set canvas size and compute subgraph bounding boxes + setCanvasSizeToGrid(graph.canvas, graph.columnWidth, graph.rowHeight) + setRoleCanvasSizeToGrid(graph.roleCanvas, graph.columnWidth, graph.rowHeight) + calculateSubgraphBoundingBoxes(graph) + offsetDrawingForSubgraphs(graph) +} + +// ============================================================================ +// Graph traversal helpers +// ============================================================================ + +/** Get all edges originating from a node. */ +function getEdgesFromNode(graph: AsciiGraph, node: AsciiNode): AsciiGraph['edges'] { + return graph.edges.filter(e => e.from.name === node.name) +} + +/** Get all direct children of a node (targets of outgoing edges). */ +function getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] { + return getEdgesFromNode(graph, node).map(e => e.to) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/index.ts b/ui/vendor/beautiful-mermaid/ascii/index.ts new file mode 100644 index 0000000..69161a9 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/index.ts @@ -0,0 +1,171 @@ +// ============================================================================ +// beautiful-mermaid — ASCII renderer public API +// +// Renders Mermaid diagrams to ASCII or Unicode box-drawing art. +// No external dependencies — pure TypeScript. +// +// Supported diagram types: +// - Flowcharts (graph TD / flowchart LR) — grid-based layout with A* pathfinding +// - State diagrams (stateDiagram-v2) — same pipeline as flowcharts +// - Sequence diagrams (sequenceDiagram) — column-based timeline layout +// - Class diagrams (classDiagram) — level-based UML layout +// - ER diagrams (erDiagram) — grid layout with crow's foot notation +// +// Usage: +// import { renderMermaidASCII } from 'beautiful-mermaid' +// const ascii = renderMermaidASCII('graph LR\n A --> B') +// ============================================================================ + +import { parseMermaid } from '../parser.ts' +import { convertToAsciiGraph } from './converter.ts' +import { createMapping } from './grid.ts' +import { drawGraph } from './draw.ts' +import { canvasToString, flipCanvasVertically, flipRoleCanvasVertically } from './canvas.ts' +import { renderSequenceAscii } from './sequence.ts' +import { renderClassAscii } from './class-diagram.ts' +import { renderErAscii } from './er-diagram.ts' +import { renderXYChartAscii } from './xychart.ts' +import { detectColorMode, DEFAULT_ASCII_THEME, diagramColorsToAsciiTheme } from './ansi.ts' +import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts' + +// Re-export types for external use +export type { AsciiTheme, ColorMode } +export { DEFAULT_ASCII_THEME, detectColorMode, diagramColorsToAsciiTheme } + +export interface AsciiRenderOptions { + /** true = ASCII chars (+,-,|,>), false = Unicode box-drawing (┌,─,│,►). Default: false */ + useAscii?: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX?: number + /** Vertical spacing between nodes. Default: 5 */ + paddingY?: number + /** Padding inside node boxes. Default: 1 */ + boxBorderPadding?: number + /** + * Color mode for output. + * - 'none': No colors (plain text) + * - 'auto': Auto-detect (terminal ANSI capabilities, or HTML in browsers) + * - 'ansi16': 16-color ANSI + * - 'ansi256': 256-color xterm + * - 'truecolor': 24-bit RGB + * - 'html': HTML tags with inline color styles (for browser rendering) + * Default: 'auto' + */ + colorMode?: ColorMode | 'auto' + /** Theme colors for ASCII output. Uses default theme if not provided. */ + theme?: Partial +} + +/** + * Detect the diagram type from the mermaid source text. + * Mirrors the detection logic in src/index.ts for the SVG renderer. + */ +function detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' { + const firstLine = text.trim().split('\n')[0]?.trim().toLowerCase() ?? '' + + if (/^xychart(-beta)?\b/.test(firstLine)) return 'xychart' + if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence' + if (/^classdiagram\s*$/.test(firstLine)) return 'class' + if (/^erdiagram\s*$/.test(firstLine)) return 'er' + + // Default: flowchart/state (handled by parseMermaid internally) + return 'flowchart' +} + +/** + * Render Mermaid diagram text to an ASCII/Unicode string. + * + * Synchronous — no async layout engine needed (unlike the SVG renderer). + * Auto-detects diagram type from the header line and dispatches to + * the appropriate renderer. + * + * @param text - Mermaid source text (any supported diagram type) + * @param options - Rendering options + * @returns Multi-line ASCII/Unicode string + * + * @example + * ```ts + * const result = renderMermaidAscii(` + * graph LR + * A --> B --> C + * `, { useAscii: true }) + * + * // Output: + * // +---+ +---+ +---+ + * // | | | | | | + * // | A |---->| B |---->| C | + * // | | | | | | + * // +---+ +---+ +---+ + * ``` + */ +export function renderMermaidASCII( + text: string, + options: AsciiRenderOptions = {}, +): string { + const config: AsciiConfig = { + useAscii: options.useAscii ?? false, + paddingX: options.paddingX ?? 5, + paddingY: options.paddingY ?? 5, + boxBorderPadding: options.boxBorderPadding ?? 1, + graphDirection: 'TD', // default, overridden for flowcharts below + } + + // Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode) + const colorMode: ColorMode = options.colorMode === 'auto' || options.colorMode === undefined + ? detectColorMode() + : options.colorMode + + // Merge user theme with defaults + const theme: AsciiTheme = { ...DEFAULT_ASCII_THEME, ...options.theme } + + const diagramType = detectDiagramType(text) + + switch (diagramType) { + case 'xychart': + return renderXYChartAscii(text, config, colorMode, theme) + + case 'sequence': + return renderSequenceAscii(text, config, colorMode, theme) + + case 'class': + return renderClassAscii(text, config, colorMode, theme) + + case 'er': + return renderErAscii(text, config, colorMode, theme) + + case 'flowchart': + default: { + // Flowchart + state diagram pipeline (original) + const parsed = parseMermaid(text) + + // Normalize direction for grid layout. + // BT is laid out as TD then flipped vertically after drawing. + // RL is treated as LR (full RL support not yet implemented). + if (parsed.direction === 'LR' || parsed.direction === 'RL') { + config.graphDirection = 'LR' + } else { + config.graphDirection = 'TD' + } + + const graph = convertToAsciiGraph(parsed, config) + createMapping(graph) + drawGraph(graph) + + // BT: flip the finished canvas vertically so the flow runs bottom→top. + // The grid layout ran as TD; flipping + character remapping produces BT. + if (parsed.direction === 'BT') { + flipCanvasVertically(graph.canvas) + flipRoleCanvasVertically(graph.roleCanvas) + } + + return canvasToString(graph.canvas, { + roleCanvas: graph.roleCanvas, + colorMode, + theme, + }) + } + } +} + +/** @deprecated Use `renderMermaidASCII` */ +export const renderMermaidAscii = renderMermaidASCII diff --git a/ui/vendor/beautiful-mermaid/ascii/multiline-utils.ts b/ui/vendor/beautiful-mermaid/ascii/multiline-utils.ts new file mode 100644 index 0000000..347d260 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/multiline-utils.ts @@ -0,0 +1,77 @@ +// ============================================================================ +// ASCII renderer — multi-line text utilities +// +// Shared utilities for handling multi-line labels (containing \n from
tags) +// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and +// centered rendering across all diagram types. +// ============================================================================ + +import type { Canvas } from './types.ts' +import { drawText } from './canvas.ts' + +/** + * Split a label into lines. + * Labels are already normalized by parsers (br tags → \n). + */ +export function splitLines(label: string): string[] { + return label.split('\n') +} + +/** + * Get the maximum line width for sizing calculations. + * Used to determine column widths for multi-line labels. + */ +export function maxLineWidth(label: string): number { + const lines = splitLines(label) + return Math.max(...lines.map(l => l.length), 0) +} + +/** + * Get the number of lines for height calculations. + * Used to determine row heights for multi-line labels. + */ +export function lineCount(label: string): number { + return splitLines(label).length +} + +/** + * Draw multi-line text centered at (cx, cy). + * Expands vertically from the center point. + * Each line is horizontally centered independently. + */ +export function drawMultilineTextCentered( + canvas: Canvas, + label: string, + cx: number, + cy: number +): void { + const lines = splitLines(label) + const totalHeight = lines.length + // Center vertically: start y positions lines evenly around cy + const startY = cy - Math.floor((totalHeight - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + // Center each line horizontally + const startX = cx - Math.floor(line.length / 2) + // Force overwrite for node labels (they take priority) + drawText(canvas, { x: startX, y: startY + i }, line, true) + } +} + +/** + * Draw multi-line text left-aligned starting at (x, y). + * Each subsequent line is placed one row below. + */ +export function drawMultilineTextLeft( + canvas: Canvas, + label: string, + x: number, + y: number +): void { + const lines = splitLines(label) + for (let i = 0; i < lines.length; i++) { + // Force overwrite for node labels (they take priority) + drawText(canvas, { x, y: y + i }, lines[i]!, true) + } +} diff --git a/ui/vendor/beautiful-mermaid/ascii/pathfinder.ts b/ui/vendor/beautiful-mermaid/ascii/pathfinder.ts new file mode 100644 index 0000000..ccade51 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/pathfinder.ts @@ -0,0 +1,215 @@ +// ============================================================================ +// ASCII renderer — A* pathfinding for edge routing +// +// Ported from AlexanderGrooff/mermaid-ascii cmd/arrow.go. +// Uses A* search with a corner-penalizing heuristic to find clean +// paths between nodes on the grid. Prefers straight lines over zigzags. +// ============================================================================ + +import type { GridCoord, AsciiNode } from './types.ts' +import { gridKey, gridCoordEquals } from './types.ts' + +// ============================================================================ +// Priority queue (min-heap) for A* open set +// ============================================================================ + +interface PQItem { + coord: GridCoord + priority: number +} + +/** + * Simple min-heap priority queue. + * For the grid sizes we handle (~100s of cells), this is more than fast enough. + */ +class MinHeap { + private items: PQItem[] = [] + + get length(): number { + return this.items.length + } + + push(item: PQItem): void { + this.items.push(item) + this.bubbleUp(this.items.length - 1) + } + + pop(): PQItem | undefined { + if (this.items.length === 0) return undefined + const top = this.items[0]! + const last = this.items.pop()! + if (this.items.length > 0) { + this.items[0] = last + this.sinkDown(0) + } + return top + } + + private bubbleUp(i: number): void { + while (i > 0) { + const parent = (i - 1) >> 1 + if (this.items[i]!.priority < this.items[parent]!.priority) { + ;[this.items[i], this.items[parent]] = [this.items[parent]!, this.items[i]!] + i = parent + } else { + break + } + } + } + + private sinkDown(i: number): void { + const n = this.items.length + while (true) { + let smallest = i + const left = 2 * i + 1 + const right = 2 * i + 2 + if (left < n && this.items[left]!.priority < this.items[smallest]!.priority) { + smallest = left + } + if (right < n && this.items[right]!.priority < this.items[smallest]!.priority) { + smallest = right + } + if (smallest !== i) { + ;[this.items[i], this.items[smallest]] = [this.items[smallest]!, this.items[i]!] + i = smallest + } else { + break + } + } + } +} + +// ============================================================================ +// A* heuristic +// ============================================================================ + +/** + * Manhattan distance with a +1 penalty when both dx and dy are non-zero. + * This encourages the pathfinder to prefer straight lines and minimize corners. + */ +export function heuristic(a: GridCoord, b: GridCoord): number { + const absX = Math.abs(a.x - b.x) + const absY = Math.abs(a.y - b.y) + if (absX === 0 || absY === 0) { + return absX + absY + } + return absX + absY + 1 +} + +// ============================================================================ +// A* pathfinding +// ============================================================================ + +/** 4-directional movement (no diagonals in grid pathfinding). */ +const MOVE_DIRS: GridCoord[] = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, +] + +/** Check if a grid cell is unoccupied and has non-negative coordinates. */ +function isFreeInGrid(grid: Map, c: GridCoord): boolean { + if (c.x < 0 || c.y < 0) return false + return !grid.has(gridKey(c)) +} + +/** + * Find a path from `from` to `to` on the grid using A*. + * Returns the path as an array of GridCoords, or null if no path exists. + */ +export function getPath( + grid: Map, + from: GridCoord, + to: GridCoord, +): GridCoord[] | null { + const pq = new MinHeap() + pq.push({ coord: from, priority: 0 }) + + const costSoFar = new Map() + costSoFar.set(gridKey(from), 0) + + const cameFrom = new Map() + cameFrom.set(gridKey(from), null) + + while (pq.length > 0) { + const current = pq.pop()!.coord + + if (gridCoordEquals(current, to)) { + // Reconstruct path by walking backwards through cameFrom + const path: GridCoord[] = [] + let c: GridCoord | null = current + while (c !== null) { + path.unshift(c) + c = cameFrom.get(gridKey(c)) ?? null + } + return path + } + + const currentCost = costSoFar.get(gridKey(current))! + + for (const dir of MOVE_DIRS) { + const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y } + + // Allow moving to the destination even if it's occupied (it's a node boundary) + if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) { + continue + } + + const newCost = currentCost + 1 + const nextKey = gridKey(next) + const existingCost = costSoFar.get(nextKey) + + if (existingCost === undefined || newCost < existingCost) { + costSoFar.set(nextKey, newCost) + const priority = newCost + heuristic(next, to) + pq.push({ coord: next, priority }) + cameFrom.set(nextKey, current) + } + } + } + + return null // No path found +} + +/** + * Simplify a path by removing intermediate waypoints on straight segments. + * E.g., [(0,0), (1,0), (2,0), (2,1)] becomes [(0,0), (2,0), (2,1)]. + * This reduces the number of line-drawing operations. + */ +export function mergePath(path: GridCoord[]): GridCoord[] { + if (path.length <= 2) return path + + const toRemove = new Set() + let step0 = path[0]! + let step1 = path[1]! + + for (let idx = 2; idx < path.length; idx++) { + const step2 = path[idx]! + const prevDx = step1.x - step0.x + const prevDy = step1.y - step0.y + const dx = step2.x - step1.x + const dy = step2.y - step1.y + + // Same direction — the middle point is redundant + if (prevDx === dx && prevDy === dy) { + // In Go: indexToRemove = append(indexToRemove, idx+1) but idx is 0-based from path[2:] + // which corresponds to index idx in the full path. Go uses idx+1 because idx iterates + // from 0 in the [2:] slice, mapping to full-array index idx+1. + // Actually re-checking Go code: the loop is `for idx, step2 := range path[2:]` + // so idx=0 → path[2], and it removes idx+1 which is index 1 in the full array. + // Wait, that doesn't look right. Let me re-read: + // step0 = path[0], step1 = path[1] + // for idx, step2 := range path[2:] { ... indexToRemove = append(indexToRemove, idx+1) ... } + // When idx=0, step2=path[2], and it removes index 1 (step1 = path[1]) if directions match + // So it removes the middle point (step1) which is at index idx+1 in the original array + // when counting from the 2-ahead loop. Let me just track which middle indices to remove. + toRemove.add(idx - 1) // Remove the middle point (step1's position) + } + + step0 = step1 + step1 = step2 + } + + return path.filter((_, i) => !toRemove.has(i)) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/sequence.ts b/ui/vendor/beautiful-mermaid/ascii/sequence.ts new file mode 100644 index 0000000..5ff5eaa --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/sequence.ts @@ -0,0 +1,451 @@ +// ============================================================================ +// ASCII renderer — sequence diagrams +// +// Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout. +// Each actor occupies a column with a vertical lifeline; messages are horizontal +// arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups. +// +// Layout is fundamentally different from flowcharts — no grid or A* pathfinding. +// Instead: actors → columns, messages → rows, all positioned linearly. +// ============================================================================ + +import { parseSequenceDiagram } from '../sequence/parser.ts' +import type { SequenceDiagram, Block } from '../sequence/types.ts' +import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts' +import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts' +import { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts' + +/** Classify a box-drawing character as 'border' or 'text'. */ +function classifyBoxChar(ch: string): CharRole { + if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|]$/.test(ch)) return 'border' + return 'text' +} + +/** + * Render a Mermaid sequence diagram to ASCII/Unicode text. + * + * Pipeline: parse → layout (columns + rows) → draw onto canvas → string. + */ +export function renderSequenceAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const diagram = parseSequenceDiagram(lines) + + if (diagram.actors.length === 0) return '' + + const useAscii = config.useAscii + + // Box-drawing characters + const H = useAscii ? '-' : '─' + const V = useAscii ? '|' : '│' + const TL = useAscii ? '+' : '┌' + const TR = useAscii ? '+' : '┐' + const BL = useAscii ? '+' : '└' + const BR = useAscii ? '+' : '┘' + const JT = useAscii ? '+' : '┬' // top junction on lifeline + const JB = useAscii ? '+' : '┴' // bottom junction on lifeline + const JL = useAscii ? '+' : '├' // left junction + const JR = useAscii ? '+' : '┤' // right junction + + // ---- LAYOUT: compute lifeline X positions ---- + + const actorIdx = new Map() + diagram.actors.forEach((a, i) => actorIdx.set(a.id, i)) + + const boxPad = 1 + // Use max line width for multi-line actor labels + const actorBoxWidths = diagram.actors.map(a => maxLineWidth(a.label) + 2 * boxPad + 2) + const halfBox = actorBoxWidths.map(w => Math.ceil(w / 2)) + // Calculate actor box heights based on number of lines in label + const actorBoxHeights = diagram.actors.map(a => lineCount(a.label) + 2) // lines + top/bottom border + const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning + + // Compute minimum gap between adjacent lifelines based on message labels. + // For messages spanning multiple actors, distribute the required width across gaps. + const adjMaxWidth: number[] = new Array(Math.max(diagram.actors.length - 1, 0)).fill(0) + + for (const msg of diagram.messages) { + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + if (fi === ti) continue // self-messages don't affect spacing + const lo = Math.min(fi, ti) + const hi = Math.max(fi, ti) + // Required gap per span = (max line width + arrow decorations) / number of gaps + const needed = maxLineWidth(msg.label) + 4 + const numGaps = hi - lo + const perGap = Math.ceil(needed / numGaps) + for (let g = lo; g < hi; g++) { + adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap) + } + } + + // Compute lifeline x-positions (greedy left-to-right) + const llX: number[] = [halfBox[0]!] + for (let i = 1; i < diagram.actors.length; i++) { + const gap = Math.max( + halfBox[i - 1]! + halfBox[i]! + 2, + adjMaxWidth[i - 1]! + 2, + 10, + ) + llX[i] = llX[i - 1]! + gap + } + + // ---- LAYOUT: compute vertical positions for messages ---- + + // For each message index, track the y where its arrow is drawn. + // Also track block start/end y positions and divider y positions. + const msgArrowY: number[] = [] + const msgLabelY: number[] = [] + const blockStartY = new Map() + const blockEndY = new Map() + const divYMap = new Map() // "blockIdx:divIdx" → y + const notePositions: Array<{ x: number; y: number; width: number; height: number; lines: string[] }> = [] + + let curY = actorBoxH // start right below header boxes + + for (let m = 0; m < diagram.messages.length; m++) { + // Block openings at this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.startIndex === m) { + curY += 2 // 1 blank + 1 header row + blockStartY.set(b, curY - 1) + } + } + + // Dividers at this message index + for (let b = 0; b < diagram.blocks.length; b++) { + for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) { + if (diagram.blocks[b]!.dividers[d]!.index === m) { + curY += 1 + divYMap.set(`${b}:${d}`, curY) + curY += 1 + } + } + } + + curY += 1 // blank row before message + + const msg = diagram.messages[m]! + const isSelf = msg.from === msg.to + + // Calculate height needed for multi-line message labels + const msgLineCount = lineCount(msg.label) + + if (isSelf) { + // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm + msgLabelY[m] = curY + 1 + msgArrowY[m] = curY + curY += 2 + msgLineCount // top-arm + label lines + bottom-arm + } else { + // Normal message: label row(s) then arrow row + msgLabelY[m] = curY + msgArrowY[m] = curY + msgLineCount // arrow goes after all label lines + curY += msgLineCount + 1 // label lines + arrow row + } + + // Notes after this message + for (let n = 0; n < diagram.notes.length; n++) { + if (diagram.notes[n]!.afterIndex === m) { + curY += 1 + const note = diagram.notes[n]! + const nLines = splitLines(note.text) + const nWidth = Math.max(...nLines.map(l => l.length)) + 4 + const nHeight = nLines.length + 2 + + // Determine x position based on note.position + const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0 + let nx: number + if (note.position === 'left') { + nx = llX[aIdx]! - nWidth - 1 + } else if (note.position === 'right') { + nx = llX[aIdx]! + 2 + } else { + // 'over' — center over actor(s) + if (note.actorIds.length >= 2) { + const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx + nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2) + } else { + nx = llX[aIdx]! - Math.floor(nWidth / 2) + } + } + nx = Math.max(0, nx) + + notePositions.push({ x: nx, y: curY, width: nWidth, height: nHeight, lines: nLines }) + curY += nHeight + } + } + + // Block closings after this message + for (let b = 0; b < diagram.blocks.length; b++) { + if (diagram.blocks[b]!.endIndex === m) { + curY += 1 + blockEndY.set(b, curY) + curY += 1 + } + } + } + + curY += 1 // gap before footer + const footerY = curY + const totalH = footerY + actorBoxH + + // Total canvas width + const lastLL = llX[llX.length - 1] ?? 0 + const lastHalf = halfBox[halfBox.length - 1] ?? 0 + let totalW = lastLL + lastHalf + 2 + + // Ensure canvas is wide enough for self-message labels and notes + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + if (msg.from === msg.to) { + const fi = actorIdx.get(msg.from)! + const selfRight = llX[fi]! + 6 + 2 + msg.label.length + totalW = Math.max(totalW, selfRight + 1) + } + } + for (const np of notePositions) { + totalW = Math.max(totalW, np.x + np.width + 1) + } + + const canvas = mkCanvas(totalW, totalH - 1) + const rc = mkRoleCanvas(totalW, totalH - 1) + + /** Set a character on the canvas and track its role. */ + function setC(x: number, y: number, ch: string, role: CharRole): void { + if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) { + canvas[x]![y] = ch + setRole(rc, x, y, role) + } + } + + // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ---- + + function drawActorBox(cx: number, topY: number, label: string): void { + const lines = splitLines(label) + const maxW = maxLineWidth(label) + const w = maxW + 2 * boxPad + 2 + const h = lines.length + 2 // lines + top/bottom border + const left = cx - Math.floor(w / 2) + + // Top border + setC(left, topY, TL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border') + setC(left + w - 1, topY, TR, 'border') + + // Content lines (centered horizontally within the box) + for (let i = 0; i < lines.length; i++) { + const row = topY + 1 + i + setC(left, row, V, 'border') + setC(left + w - 1, row, V, 'border') + // Center this line within the box + const line = lines[i]! + const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2) + for (let j = 0; j < line.length; j++) { + setC(ls + j, row, line[j]!, 'text') + } + } + + // Bottom border + const bottomY = topY + h - 1 + setC(left, bottomY, BL, 'border') + for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border') + setC(left + w - 1, bottomY, BR, 'border') + } + + // ---- DRAW: lifelines ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const x = llX[i]! + for (let y = actorBoxH; y <= footerY; y++) { + setC(x, y, V, 'line') + } + } + + // ---- DRAW: actor header + footer boxes (drawn over lifelines) ---- + + for (let i = 0; i < diagram.actors.length; i++) { + const actor = diagram.actors[i]! + drawActorBox(llX[i]!, 0, actor.label) + drawActorBox(llX[i]!, footerY, actor.label) + + // Lifeline junctions on box borders (Unicode only) + if (!useAscii) { + setC(llX[i]!, actorBoxH - 1, JT, 'junction') + setC(llX[i]!, footerY, JB, 'junction') + } + } + + // ---- DRAW: messages ---- + + for (let m = 0; m < diagram.messages.length; m++) { + const msg = diagram.messages[m]! + const fi = actorIdx.get(msg.from)! + const ti = actorIdx.get(msg.to)! + const fromX = llX[fi]! + const toX = llX[ti]! + const isSelf = fi === ti + const isDashed = msg.lineStyle === 'dashed' + const isFilled = msg.arrowHead === 'filled' + + // Arrow line character (solid vs dashed) + const lineChar = isDashed ? (useAscii ? '.' : '╌') : H + + if (isSelf) { + // Self-message: 3-row loop to the right of the lifeline + // ├──┐ (row 0 = msgArrowY) + // │ │ Label (row 1) + // │◄─┘ (row 2) + const y0 = msgArrowY[m]! + const loopW = Math.max(4, 4) + + // Row 0: start junction + horizontal + top-right corner + setC(fromX, y0, JL, 'junction') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0, lineChar, 'line') + setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner') + + // Row 1: vertical on right side + label + setC(fromX + loopW, y0 + 1, V, 'line') + const labelX = fromX + loopW + 2 + for (let i = 0; i < msg.label.length; i++) { + if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i]!, 'text') + } + + // Row 2: arrow-back + horizontal + bottom-right corner + const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') + setC(fromX, y0 + 2, arrowChar, 'arrow') + for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, 'line') + setC(fromX + loopW, y0 + 2, useAscii ? '+' : '┘', 'corner') + } else { + // Normal message: label on row above, arrow on row below + const labelY = msgLabelY[m]! + const arrowY = msgArrowY[m]! + const leftToRight = fromX < toX + + // Draw label centered between the two lifelines (supports multi-line) + const midX = Math.floor((fromX + toX) / 2) + const msgLines = splitLines(msg.label) + + for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) { + const line = msgLines[lineIdx]! + const labelStart = midX - Math.floor(line.length / 2) + const y = labelY + lineIdx + for (let i = 0; i < line.length; i++) { + const lx = labelStart + i + if (lx >= 0 && lx < totalW) setC(lx, y, line[i]!, 'text') + } + } + + // Draw arrow line + if (leftToRight) { + for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, 'line') + // Arrowhead at destination + const ah = isFilled ? (useAscii ? '>' : '▶') : (useAscii ? '>' : '▷') + setC(toX, arrowY, ah, 'arrow') + } else { + for (let x = toX + 1; x < fromX; x++) setC(x, arrowY, lineChar, 'line') + const ah = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁') + setC(toX, arrowY, ah, 'arrow') + } + } + } + + // ---- DRAW: blocks (loop, alt, opt, par, etc.) ---- + + for (let b = 0; b < diagram.blocks.length; b++) { + const block = diagram.blocks[b]! + const topY = blockStartY.get(b) + const botY = blockEndY.get(b) + if (topY === undefined || botY === undefined) continue + + // Find the leftmost/rightmost lifelines involved in this block's messages + let minLX = totalW + let maxLX = 0 + for (let m = block.startIndex; m <= block.endIndex; m++) { + if (m >= diagram.messages.length) break + const msg = diagram.messages[m]! + const f = actorIdx.get(msg.from) ?? 0 + const t = actorIdx.get(msg.to) ?? 0 + minLX = Math.min(minLX, llX[Math.min(f, t)]!) + maxLX = Math.max(maxLX, llX[Math.max(f, t)]!) + } + + const bLeft = Math.max(0, minLX - 4) + const bRight = Math.min(totalW - 1, maxLX + 4) + + // Top border with block type label + setC(bLeft, topY, TL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border') + setC(bRight, topY, TR, 'border') + // Write block header label over the top border (supports multi-line) + const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type + const hdrLines = splitLines(hdrLabel) + + for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) { + const line = hdrLines[lineIdx]! + for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) { + setC(bLeft + 1 + i, topY + lineIdx, line[i]!, 'text') + } + } + + // Bottom border + setC(bLeft, botY, BL, 'border') + for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border') + setC(bRight, botY, BR, 'border') + + // Side borders + for (let y = topY + 1; y < botY; y++) { + setC(bLeft, y, V, 'border') + setC(bRight, y, V, 'border') + } + + // Dividers + for (let d = 0; d < block.dividers.length; d++) { + const dY = divYMap.get(`${b}:${d}`) + if (dY === undefined) continue + const dashChar = isDashedH() + setC(bLeft, dY, JL, 'junction') + for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line') + setC(bRight, dY, JR, 'junction') + // Divider label + const dLabel = block.dividers[d]!.label + if (dLabel) { + const dStr = `[${dLabel}]` + for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) { + setC(bLeft + 1 + i, dY, dStr[i]!, 'text') + } + } + } + } + + // ---- DRAW: notes ---- + + for (const np of notePositions) { + // Ensure canvas is big enough + increaseSize(canvas, np.x + np.width, np.y + np.height) + increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height) + // Top border + setC(np.x, np.y, TL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border') + setC(np.x + np.width - 1, np.y, TR, 'border') + // Content rows + for (let l = 0; l < np.lines.length; l++) { + const ly = np.y + 1 + l + setC(np.x, ly, V, 'border') + setC(np.x + np.width - 1, ly, V, 'border') + for (let i = 0; i < np.lines[l]!.length; i++) { + setC(np.x + 2 + i, ly, np.lines[l]![i]!, 'text') + } + } + // Bottom border + const by = np.y + np.height - 1 + setC(np.x, by, BL, 'border') + for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border') + setC(np.x + np.width - 1, by, BR, 'border') + } + + return canvasToString(canvas, { roleCanvas: rc, colorMode, theme }) + + // ---- Helper: dashed horizontal character ---- + function isDashedH(): string { + return useAscii ? '-' : '╌' + } +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/circle.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/circle.ts new file mode 100644 index 0000000..ddeda48 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/circle.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Circle shape renderer — uses corner decorators instead of curves +// ============================================================================ + +import type { ShapeRenderer } from './types.ts' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' +import { getCorners } from './corners.ts' + +/** + * Circle shape renderer. + * Uses circle markers (◯) at corners to indicate circular shape semantics. + * + * Renders as: + * ◯─────────◯ + * │ Label │ + * ◯─────────◯ + */ +export const circleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('circle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/corners.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/corners.ts new file mode 100644 index 0000000..b070e0f --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/corners.ts @@ -0,0 +1,127 @@ +// ============================================================================ +// Corner character lookup table for shape rendering +// ============================================================================ +// +// All shapes are rendered as rectangles with distinctive corner characters +// to indicate shape type. This eliminates diagonal characters while keeping +// shapes visually distinguishable. + +import type { AsciiNodeShape } from '../types.ts' + +/** + * Corner characters for a shape in both Unicode and ASCII modes. + */ +export interface CornerChars { + /** Top-left corner */ + tl: string + /** Top-right corner */ + tr: string + /** Bottom-left corner */ + bl: string + /** Bottom-right corner */ + br: string +} + +/** + * Shape corner configuration with both Unicode and ASCII variants. + */ +export interface ShapeCorners { + unicode: CornerChars + ascii: CornerChars +} + +/** + * Corner character lookup table for all shape types. + * + * Design principles: + * - All shapes use orthogonal box structure (no diagonals) + * - Corner characters indicate shape semantics + * - ASCII fallbacks use available punctuation + */ +export const SHAPE_CORNERS: Record = { + // Standard rectangular shapes + rectangle: { + unicode: { tl: '┌', tr: '┐', bl: '└', br: '┘' }, + ascii: { tl: '+', tr: '+', bl: '+', br: '+' }, + }, + rounded: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Circular shapes - use circle markers at corners + circle: { + unicode: { tl: '◯', tr: '◯', bl: '◯', br: '◯' }, + ascii: { tl: 'o', tr: 'o', bl: 'o', br: 'o' }, + }, + doublecircle: { + unicode: { tl: '◎', tr: '◎', bl: '◎', br: '◎' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, + + // Diamond - decision nodes + diamond: { + unicode: { tl: '◇', tr: '◇', bl: '◇', br: '◇' }, + ascii: { tl: '<', tr: '>', bl: '<', br: '>' }, + }, + + // Hexagon - process nodes (crop corners — monospace-safe, distinct from rectangle) + hexagon: { + unicode: { tl: '⌜', tr: '⌝', bl: '⌞', br: '⌟' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + + // Stadium/pill shape + stadium: { + unicode: { tl: '(', tr: ')', bl: '(', br: ')' }, + ascii: { tl: '(', tr: ')', bl: '(', br: ')' }, + }, + + // Subroutine - double vertical bars + subroutine: { + unicode: { tl: '╟', tr: '╢', bl: '╟', br: '╢' }, + ascii: { tl: '|', tr: '|', bl: '|', br: '|' }, + }, + + // Cylinder/database + cylinder: { + unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' }, + ascii: { tl: '.', tr: '.', bl: "'", br: "'" }, + }, + + // Asymmetric/flag - pointer on left side + asymmetric: { + unicode: { tl: '▷', tr: '┐', bl: '▷', br: '┘' }, + ascii: { tl: '>', tr: '+', bl: '>', br: '+' }, + }, + + // Trapezoid - wider at bottom (top corners slope inward) + trapezoid: { + unicode: { tl: '/', tr: '\\', bl: '└', br: '┘' }, + ascii: { tl: '/', tr: '\\', bl: '+', br: '+' }, + }, + + // Trapezoid-alt - wider at top (bottom corners slope inward) + 'trapezoid-alt': { + unicode: { tl: '┌', tr: '┐', bl: '\\', br: '/' }, + ascii: { tl: '+', tr: '+', bl: '\\', br: '/' }, + }, + + // State diagram pseudostates (special handling, not corner-based) + 'state-start': { + unicode: { tl: '●', tr: '●', bl: '●', br: '●' }, + ascii: { tl: '*', tr: '*', bl: '*', br: '*' }, + }, + 'state-end': { + unicode: { tl: '◉', tr: '◉', bl: '◉', br: '◉' }, + ascii: { tl: '@', tr: '@', bl: '@', br: '@' }, + }, +} + +/** + * Get corner characters for a shape type. + */ +export function getCorners(shape: AsciiNodeShape, useAscii: boolean): CornerChars { + const corners = SHAPE_CORNERS[shape] ?? SHAPE_CORNERS.rectangle + return useAscii ? corners.ascii : corners.unicode +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/diamond.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/diamond.ts new file mode 100644 index 0000000..8bd8132 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/diamond.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Diamond shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types.ts' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' +import { getCorners } from './corners.ts' + +/** + * Diamond shape renderer. + * Uses diamond markers (◇) at corners to indicate decision node semantics. + * + * Renders as: + * ◇─────────◇ + * │ Label │ + * ◇─────────◇ + */ +export const diamondRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('diamond', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/hexagon.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/hexagon.ts new file mode 100644 index 0000000..ef6a082 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/hexagon.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Hexagon shape renderer — uses corner decorators instead of diagonals +// ============================================================================ + +import type { ShapeRenderer } from './types.ts' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' +import { getCorners } from './corners.ts' + +/** + * Hexagon shape renderer. + * Uses hexagon markers (⬡) at corners to indicate process node semantics. + * + * Renders as: + * ⬡─────────⬡ + * │ Label │ + * ⬡─────────⬡ + */ +export const hexagonRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('hexagon', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/index.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/index.ts new file mode 100644 index 0000000..7f1e3aa --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/index.ts @@ -0,0 +1,101 @@ +// ============================================================================ +// Shape registry — pluggable ASCII shape renderers +// ============================================================================ + +import type { AsciiNodeShape, Canvas, DrawingCoord, Direction } from '../types.ts' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } from './types.ts' + +// Import all shape renderers +import { rectangleRenderer } from './rectangle.ts' +import { diamondRenderer } from './diamond.ts' +import { circleRenderer } from './circle.ts' +import { stateStartRenderer, stateEndRenderer } from './state.ts' +import { roundedRenderer } from './rounded.ts' +import { stadiumRenderer } from './stadium.ts' +import { hexagonRenderer } from './hexagon.ts' +import { + subroutineRenderer, + doublecircleRenderer, + cylinderRenderer, + asymmetricRenderer, + trapezoidRenderer, + trapezoidAltRenderer, +} from './special.ts' + +// Re-export types +export type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } + +/** + * Global shape registry — maps shape types to their renderers. + * Rectangle is the default fallback for unregistered shapes. + */ +export const shapeRegistry: ShapeRegistry = new Map([ + // Core shapes + ['rectangle', rectangleRenderer], + ['rounded', roundedRenderer], + ['diamond', diamondRenderer], + ['stadium', stadiumRenderer], + ['circle', circleRenderer], + + // Batch 1 additions + ['subroutine', subroutineRenderer], + ['doublecircle', doublecircleRenderer], + ['hexagon', hexagonRenderer], + + // Batch 2 additions + ['cylinder', cylinderRenderer], + ['asymmetric', asymmetricRenderer], + ['trapezoid', trapezoidRenderer], + ['trapezoid-alt', trapezoidAltRenderer], + + // State diagram pseudo-states + ['state-start', stateStartRenderer], + ['state-end', stateEndRenderer], +]) + +/** + * Get the renderer for a shape type, falling back to rectangle. + */ +export function getShapeRenderer(shape: AsciiNodeShape): ShapeRenderer { + return shapeRegistry.get(shape) ?? rectangleRenderer +} + +/** + * Render a node shape to a canvas. + * This is the main entry point for shape rendering. + */ +export function renderShape( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): Canvas { + const renderer = getShapeRenderer(shape) + const dimensions = renderer.getDimensions(label, options) + return renderer.render(label, dimensions, options) +} + +/** + * Get dimensions for a shape given a label. + * Used during layout to determine node size. + */ +export function getShapeDimensions( + shape: AsciiNodeShape, + label: string, + options: ShapeRenderOptions +): ShapeDimensions { + const renderer = getShapeRenderer(shape) + return renderer.getDimensions(label, options) +} + +/** + * Get edge attachment point for a shape. + */ +export function getShapeAttachmentPoint( + shape: AsciiNodeShape, + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const renderer = getShapeRenderer(shape) + return renderer.getAttachmentPoint(dir, dimensions, baseCoord) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/rectangle.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/rectangle.ts new file mode 100644 index 0000000..5d35dc3 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/rectangle.ts @@ -0,0 +1,173 @@ +// ============================================================================ +// Rectangle shape renderer — standard box with corners +// ============================================================================ +// +// This module provides the base box rendering used by all rectangular shapes. +// The renderBox() function accepts custom corner characters, allowing different +// shapes to reuse the same rendering logic with different visual markers. + +import type { Canvas, DrawingCoord, Direction } from '../types.ts' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle } from '../types.ts' +import { mkCanvas } from '../canvas.ts' +import { splitLines } from '../multiline-utils.ts' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' +import { dirEquals } from '../edge-routing.ts' +import { type CornerChars, getCorners } from './corners.ts' + +// ============================================================================ +// Shared dimension calculation +// ============================================================================ + +/** + * Calculate standard box dimensions for any rectangular shape. + * Used by rectangle, circle, diamond, hexagon, etc. + */ +export function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => l.length), 0) + const lineCount = lines.length + + // Width: 2*padding + maxLineWidth + 2 border chars + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + + // Height: lineCount + 2*padding + 2 border chars + // Ensure innerHeight is odd for symmetric vertical centering + const rawInnerHeight = lineCount + 2 * options.padding + const innerHeight = rawInnerHeight % 2 === 0 ? rawInnerHeight + 1 : rawInnerHeight + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + // Grid layout: [border=1, content, border=1] + gridColumns: [1, innerWidth, 1], + gridRows: [1, innerHeight, 1], + } +} + +// ============================================================================ +// Shared box rendering +// ============================================================================ + +/** + * Render a box with custom corner characters. + * This is the core rendering function used by all rectangular shapes. + * + * @param label - Text to display in the box + * @param dimensions - Pre-calculated dimensions + * @param corners - Corner characters (tl, tr, bl, br) + * @param useAscii - Whether to use ASCII or Unicode for lines + */ +export function renderBox( + label: string, + dimensions: ShapeDimensions, + corners: CornerChars, + useAscii: boolean +): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const from = { x: 0, y: 0 } + const to = { x: width - 1, y: height - 1 } + + // Line characters + const hLine = useAscii ? '-' : '─' + const vLine = useAscii ? '|' : '│' + + // Draw horizontal lines (top and bottom) + for (let x = from.x + 1; x < to.x; x++) { + canvas[x]![from.y] = hLine + canvas[x]![to.y] = hLine + } + + // Draw vertical lines (left and right) + for (let y = from.y + 1; y < to.y; y++) { + canvas[from.x]![y] = vLine + canvas[to.x]![y] = vLine + } + + // Draw corners + canvas[from.x]![from.y] = corners.tl + canvas[to.x]![from.y] = corners.tr + canvas[from.x]![to.y] = corners.bl + canvas[to.x]![to.y] = corners.br + + // Center the multi-line label + const lines = splitLines(label) + const w = width - 1 // Match original grid-based width calculation + const h = height - 1 + const centerY = Math.floor(h / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1 + for (let j = 0; j < line.length; j++) { + const x = textX + j + const y = startY + i + if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0]!.length) { + canvas[x]![y] = line[j]! + } + } + } + + return canvas +} + +// ============================================================================ +// Shared attachment point calculation +// ============================================================================ + +/** + * Calculate edge attachment point for rectangular shapes. + * All box-based shapes use the same attachment logic. + */ +export function getBoxAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord +): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + if (dirEquals(dir, UpperLeft)) return { x: baseCoord.x, y: baseCoord.y } + if (dirEquals(dir, UpperRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y } + if (dirEquals(dir, LowerLeft)) return { x: baseCoord.x, y: baseCoord.y + height - 1 } + if (dirEquals(dir, LowerRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y + height - 1 } + // Middle + return { x: centerX, y: centerY } +} + +// ============================================================================ +// Rectangle renderer +// ============================================================================ + +/** + * Rectangle shape renderer — the default box shape. + * Renders as: + * ┌─────────┐ + * │ Label │ + * └─────────┘ + */ +export const rectangleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const corners = getCorners('rectangle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/rounded.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/rounded.ts new file mode 100644 index 0000000..38b39ff --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/rounded.ts @@ -0,0 +1,27 @@ +// ============================================================================ +// Rounded rectangle shape renderer — uses rounded corner decorators +// ============================================================================ + +import type { ShapeRenderer } from './types.ts' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' +import { getCorners } from './corners.ts' + +/** + * Rounded rectangle shape renderer. + * Uses rounded corner markers (╭╮╰╯) to indicate soft edges. + * + * Renders as: + * ╭─────────╮ + * │ Label │ + * ╰─────────╯ + */ +export const roundedRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('rounded', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/special.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/special.ts new file mode 100644 index 0000000..e7ce95d --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/special.ts @@ -0,0 +1,293 @@ +// ============================================================================ +// Special shape renderers — subroutine, doublecircle, cylinder, etc. +// ============================================================================ +// +// Some shapes have unique internal structure (subroutine, cylinder) and keep +// custom rendering. Others use the corner decorator pattern for simplicity. + +import type { Canvas, DrawingCoord, Direction } from '../types.ts' +import { Up, Down, Left, Right } from '../types.ts' +import { mkCanvas } from '../canvas.ts' +import { splitLines } from '../multiline-utils.ts' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' +import { dirEquals } from '../edge-routing.ts' +import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts' +import { getCorners } from './corners.ts' + +// ============================================================================ +// Subroutine — keeps custom double-border rendering +// ============================================================================ + +/** + * Subroutine shape renderer — double-bordered rectangle. + * Renders as: + * ┌┬─────────┬┐ + * ││ Label ││ + * └┴─────────┴┘ + */ +export const subroutineRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => l.length), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Double borders on each side + const innerHeight = lineCount + 2 * options.padding + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top border + canvas[0]![0] = options.useAscii ? '+' : '┌' + canvas[1]![0] = options.useAscii ? '+' : '┬' + for (let x = 2; x < width - 2; x++) canvas[x]![0] = hChar + canvas[width - 2]![0] = options.useAscii ? '+' : '┬' + canvas[width - 1]![0] = options.useAscii ? '+' : '┐' + + // Sides with double border + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = vChar + canvas[1]![y] = vChar + canvas[width - 2]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Bottom border + canvas[0]![height - 1] = options.useAscii ? '+' : '└' + canvas[1]![height - 1] = options.useAscii ? '+' : '┴' + for (let x = 2; x < width - 2; x++) canvas[x]![height - 1] = hChar + canvas[width - 2]![height - 1] = options.useAscii ? '+' : '┴' + canvas[width - 1]![height - 1] = options.useAscii ? '+' : '┘' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const textX = Math.floor(width / 2) - Math.floor(line.length / 2) + for (let j = 0; j < line.length; j++) { + const x = textX + j + const y = startY + i + if (x > 1 && x < width - 2 && y > 0 && y < height - 1) { + canvas[x]![y] = line[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Double circle — uses corner decorators +// ============================================================================ + +/** + * Double circle shape renderer. + * Uses double circle markers (◎) at corners. + * + * Renders as: + * ◎─────────◎ + * │ Label │ + * ◎─────────◎ + */ +export const doublecircleRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('doublecircle', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Cylinder — keeps custom rendering for database appearance +// ============================================================================ + +/** + * Cylinder shape renderer — database symbol. + * Renders as: + * ╭─────╮ + * │─────│ + * │ DB │ + * │─────│ + * ╰─────╯ + */ +export const cylinderRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => l.length), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 2 + const innerHeight = lineCount + 2 * options.padding + 2 // Extra for curved top/bottom + const height = innerHeight + 2 + + return { + width, + height, + labelArea: { + x: 1 + options.padding, + y: 2 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [1, innerWidth, 1], + gridRows: [2, innerHeight - 2, 2], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const hChar = options.useAscii ? '-' : '─' + const vChar = options.useAscii ? '|' : '│' + + // Top ellipse + canvas[0]![0] = options.useAscii ? '.' : '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = options.useAscii ? '.' : '╮' + + // Second row - bottom of top ellipse + canvas[0]![1] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![1] = hChar + canvas[width - 1]![1] = vChar + + // Middle section + for (let y = 2; y < height - 2; y++) { + canvas[0]![y] = vChar + canvas[width - 1]![y] = vChar + } + + // Second to last row - top of bottom ellipse + canvas[0]![height - 2] = vChar + for (let x = 1; x < width - 1; x++) canvas[x]![height - 2] = hChar + canvas[width - 1]![height - 2] = vChar + + // Bottom ellipse + canvas[0]![height - 1] = options.useAscii ? '\'' : '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = options.useAscii ? '\'' : '╯' + + // Center the label + const lines = splitLines(label) + const centerY = Math.floor(height / 2) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const textX = Math.floor(width / 2) - Math.floor(line.length / 2) + for (let j = 0; j < line.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y > 1 && y < height - 2) { + canvas[x]![y] = line[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Asymmetric (flag) — uses corner decorators +// ============================================================================ + +/** + * Asymmetric (flag/banner) shape renderer. + * Uses arrow markers (▷) on left corners. + * + * Renders as: + * ▷─────────┐ + * │ Label │ + * ▷─────────┘ + */ +export const asymmetricRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('asymmetric', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid shape renderer — wider at bottom. + * Uses slope markers (◸◹) on top corners. + * + * Renders as: + * ◸─────────◹ + * │ Label │ + * └─────────┘ + */ +export const trapezoidRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} + +// ============================================================================ +// Trapezoid-alt — uses corner decorators instead of diagonal sides +// ============================================================================ + +/** + * Trapezoid-alt shape renderer — wider at top. + * Uses slope markers (◺◿) on bottom corners. + * + * Renders as: + * ┌─────────┐ + * │ Label │ + * ◺─────────◿ + */ +export const trapezoidAltRenderer: ShapeRenderer = { + getDimensions: getBoxDimensions, + + render(label, dimensions, options) { + const corners = getCorners('trapezoid-alt', options.useAscii) + return renderBox(label, dimensions, corners, options.useAscii) + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/stadium.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/stadium.ts new file mode 100644 index 0000000..b400cad --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/stadium.ts @@ -0,0 +1,112 @@ +// ============================================================================ +// Stadium (pill) shape renderer — special parentheses-based rendering +// ============================================================================ +// +// Stadium has unique rendering: single-line is inline `(Label)`, multi-line +// uses parentheses or rounded corners. This differs from other shapes that +// use corner decorators with box lines. + +import type { Canvas, DrawingCoord, Direction } from '../types.ts' +import { mkCanvas } from '../canvas.ts' +import { splitLines } from '../multiline-utils.ts' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' +import { getBoxAttachmentPoint } from './rectangle.ts' + +/** + * Stadium (pill) shape renderer. + * + * Single-line: ( Label ) + * + * Multi-line unicode: + * ╭──────────╮ + * │ Label │ + * ╰──────────╯ + * + * Multi-line ASCII: + * (----------) + * ( Label ) + * (----------) + */ +export const stadiumRenderer: ShapeRenderer = { + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions { + const lines = splitLines(label) + const maxLineWidth = Math.max(...lines.map(l => l.length), 0) + const lineCount = lines.length + + const innerWidth = 2 * options.padding + maxLineWidth + const width = innerWidth + 4 // Extra for rounded ends + const innerHeight = lineCount + 2 * options.padding + const height = Math.max(innerHeight + 2, 3) + + return { + width, + height, + labelArea: { + x: 2 + options.padding, + y: 1 + options.padding, + width: maxLineWidth, + height: lineCount, + }, + gridColumns: [2, innerWidth, 2], + gridRows: [1, innerHeight, 1], + } + }, + + render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerY = Math.floor(height / 2) + const hChar = options.useAscii ? '-' : '─' + + if (height === 3) { + // Single row pill: ( Label ) + canvas[0]![centerY] = '(' + canvas[width - 1]![centerY] = ')' + } else if (!options.useAscii) { + // Multi-row stadium with rounded corners (unicode) + canvas[0]![0] = '╭' + for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar + canvas[width - 1]![0] = '╮' + + for (let y = 1; y < height - 1; y++) { + canvas[0]![y] = '│' + canvas[width - 1]![y] = '│' + } + + canvas[0]![height - 1] = '╰' + for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar + canvas[width - 1]![height - 1] = '╯' + } else { + // Multi-row stadium ASCII — parentheses on all sides + for (let y = 0; y < height; y++) { + canvas[0]![y] = '(' + canvas[width - 1]![y] = ')' + } + for (let x = 1; x < width - 1; x++) { + canvas[x]![0] = hChar + canvas[x]![height - 1] = hChar + } + } + + // Center the label + const lines = splitLines(label) + const startY = centerY - Math.floor((lines.length - 1) / 2) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const textX = Math.floor(width / 2) - Math.floor(line.length / 2) + for (let j = 0; j < line.length; j++) { + const x = textX + j + const y = startY + i + if (x > 0 && x < width - 1 && y >= 0 && y < height) { + canvas[x]![y] = line[j]! + } + } + } + + return canvas + }, + + getAttachmentPoint: getBoxAttachmentPoint, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/state.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/state.ts new file mode 100644 index 0000000..9c38d87 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/state.ts @@ -0,0 +1,192 @@ +// ============================================================================ +// State pseudo-state renderers — UML start and end states +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction } from '../types.ts' +import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight } from '../types.ts' +import { mkCanvas } from '../canvas.ts' +import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts' +import { dirEquals } from '../edge-routing.ts' + +/** + * State start pseudo-state renderer — filled circle in rounded box. + * Renders as: + * ╭───╮ + * │ ● │ (Unicode) + * ╰───╯ + * + * .---. + * | * | (ASCII) + * '---' + * + * This represents the UML initial pseudo-state. + */ +export const stateStartRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // Start state is a 5x3 rounded box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode rounded box with filled circle: ╭───╮ │ ● │ ╰───╯ + canvas[0]![0] = '╭' + canvas[1]![0] = '─' + canvas[2]![0] = '─' + canvas[3]![0] = '─' + canvas[4]![0] = '╮' + + canvas[0]![1] = '│' + canvas[centerX]![1] = '●' + canvas[4]![1] = '│' + + canvas[0]![2] = '╰' + canvas[1]![2] = '─' + canvas[2]![2] = '─' + canvas[3]![2] = '─' + canvas[4]![2] = '╯' + } else { + // ASCII rounded box: .---. | * | '---' + canvas[0]![0] = '.' + canvas[1]![0] = '-' + canvas[2]![0] = '-' + canvas[3]![0] = '-' + canvas[4]![0] = '.' + + canvas[0]![1] = '|' + canvas[centerX]![1] = '*' + canvas[4]![1] = '|' + + canvas[0]![2] = '\'' + canvas[1]![2] = '-' + canvas[2]![2] = '-' + canvas[3]![2] = '-' + canvas[4]![2] = '\'' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} + +/** + * State end pseudo-state renderer — bullseye in double-bordered box. + * Renders as: + * ╔═══╗ + * ║ ◎ ║ (Unicode) + * ╚═══╝ + * + * #===# + * # * # (ASCII) + * #===# + * + * This represents the UML final state. The double border distinguishes it + * from the start state's single rounded border. + */ +export const stateEndRenderer: ShapeRenderer = { + getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions { + // End state is a 5x3 double-bordered box with centered symbol + const width = 5 + const height = 3 + + return { + width, + height, + labelArea: { x: 2, y: 1, width: 1, height: 1 }, + gridColumns: [1, 3, 1], + gridRows: [1, 1, 1], + } + }, + + render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas { + const { width, height } = dimensions + const canvas = mkCanvas(width - 1, height - 1) + + const centerX = Math.floor(width / 2) // = 2 + + if (!options.useAscii) { + // Unicode double-bordered box with bullseye: ╔═══╗ ║ ◎ ║ ╚═══╝ + canvas[0]![0] = '╔' + canvas[1]![0] = '═' + canvas[2]![0] = '═' + canvas[3]![0] = '═' + canvas[4]![0] = '╗' + + canvas[0]![1] = '║' + canvas[centerX]![1] = '◎' + canvas[4]![1] = '║' + + canvas[0]![2] = '╚' + canvas[1]![2] = '═' + canvas[2]![2] = '═' + canvas[3]![2] = '═' + canvas[4]![2] = '╝' + } else { + // ASCII double-bordered box: #===# # * # #===# + canvas[0]![0] = '#' + canvas[1]![0] = '=' + canvas[2]![0] = '=' + canvas[3]![0] = '=' + canvas[4]![0] = '#' + + canvas[0]![1] = '#' + canvas[centerX]![1] = '*' + canvas[4]![1] = '#' + + canvas[0]![2] = '#' + canvas[1]![2] = '=' + canvas[2]![2] = '=' + canvas[3]![2] = '=' + canvas[4]![2] = '#' + } + + return canvas + }, + + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord { + const { width, height } = dimensions + const centerX = baseCoord.x + Math.floor(width / 2) + const centerY = baseCoord.y + Math.floor(height / 2) + + if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y } + if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 } + if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY } + if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY } + // All diagonals and middle point to center + return { x: centerX, y: centerY } + }, +} diff --git a/ui/vendor/beautiful-mermaid/ascii/shapes/types.ts b/ui/vendor/beautiful-mermaid/ascii/shapes/types.ts new file mode 100644 index 0000000..ba116c7 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/shapes/types.ts @@ -0,0 +1,73 @@ +// ============================================================================ +// Shape renderer types — interface for pluggable ASCII shape renderers +// ============================================================================ + +import type { Canvas, DrawingCoord, Direction, AsciiNodeShape } from '../types.ts' + +/** + * Dimensions calculated for a shape, used by layout and rendering. + */ +export interface ShapeDimensions { + /** Total width in characters including borders */ + width: number + /** Total height in characters including borders */ + height: number + /** Label area bounds (where text can be placed) */ + labelArea: { + x: number + y: number + width: number + height: number + } + /** Grid column widths for the 3-column layout [left, center, right] */ + gridColumns: [number, number, number] + /** Grid row heights for the 3-row layout [top, middle, bottom] */ + gridRows: [number, number, number] +} + +/** + * Options passed to shape renderers. + */ +export interface ShapeRenderOptions { + /** Use ASCII chars (+,-,|) vs Unicode box-drawing (┌,─,│) */ + useAscii: boolean + /** Padding inside the shape */ + padding: number +} + +/** + * Interface for pluggable shape renderers. + * Each shape type implements this interface. + */ +export interface ShapeRenderer { + /** + * Calculate dimensions for this shape given a label. + * Used during layout to determine node size. + */ + getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions + + /** + * Render the shape to a canvas. + * Returns a standalone canvas containing just the shape. + */ + render( + label: string, + dimensions: ShapeDimensions, + options: ShapeRenderOptions + ): Canvas + + /** + * Get the edge attachment point for a given direction. + * Used by edge routing to determine where edges connect. + */ + getAttachmentPoint( + dir: Direction, + dimensions: ShapeDimensions, + baseCoord: DrawingCoord + ): DrawingCoord +} + +/** + * Registry of shape renderers keyed by shape type. + */ +export type ShapeRegistry = Map diff --git a/ui/vendor/beautiful-mermaid/ascii/types.ts b/ui/vendor/beautiful-mermaid/ascii/types.ts new file mode 100644 index 0000000..e6d93e5 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/types.ts @@ -0,0 +1,273 @@ +// ============================================================================ +// ASCII renderer — type definitions +// +// Ported from AlexanderGrooff/mermaid-ascii (Go). +// These types model the grid-based coordinate system, 2D text canvas, +// and graph structures used by the ASCII/Unicode renderer. +// ============================================================================ + +import type { NodeShape } from '../types.ts' + +// Re-export NodeShape for convenience +export type { NodeShape } + +/** + * Shape type for ASCII rendering — maps parser shapes to ASCII renderers. + * Most shapes from the parser are supported, with fallback to 'rectangle'. + */ +export type AsciiNodeShape = NodeShape + +/** Logical grid coordinate — nodes occupy 3x3 blocks on this grid. */ +export interface GridCoord { + x: number + y: number +} + +/** Character-level coordinate on the 2D text canvas. */ +export interface DrawingCoord { + x: number + y: number +} + +/** + * Direction constants model positions on a node's 3x3 grid block. + * Each node occupies grid cells [x..x+2, y..y+2]. + * Directions are offsets into that block, used for edge attachment points. + * + * (0,0) UL (1,0) Up (2,0) UR + * (0,1) Left (1,1) Mid (2,1) Right + * (0,2) LL (1,2) Down (2,2) LR + */ +export interface Direction { + readonly x: number + readonly y: number +} + +export const Up: Direction = { x: 1, y: 0 } +export const Down: Direction = { x: 1, y: 2 } +export const Left: Direction = { x: 0, y: 1 } +export const Right: Direction = { x: 2, y: 1 } +export const UpperRight: Direction = { x: 2, y: 0 } +export const UpperLeft: Direction = { x: 0, y: 0 } +export const LowerRight: Direction = { x: 2, y: 2 } +export const LowerLeft: Direction = { x: 0, y: 2 } +export const Middle: Direction = { x: 1, y: 1 } + +/** All named directions for iteration. */ +export const ALL_DIRECTIONS: readonly Direction[] = [ + Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle, +] + +/** + * 2D text canvas — column-major (canvas[x][y]). + * Each cell holds a single character (or space). + */ +export type Canvas = string[][] + +/** A node in the ASCII graph, positioned on the grid. */ +export interface AsciiNode { + /** Unique identity key — the original node ID from the parser (e.g. "A", "B"). */ + name: string + /** Human-readable label for rendering inside the box (e.g. "Web Server"). */ + displayLabel: string + /** Node shape from the parser (e.g. "rectangle", "diamond", "circle"). */ + shape: AsciiNodeShape + index: number + gridCoord: GridCoord | null + drawingCoord: DrawingCoord | null + drawing: Canvas | null + drawn: boolean + styleClassName: string + styleClass: AsciiStyleClass +} + +/** Style class for colored node text (ported from Go's classDef). */ +export interface AsciiStyleClass { + name: string + styles: Record +} + +/** Edge line style for ASCII rendering. */ +export type AsciiEdgeStyle = 'solid' | 'dotted' | 'thick' + +/** An edge in the ASCII graph, with a routed path. */ +export interface AsciiEdge { + from: AsciiNode + to: AsciiNode + text: string + path: GridCoord[] + labelLine: GridCoord[] + startDir: Direction + endDir: Direction + /** Line style: solid (default), dotted (-.->) or thick (==>) */ + style: AsciiEdgeStyle + /** Whether to render an arrowhead at the start (source end) of the edge */ + hasArrowStart: boolean + /** Whether to render an arrowhead at the end (target end) of the edge */ + hasArrowEnd: boolean + /** Bundle this edge belongs to (if any). Set during bundling analysis. */ + bundle?: EdgeBundle + /** + * For bundled edges: path from source/target to the junction point. + * The full visual path is: pathToJunction + bundle.sharedPath (for fan-in) + * or bundle.sharedPath + pathToJunction (for fan-out). + */ + pathToJunction?: GridCoord[] +} + +/** A subgraph container with bounding box for rendering. */ +export interface AsciiSubgraph { + name: string + nodes: AsciiNode[] + parent: AsciiSubgraph | null + children: AsciiSubgraph[] + minX: number + minY: number + maxX: number + maxY: number + /** Optional direction override for layout within this subgraph (LR or TD). */ + direction?: 'LR' | 'TD' +} + +/** Configuration for ASCII rendering. */ +export interface AsciiConfig { + /** true = ASCII chars (+,-,|), false = Unicode box-drawing (┌,─,│). Default: false */ + useAscii: boolean + /** Horizontal spacing between nodes. Default: 5 */ + paddingX: number + /** Vertical spacing between nodes. Default: 5 */ + paddingY: number + /** Padding inside node boxes. Default: 1 */ + boxBorderPadding: number + /** Graph direction: "LR" or "TD". */ + graphDirection: 'LR' | 'TD' +} + +/** Full ASCII graph state used during layout and rendering. */ +export interface AsciiGraph { + nodes: AsciiNode[] + edges: AsciiEdge[] + canvas: Canvas + /** Role canvas — tracks the role of each character for colored output. */ + roleCanvas: RoleCanvas + /** Grid occupancy map — maps "x,y" keys to node references. */ + grid: Map + columnWidth: Map + rowHeight: Map + subgraphs: AsciiSubgraph[] + config: AsciiConfig + /** Offset applied to all drawing coords to accommodate subgraph borders. */ + offsetX: number + offsetY: number + /** Edge bundles for parallel link visualization. Set during bundling analysis. */ + bundles: EdgeBundle[] +} + +// ============================================================================ +// Coordinate helpers +// ============================================================================ + +export function gridCoordEquals(a: GridCoord, b: GridCoord): boolean { + return a.x === b.x && a.y === b.y +} + +export function drawingCoordEquals(a: DrawingCoord, b: DrawingCoord): boolean { + return a.x === b.x && a.y === b.y +} + +/** Apply a direction offset to a grid coordinate (move into the 3x3 block). */ +export function gridCoordDirection(c: GridCoord, dir: Direction): GridCoord { + return { x: c.x + dir.x, y: c.y + dir.y } +} + +/** Key for storing GridCoord in a Map. */ +export function gridKey(c: GridCoord): string { + return `${c.x},${c.y}` +} + +/** Default empty style class. */ +export const EMPTY_STYLE: AsciiStyleClass = { name: '', styles: {} } + +// ============================================================================ +// Character role types for colored output +// ============================================================================ + +/** + * Role of a character in the ASCII diagram, used for theming. + * Each role maps to a different color when colors are enabled. + */ +export type CharRole = + | 'text' // Node labels, edge labels + | 'border' // Node box borders, subgraph borders + | 'line' // Edge lines (paths between nodes) + | 'arrow' // Arrowheads (▲▼◄► or ^v<>) + | 'corner' // Corner characters at path bends + | 'junction' // Junction characters (┬┴├┤ where edges meet boxes) + +/** + * Role canvas — parallel to Canvas, tracks the role of each character. + * Same column-major structure: roleCanvas[x][y] gives the role at (x, y). + * null means the character has no role (whitespace). + */ +export type RoleCanvas = (CharRole | null)[][] + +/** + * Theme colors for ASCII output — hex color strings. + * Derived from the SVG theme system for visual consistency. + */ +export interface AsciiTheme { + /** Text color (node labels, edge labels) */ + fg: string + /** Box border color (node borders, subgraph borders) */ + border: string + /** Edge line color (paths between nodes) */ + line: string + /** Arrowhead color (▲▼◄► or ^v<>) */ + arrow: string + /** Theme accent color (optional, used by xycharts for series 0) */ + accent?: string + /** Background color (optional, used by xycharts for dark-mode-aware shading) */ + bg?: string + /** Corner character color (optional, defaults to line) */ + corner?: string + /** Junction character color (optional, defaults to border) */ + junction?: string +} + +/** Color mode for output. */ +export type ColorMode = + | 'none' // No colors (plain text) + | 'ansi16' // 16-color ANSI (basic terminals) + | 'ansi256' // 256-color ANSI (xterm) + | 'truecolor' // 24-bit RGB (modern terminals) + | 'html' // HTML tags with inline color styles (browsers) + +// ============================================================================ +// Edge bundling types +// ============================================================================ + +/** + * Edge bundle — groups edges that share a common source or target. + * Used to visually merge parallel links before they reach the shared node. + * + * For fan-in (A & B --> C): multiple sources converge to one target. + * For fan-out (A --> B & C): one source diverges to multiple targets. + */ +export interface EdgeBundle { + /** Bundle type: fan-in = many→one, fan-out = one→many */ + type: 'fan-in' | 'fan-out' + /** Edges in this bundle */ + edges: AsciiEdge[] + /** The common node (target for fan-in, source for fan-out) */ + sharedNode: AsciiNode + /** The non-shared nodes (sources for fan-in, targets for fan-out) */ + otherNodes: AsciiNode[] + /** Junction point where edges merge/split — set during routing */ + junctionPoint: GridCoord | null + /** Path from junction to shared node (drawn once for all edges) */ + sharedPath: GridCoord[] + /** Direction when entering/exiting the junction */ + junctionDir: Direction + /** Direction when entering/exiting the shared node */ + sharedNodeDir: Direction +} diff --git a/ui/vendor/beautiful-mermaid/ascii/validate.ts b/ui/vendor/beautiful-mermaid/ascii/validate.ts new file mode 100644 index 0000000..dcbb258 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/validate.ts @@ -0,0 +1,120 @@ +/** + * ASCII Rendering Validation Utilities + * + * Provides validation functions for ASCII diagram output, + * including diagonal line detection to ensure orthogonal-only routing. + */ + +/** + * Characters that represent diagonal lines in ASCII and Unicode modes. + * These should never appear in properly rendered diagrams. + */ +export const DIAGONAL_CHARS = { + ascii: ['/', '\\'], + unicode: ['\u2571', '\u2572'], // ╱ ╲ + all: ['/', '\\', '\u2571', '\u2572'], +} as const + +/** + * Position of a diagonal character in ASCII output. + */ +export interface DiagonalPosition { + line: number + col: number + char: string +} + +/** + * Check if ASCII output contains any diagonal line characters. + * Returns true if diagonals are found (which is an error condition). + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns true if diagonal characters are present, false otherwise + */ +export function hasDiagonalLines(asciiOutput: string): boolean { + return DIAGONAL_CHARS.all.some((char) => asciiOutput.includes(char)) +} + +/** + * Find all diagonal line character positions in ASCII output. + * Useful for debugging when diagonals are detected. + * + * Skips diagonal characters that appear inside node labels (between box borders). + * This prevents false positives from labels like "feature/auth" or "release/1.0". + * + * @param asciiOutput - The rendered ASCII diagram string + * @returns Array of positions where diagonal characters were found + */ +export function findDiagonalLines(asciiOutput: string): DiagonalPosition[] { + const positions: DiagonalPosition[] = [] + const lines = asciiOutput.split('\n') + + // Box-drawing characters that indicate node boundaries + const boxBorders = new Set(['│', '┤', '├', '║', '┃', '|']) + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum]! + + // Find all box border positions in this line + const borderPositions: number[] = [] + for (let col = 0; col < line.length; col++) { + if (boxBorders.has(line[col]!)) { + borderPositions.push(col) + } + } + + for (let col = 0; col < line.length; col++) { + const char = line[col]! + if (DIAGONAL_CHARS.all.includes(char as '/' | '\\' | '╱' | '╲')) { + // Check if this position is inside a node (between two box borders) + // Find the nearest borders before and after this position + let insideNode = false + for (let i = 0; i < borderPositions.length - 1; i++) { + const leftBorder = borderPositions[i]! + const rightBorder = borderPositions[i + 1]! + if (col > leftBorder && col < rightBorder) { + // This diagonal char is between two borders - likely inside a node label + insideNode = true + break + } + } + + if (!insideNode) { + positions.push({ + line: lineNum + 1, // 1-indexed for human readability + col: col + 1, + char, + }) + } + } + } + } + + return positions +} + +/** + * Assert that ASCII output contains no diagonal lines. + * Throws an error with detailed position information if diagonals are found. + * + * @param asciiOutput - The rendered ASCII diagram string + * @param context - Optional context string for error message (e.g., diagram name) + * @throws Error if diagonal characters are present + */ +export function assertNoDiagonals(asciiOutput: string, context?: string): void { + if (!hasDiagonalLines(asciiOutput)) { + return + } + + const positions = findDiagonalLines(asciiOutput) + const contextStr = context ? ` in "${context}"` : '' + const positionStr = positions + .map((p) => ` Line ${p.line}, Col ${p.col}: '${p.char}'`) + .join('\n') + + throw new Error( + `Diagonal lines detected${contextStr}. ` + + `Edges must use orthogonal Manhattan routing (90° bends only).\n` + + `Found ${positions.length} diagonal character(s):\n${positionStr}` + ) +} diff --git a/ui/vendor/beautiful-mermaid/ascii/xychart.ts b/ui/vendor/beautiful-mermaid/ascii/xychart.ts new file mode 100644 index 0000000..0dafc16 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/ascii/xychart.ts @@ -0,0 +1,863 @@ +// ============================================================================ +// ASCII renderer — XY Chart +// +// Renders xychart-beta diagrams to ASCII/Unicode text art. +// Uses the parsed XYChart type directly (not PositionedXYChart) since +// pixel coordinates don't map to character grids. +// +// Bar charts: █ (Unicode) or # (ASCII) block characters. +// Line charts: continuous staircase routing with rounded corners (╭╮╰╯│─). +// +// Multi-series support: each series gets a distinct color from a palette. +// ============================================================================ + +import { parseXYChart } from '../xychart/parser.ts' +import type { XYChart } from '../xychart/types.ts' +import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types.ts' +import { colorizeText } from './ansi.ts' +import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors.ts' + +// ============================================================================ +// Constants +// ============================================================================ + +const PLOT_WIDTH = 60 +const PLOT_HEIGHT = 20 + +// Unicode box-drawing characters +const UNI = { + hLine: '─', + vLine: '│', + origin: '┼', + yTick: '┤', + xTick: '┬', + bar: '█', + grid: '·', + cornerTL: '╭', // top-left: down+right + cornerTR: '╮', // top-right: down+left + cornerBL: '╰', // bottom-left: up+right + cornerBR: '╯', // bottom-right: up+left +} as const + +// ASCII fallback characters +const ASC = { + hLine: '-', + vLine: '|', + origin: '+', + yTick: '+', + xTick: '+', + bar: '#', + grid: '.', + cornerTL: '+', + cornerTR: '+', + cornerBL: '+', + cornerBR: '+', +} as const + +// ============================================================================ +// Multi-series color support +// ============================================================================ + +/** Per-cell hex color override canvas. Parallel to RoleCanvas. */ +type HexCanvas = (string | null)[][] + +/** Generate an array of hex colors, one per series. */ +function getSeriesColors(total: number, theme: AsciiTheme): string[] { + const accent = theme.accent ?? CHART_ACCENT_FALLBACK + if (total <= 1) return [accent] + return Array.from({ length: total }, (_, i) => getSeriesColor(i, accent, theme.bg)) +} + +/** Map a CharRole to its hex color from the theme (for canvasToString fallback). */ +function roleToHex(role: CharRole, theme: AsciiTheme): string { + switch (role) { + case 'text': return theme.fg + case 'border': return theme.border + case 'line': return theme.line + case 'arrow': return theme.arrow + case 'corner': return theme.corner ?? theme.line + case 'junction': return theme.junction ?? theme.border + default: return theme.fg + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +export function renderXYChartAscii( + text: string, + config: AsciiConfig, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + const chart = parseXYChart(lines) + const ch = config.useAscii ? ASC : UNI + + if (chart.horizontal) { + return renderHorizontal(chart, ch, colorMode, theme) + } + return renderVertical(chart, ch, colorMode, theme) +} + +// ============================================================================ +// Vertical chart layout + rendering +// ============================================================================ + +function renderVertical( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + const yRange = chart.yAxis.range! + const yTicks = niceTickValues(yRange.min, yRange.max) + const yLabels = yTicks.map(v => formatTickValue(v)) + const yGutter = Math.max(...yLabels.map(l => l.length)) + 1 + + const plotW = Math.max(PLOT_WIDTH, dataCount * 6) + const plotH = PLOT_HEIGHT + const bandW = Math.floor(plotW / dataCount) + const catLabels = getCategoryLabels(chart, dataCount) + + // Canvas dimensions + const hasTitle = !!chart.title + const hasXTitle = !!chart.xAxis.title + const hasLegend = chart.series.length > 1 + const titleRow = hasTitle ? 0 : -1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = yGutter + 1 // +1 for axis character + const totalW = plotLeft + bandW * dataCount + 2 + const xAxisRow = plotTop + plotH + const xLabelRow = xAxisRow + 1 + const xTitleRow = hasXTitle ? xLabelRow + 1 : -1 + const totalH = xLabelRow + 1 + (hasXTitle ? 1 : 0) + (hasLegend && !hasTitle ? 0 : 0) + + // Create canvas + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Scales + const valueToRow = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return Math.round(t * (plotH - 1)) + } + const bandCenter = (i: number): number => plotLeft + Math.floor(bandW * (i + 0.5)) + + // 1. Title + if (hasTitle && titleRow >= 0) { + writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') + } + + // 2. Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // 3. Y-axis line + ticks + labels + for (let row = 0; row < plotH; row++) { + const displayRow = plotTop + (plotH - 1 - row) + set(canvas, roles, displayRow, plotLeft - 1, ch.vLine, 'border') + } + // Origin + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + const label = formatTickValue(tick) + // Tick mark on axis + set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border') + // Label + const labelStart = yGutter - label.length + writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text') + } + + // 4. X-axis line + ticks + labels + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (let i = 0; i < dataCount; i++) { + const cx = bandCenter(i) + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + // Label below + const label = catLabels[i]! + const labelStart = cx - Math.floor(label.length / 2) + writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text') + } + + // 5. X-axis title + if (hasXTitle && xTitleRow >= 0) { + const title = chart.xAxis.title! + writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, 'text') + } + + // 6. Grid lines (subtle horizontal dots at y-tick positions) + for (const tick of yTicks) { + const row = valueToRow(tick) + if (row < 0 || row >= plotH) continue + const displayRow = plotTop + (plotH - 1 - row) + for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) { + if (get(canvas, displayRow, c) === ' ') { + set(canvas, roles, displayRow, c, ch.grid, 'line') + } + } + } + + // 7. Bars — track global series index for per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const usable = Math.max(1, bandW - 2) + const singleBarW = Math.max(1, Math.min(Math.floor(usable / barCount), 8)) + const groupW = singleBarW * barCount + (barCount - 1) + const baseRow = valueToRow(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const cx = bandCenter(i) + const groupLeft = cx - Math.floor(groupW / 2) + const bx = groupLeft + bIdx * (singleBarW + 1) + const valRow = valueToRow(entry.data[i]!) + const fromRow = Math.min(baseRow, valRow) + const toRow = Math.max(baseRow, valRow) + + for (let row = fromRow; row <= toRow; row++) { + const displayRow = plotTop + (plotH - 1 - row) + for (let c = bx; c < bx + singleBarW; c++) { + set(canvas, roles, displayRow, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // 8. Lines (staircase routing with rounded corners) + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawStaircaseLine(canvas, roles, entry.data, bandCenter, valueToRow, plotTop, plotH, plotLeft, bandW * dataCount, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Horizontal chart layout + rendering +// ============================================================================ + +function renderHorizontal( + chart: XYChart, + ch: typeof UNI | typeof ASC, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + const dataCount = getDataCount(chart) + if (dataCount === 0) return '' + + const yRange = chart.yAxis.range! + const valueTicks = niceTickValues(yRange.min, yRange.max) + const catLabels = getCategoryLabels(chart, dataCount) + const catGutter = Math.max(...catLabels.map(l => l.length)) + 1 + + const plotW = Math.max(PLOT_WIDTH, 40) + const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount)) + const plotH = bandH * dataCount + + const hasTitle = !!chart.title + const hasYTitle = !!chart.yAxis.title + const hasLegend = chart.series.length > 1 + const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0) + const plotLeft = catGutter + 1 + const totalW = plotLeft + plotW + 2 + const totalH = plotTop + plotH + 2 + (hasYTitle ? 1 : 0) + const xAxisRow = plotTop + plotH + + const canvas = createCanvas(totalW, totalH) + const roles = createRoleCanvas(totalW, totalH) + const hexColors = createHexCanvas(totalW, totalH) + + // Series colors + const seriesColors = getSeriesColors(chart.series.length, theme) + + // Value scale (horizontal) + const valueToCol = (v: number): number => { + const t = (v - yRange.min) / (yRange.max - yRange.min || 1) + return plotLeft + Math.round(t * (plotW - 1)) + } + const bandMid = (i: number): number => plotTop + Math.floor(bandH * (i + 0.5)) + + // Title + if (hasTitle) { + writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text') + } + + // Legend + if (hasLegend) { + const legendRow = hasTitle ? 1 : 0 + drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors) + } + + // Y-axis (category axis on left) + for (let r = plotTop; r < plotTop + plotH; r++) { + set(canvas, roles, r, plotLeft - 1, ch.vLine, 'border') + } + set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border') + + for (let i = 0; i < dataCount; i++) { + const my = bandMid(i) + const label = catLabels[i]! + const labelStart = catGutter - label.length + writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text') + } + + // X-axis (value axis on bottom) + for (let c = plotLeft; c < plotLeft + plotW; c++) { + set(canvas, roles, xAxisRow, c, ch.hLine, 'border') + } + for (const tick of valueTicks) { + const cx = valueToCol(tick) + if (cx < plotLeft || cx >= plotLeft + plotW) continue + set(canvas, roles, xAxisRow, cx, ch.xTick, 'border') + const label = formatTickValue(tick) + writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, 'text') + } + + // Y-axis title + if (hasYTitle) { + const title = chart.yAxis.title! + writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, 'text') + } + + // Grid lines (vertical at value tick positions) + for (const tick of valueTicks) { + const cx = valueToCol(tick) + if (cx < plotLeft || cx >= plotLeft + plotW) continue + for (let r = plotTop; r < plotTop + plotH; r++) { + if (get(canvas, r, cx) === ' ') { + set(canvas, roles, r, cx, ch.grid, 'line') + } + } + } + + // Bars (horizontal) — with per-series colors + const barEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + if (barEntries.length > 0) { + const barCount = barEntries.length + const singleBarH = 1 + const groupH = singleBarH * barCount + (barCount - 1) + const baseCol = valueToCol(Math.max(0, yRange.min)) + + for (let bIdx = 0; bIdx < barEntries.length; bIdx++) { + const entry = barEntries[bIdx]! + const hexColor = seriesColors[entry.globalIdx]! + for (let i = 0; i < entry.data.length; i++) { + const my = bandMid(i) + const groupTop = my - Math.floor(groupH / 2) + const by = groupTop + bIdx * (singleBarH + 1) + const valCol = valueToCol(entry.data[i]!) + const fromCol = Math.min(baseCol, valCol) + const toCol = Math.max(baseCol, valCol) + + for (let r = by; r < by + singleBarH; r++) { + for (let c = fromCol; c <= toCol; c++) { + set(canvas, roles, r, c, ch.bar, 'arrow', hexColors, hexColor) + } + } + } + } + } + + // Lines (horizontal staircase: value on x, category on y) — with per-series colors + const lineEntries: { data: number[]; globalIdx: number }[] = [] + for (let si = 0; si < chart.series.length; si++) { + if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si }) + } + + for (const entry of lineEntries) { + if (entry.data.length === 0) continue + const hexColor = seriesColors[entry.globalIdx]! + drawHorizontalStaircaseLine(canvas, roles, entry.data, bandMid, valueToCol, plotTop, plotH, plotLeft, plotW, ch, hexColors, hexColor) + } + + return canvasToString(canvas, roles, hexColors, colorMode, theme) +} + +// ============================================================================ +// Staircase line drawing — vertical charts +// +// Connects data points with flat segments (─) at each value's row, +// vertical segments (│) between rows, and rounded corners (╭╮╰╯) +// at transitions. The vertical step happens at the midpoint column +// between adjacent data points. +// ============================================================================ + +function drawStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandCenter: (i: number) => number, + valueToRow: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotTotalW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + col: bandCenter(i), + row: valueToRow(v), + })) + + // Helper to draw on the canvas (row 0 = bottom, displayed inverted) + const drawAt = (col: number, row: number, char: string) => { + const displayRow = plotTop + (plotH - 1 - row) + if (displayRow >= 0 && col >= plotLeft && col < plotLeft + plotTotalW) { + set(canvas, roles, displayRow, col, char, 'arrow', hexCanvas, hexColor) + } + } + + // Single point: just draw a flat segment + if (points.length === 1) { + drawAt(points[0]!.col, points[0]!.row, ch.hLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.row === p2.row) { + // Flat: draw ─ across + for (let c = p1.col; c <= p2.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + continue + } + + const midCol = Math.round((p1.col + p2.col) / 2) + const goingUp = p2.row > p1.row + + // 1. Flat at p1's row from p1.col to midCol-1 + for (let c = p1.col; c < midCol; c++) { + drawAt(c, p1.row, ch.hLine) + } + + // 2. Corner at (midCol, p1.row) + // goingUp: ─ from LEFT, │ going UP → LEFT+TOP = ╯ (cornerBR) + // goingDown: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + if (goingUp) { + drawAt(midCol, p1.row, ch.cornerBR) // ╯ + } else { + drawAt(midCol, p1.row, ch.cornerTR) // ╮ + } + + // 3. Vertical from p1.row to p2.row (exclusive of endpoints) + const minRow = Math.min(p1.row, p2.row) + const maxRow = Math.max(p1.row, p2.row) + for (let row = minRow + 1; row < maxRow; row++) { + drawAt(midCol, row, ch.vLine) + } + + // 4. Corner at (midCol, p2.row) + // goingUp: │ from BOTTOM, ─ going RIGHT → BOT+RIGHT = ╭ (cornerTL) + // goingDown: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + if (goingUp) { + drawAt(midCol, p2.row, ch.cornerTL) // ╭ + } else { + drawAt(midCol, p2.row, ch.cornerBL) // ╰ + } + + // 5. Flat at p2's row from midCol+1 to p2.col + for (let c = midCol + 1; c <= p2.col; c++) { + drawAt(c, p2.row, ch.hLine) + } + + // Leading flat for first segment (before p1.col) + if (i === 0) { + const leadStart = Math.max(plotLeft, p1.col - Math.floor((p2.col - p1.col) / 4)) + for (let c = leadStart; c < p1.col; c++) { + drawAt(c, p1.row, ch.hLine) + } + } + + // Trailing flat for last segment (after p2.col) + if (i === points.length - 2) { + const trailEnd = Math.min(plotLeft + plotTotalW - 1, p2.col + Math.floor((p2.col - p1.col) / 4)) + for (let c = p2.col + 1; c <= trailEnd; c++) { + drawAt(c, p2.row, ch.hLine) + } + } + } +} + +// ============================================================================ +// Staircase line drawing — horizontal charts +// +// Same staircase approach but with axes swapped: +// data values map to columns (horizontal position) and categories map to +// rows (vertical position). Flat segments are vertical (│), transitions +// are horizontal (─), with the same rounded corners. +// ============================================================================ + +function drawHorizontalStaircaseLine( + canvas: Canvas, + roles: RoleCanvas, + data: number[], + bandMid: (i: number) => number, + valueToCol: (v: number) => number, + plotTop: number, + plotH: number, + plotLeft: number, + plotW: number, + ch: typeof UNI | typeof ASC, + hexCanvas?: HexCanvas, + hexColor?: string | null, +): void { + if (data.length === 0) return + + const points = data.map((v, i) => ({ + row: bandMid(i), + col: valueToCol(v), + })) + + const drawAt = (row: number, col: number, char: string) => { + if (row >= plotTop && row < plotTop + plotH && col >= plotLeft && col < plotLeft + plotW) { + set(canvas, roles, row, col, char, 'arrow', hexCanvas, hexColor) + } + } + + if (points.length === 1) { + drawAt(points[0]!.row, points[0]!.col, ch.vLine) + return + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + if (p1.col === p2.col) { + // Same value: draw │ down + for (let r = p1.row; r <= p2.row; r++) { + drawAt(r, p1.col, ch.vLine) + } + continue + } + + const midRow = Math.round((p1.row + p2.row) / 2) + const goingRight = p2.col > p1.col + + // 1. Vertical at p1's col from p1.row to midRow-1 + for (let r = p1.row; r < midRow; r++) { + drawAt(r, p1.col, ch.vLine) + } + + // 2. Corner at (midRow, p1.col) + // goingRight: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL) + // goingLeft: │ from TOP, ─ going LEFT → TOP+LEFT = ╯ (cornerBR) + if (goingRight) { + drawAt(midRow, p1.col, ch.cornerBL) // ╰ + } else { + drawAt(midRow, p1.col, ch.cornerBR) // ╯ + } + + // 3. Horizontal from p1.col to p2.col (exclusive) + const minCol = Math.min(p1.col, p2.col) + const maxCol = Math.max(p1.col, p2.col) + for (let c = minCol + 1; c < maxCol; c++) { + drawAt(midRow, c, ch.hLine) + } + + // 4. Corner at (midRow, p2.col) + // goingRight: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR) + // goingLeft: ─ from RIGHT, │ going DOWN → RIGHT+BOT = ╭ (cornerTL) + if (goingRight) { + drawAt(midRow, p2.col, ch.cornerTR) // ╮ + } else { + drawAt(midRow, p2.col, ch.cornerTL) // ╭ + } + + // 5. Vertical at p2's col from midRow+1 to p2.row + for (let r = midRow + 1; r <= p2.row; r++) { + drawAt(r, p2.col, ch.vLine) + } + } +} + +// ============================================================================ +// Legend — shows series symbols with per-series colors +// ============================================================================ + +function drawLegend( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + chart: XYChart, + row: number, + totalW: number, + ch: typeof UNI | typeof ASC, + seriesColors: string[], +): void { + // Build legend items with global series indices + type LegendItem = { symbol: string; label: string; globalIdx: number } + const items: LegendItem[] = [] + let barIdx = 0, lineIdx = 0 + for (let si = 0; si < chart.series.length; si++) { + const s = chart.series[si]! + if (s.type === 'bar') { + items.push({ symbol: ch.bar, label: `Bar ${barIdx + 1}`, globalIdx: si }) + barIdx++ + } else { + items.push({ symbol: ch.hLine, label: `Line ${lineIdx + 1}`, globalIdx: si }) + lineIdx++ + } + } + + // Calculate total legend width: "symbol space label symbol space label ..." + let totalLen = 0 + for (let i = 0; i < items.length; i++) { + if (i > 0) totalLen += 2 // gap between items + totalLen += 1 + 1 + items[i]!.label.length // symbol + space + label + } + + const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2)) + let col = startCol + + for (let i = 0; i < items.length; i++) { + if (i > 0) col += 2 // gap + const item = items[i]! + // Symbol with series-specific color + set(canvas, roles, row, col, item.symbol, 'arrow', hexCanvas, seriesColors[item.globalIdx]) + col += 1 + // Space (already ' ' from canvas init) + col += 1 + // Label text + writeText(canvas, roles, row, col, item.label, 'text') + col += item.label.length + } +} + +// ============================================================================ +// Canvas utilities +// ============================================================================ + +function createCanvas(width: number, height: number): Canvas { + return Array.from({ length: width }, () => Array.from({ length: height }, () => ' ')) +} + +function createRoleCanvas(width: number, height: number): RoleCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function createHexCanvas(width: number, height: number): HexCanvas { + return Array.from({ length: width }, () => Array.from({ length: height }).fill(null)) +} + +function set( + canvas: Canvas, roles: RoleCanvas, row: number, col: number, + char: string, role: CharRole, + hexCanvas?: HexCanvas, hex?: string | null, +): void { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + canvas[col]![row] = char + roles[col]![row] = role + if (hexCanvas && hex) hexCanvas[col]![row] = hex + } +} + +function get(canvas: Canvas, row: number, col: number): string { + if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) { + return canvas[col]![row]! + } + return ' ' +} + +function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void { + for (let i = 0; i < text.length; i++) { + set(canvas, roles, row, startCol + i, text[i]!, role) + } +} + +// ============================================================================ +// Canvas → string (with per-cell hex color support) +// ============================================================================ + +function canvasToString( + canvas: Canvas, + roles: RoleCanvas, + hexCanvas: HexCanvas, + colorMode: ColorMode, + theme: AsciiTheme, +): string { + if (canvas.length === 0) return '' + const height = canvas[0]!.length + const width = canvas.length + const lines: string[] = [] + + for (let row = 0; row < height; row++) { + const chars: string[] = [] + const rowRoles: (CharRole | null)[] = [] + const rowHex: (string | null)[] = [] + for (let col = 0; col < width; col++) { + chars.push(canvas[col]![row]!) + rowRoles.push(roles[col]![row]!) + rowHex.push(hexCanvas[col]![row]!) + } + // Trim trailing spaces + let end = chars.length - 1 + while (end >= 0 && chars[end] === ' ') end-- + if (end < 0) { + lines.push('') + } else { + lines.push(colorizeRow( + chars.slice(0, end + 1), + rowRoles.slice(0, end + 1), + rowHex.slice(0, end + 1), + theme, + colorMode, + )) + } + } + + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop() + } + + return lines.join('\n') +} + +/** + * Colorize a row of characters, using hex color overrides where available + * and falling back to role-based theme colors otherwise. + * Groups consecutive same-color characters for efficient escape sequences. + */ +function colorizeRow( + chars: string[], + roles: (CharRole | null)[], + hexOverrides: (string | null)[], + theme: AsciiTheme, + mode: ColorMode, +): string { + if (mode === 'none') return chars.join('') + + let result = '' + let currentColor: string | null = null + let buffer = '' + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + + if (char === ' ') { + // Flush buffer before whitespace + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + buffer = '' + currentColor = null + } + result += ' ' + continue + } + + // Effective color: hex override > role-based > null + const hexOvr = hexOverrides[i] ?? null + const roleVal = roles[i] ?? null + const color = hexOvr ?? (roleVal ? roleToHex(roleVal, theme) : null) + + if (color === currentColor) { + buffer += char + } else { + // Flush previous group + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + buffer = char + currentColor = color + } + } + + // Flush remaining + if (buffer.length > 0) { + result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer + } + + return result +} + +// ============================================================================ +// Helpers (chart-level) +// ============================================================================ + +function getDataCount(chart: XYChart): number { + if (chart.xAxis.categories) return chart.xAxis.categories.length + for (const s of chart.series) { + if (s.data.length > 0) return s.data.length + } + return 0 +} + +function getCategoryLabels(chart: XYChart, count: number): string[] { + if (chart.xAxis.categories) return chart.xAxis.categories + if (chart.xAxis.range) { + const { min, max } = chart.xAxis.range + const step = count > 1 ? (max - min) / (count - 1) : 0 + return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i)) + } + return Array.from({ length: count }, (_, i) => String(i + 1)) +} + +/** Generate nice tick values for a numeric range. */ +function niceTickValues(min: number, max: number): number[] { + const range = max - min + if (range <= 0) return [min] + + const rawInterval = range / 6 + const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval))) + const residual = rawInterval / magnitude + let niceInterval: number + if (residual <= 1.5) niceInterval = magnitude + else if (residual <= 3) niceInterval = 2 * magnitude + else if (residual <= 7) niceInterval = 5 * magnitude + else niceInterval = 10 * magnitude + + const start = Math.ceil(min / niceInterval) * niceInterval + const ticks: number[] = [] + for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) { + ticks.push(Math.round(v * 1e10) / 1e10) + } + return ticks +} + +function formatTickValue(v: number): string { + if (Number.isInteger(v)) return String(v) + return v.toFixed(Math.abs(v) < 10 ? 1 : 0) +} diff --git a/ui/vendor/beautiful-mermaid/class/layout.ts b/ui/vendor/beautiful-mermaid/class/layout.ts new file mode 100644 index 0000000..5930a4b --- /dev/null +++ b/ui/vendor/beautiful-mermaid/class/layout.ts @@ -0,0 +1,211 @@ +/** + * Class diagram layout engine (ELK.js). + * + * Each class box has 3 compartments: + * 1. Header (class name + optional annotation) + * 2. Attributes section + * 3. Methods section + */ + +import type { ElkNode, ElkExtendedEdge } from 'elkjs' +import type { ClassDiagram, ClassNode, ClassMember, PositionedClassDiagram, PositionedClassNode, PositionedClassRelationship } from './types.ts' +import type { RenderOptions, Point } from '../types.ts' +import { estimateTextWidth, estimateMonoTextWidth, FONT_SIZES, FONT_WEIGHTS } from '../styles.ts' +import { measureMultilineText } from '../text-metrics.ts' +import { elkLayoutSync } from '../elk-instance.ts' + +/** Layout constants for class diagrams */ +export const CLS = { + padding: 40, + boxPadX: 8, + headerBaseHeight: 32, + annotationHeight: 16, + memberRowHeight: 20, + sectionPadY: 8, + emptySectionHeight: 8, + minWidth: 120, + memberFontSize: 11, + memberFontWeight: 400, + nodeSpacing: 40, + layerSpacing: 60, +} as const + +type ClassSizeMap = Map + +/** Build ELK graph and size map from a class diagram. */ +function buildClassElkGraph( + diagram: ClassDiagram, + _options: RenderOptions +): { elkGraph: ElkNode; classSizes: ClassSizeMap } { + const classSizes: ClassSizeMap = new Map() + + for (const cls of diagram.classes) { + const headerHeight = cls.annotation + ? CLS.headerBaseHeight + CLS.annotationHeight + : CLS.headerBaseHeight + + const attrHeight = cls.attributes.length > 0 + ? cls.attributes.length * CLS.memberRowHeight + CLS.sectionPadY + : CLS.emptySectionHeight + + const methodHeight = cls.methods.length > 0 + ? cls.methods.length * CLS.memberRowHeight + CLS.sectionPadY + : CLS.emptySectionHeight + + const headerTextW = estimateTextWidth(cls.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel) + const maxAttrW = maxMemberWidth(cls.attributes) + const maxMethodW = maxMemberWidth(cls.methods) + const width = Math.max(CLS.minWidth, headerTextW + CLS.boxPadX * 2, maxAttrW + CLS.boxPadX * 2, maxMethodW + CLS.boxPadX * 2) + const height = headerHeight + attrHeight + methodHeight + + classSizes.set(cls.id, { width, height, headerHeight, attrHeight, methodHeight }) + } + + const elkGraph: ElkNode = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': String(CLS.nodeSpacing), + 'elk.layered.spacing.nodeNodeBetweenLayers': String(CLS.layerSpacing), + 'elk.padding': `[top=${CLS.padding},left=${CLS.padding},bottom=${CLS.padding},right=${CLS.padding}]`, + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.edgeLabels.placement': 'CENTER', + }, + children: [], + edges: [], + } + + for (const cls of diagram.classes) { + const size = classSizes.get(cls.id)! + elkGraph.children!.push({ id: cls.id, width: size.width, height: size.height }) + } + + for (let i = 0; i < diagram.relationships.length; i++) { + const rel = diagram.relationships[i]! + const edge: ElkExtendedEdge = { id: `e${i}`, sources: [rel.from], targets: [rel.to] } + if (rel.label) { + const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + edge.labels = [{ text: rel.label, width: metrics.width + 8, height: metrics.height + 6 }] + } + elkGraph.edges!.push(edge) + } + + return { elkGraph, classSizes } +} + +/** Extract positioned classes and relationships from ELK result. */ +function extractClassLayout( + result: ElkNode, + diagram: ClassDiagram, + classSizes: ClassSizeMap +): PositionedClassDiagram { + const classLookup = new Map() + for (const cls of diagram.classes) classLookup.set(cls.id, cls) + + const positionedClasses: PositionedClassNode[] = [] + for (const child of result.children ?? []) { + const cls = classLookup.get(child.id) + if (cls) { + const size = classSizes.get(cls.id)! + positionedClasses.push({ + id: cls.id, + label: cls.label, + annotation: cls.annotation, + attributes: cls.attributes, + methods: cls.methods, + x: child.x ?? 0, + y: child.y ?? 0, + width: child.width ?? size.width, + height: child.height ?? size.height, + headerHeight: size.headerHeight, + attrHeight: size.attrHeight, + methodHeight: size.methodHeight, + }) + } + } + + const relationships: PositionedClassRelationship[] = [] + for (let i = 0; i < (result.edges?.length ?? 0); i++) { + const elkEdge = result.edges![i]! + const rel = diagram.relationships[i]! + + const points: Point[] = [] + if (elkEdge.sections && elkEdge.sections.length > 0) { + const section = elkEdge.sections[0]! + points.push({ x: section.startPoint.x, y: section.startPoint.y }) + if (section.bendPoints) { + for (const bp of section.bendPoints) { + points.push({ x: bp.x, y: bp.y }) + } + } + points.push({ x: section.endPoint.x, y: section.endPoint.y }) + } + + let labelPosition: Point | undefined + if (elkEdge.labels && elkEdge.labels.length > 0) { + const label = elkEdge.labels[0]! + if (label.x != null && label.y != null) { + labelPosition = { + x: label.x + (label.width ?? 0) / 2, + y: label.y + (label.height ?? 0) / 2, + } + } + } + + relationships.push({ + from: rel.from, + to: rel.to, + type: rel.type, + markerAt: rel.markerAt, + label: rel.label, + fromCardinality: rel.fromCardinality, + toCardinality: rel.toCardinality, + points, + labelPosition, + }) + } + + return { + width: result.width ?? 600, + height: result.height ?? 400, + classes: positionedClasses, + relationships, + } +} + +/** + * Lay out a parsed class diagram using ELK.js (synchronous). + */ +export function layoutClassDiagramSync( + diagram: ClassDiagram, + options: RenderOptions = {} +): PositionedClassDiagram { + if (diagram.classes.length === 0) { + return { width: 0, height: 0, classes: [], relationships: [] } + } + + const { elkGraph, classSizes } = buildClassElkGraph(diagram, options) + const result = elkLayoutSync(elkGraph) + return extractClassLayout(result, diagram, classSizes) +} + +/** Calculate the max width of a list of class members (uses mono metrics) */ +function maxMemberWidth(members: ClassMember[]): number { + if (members.length === 0) return 0 + let maxW = 0 + for (const m of members) { + const text = memberToString(m) + const w = estimateMonoTextWidth(text, CLS.memberFontSize) + if (w > maxW) maxW = w + } + return maxW +} + +/** Convert a class member to its display string */ +export function memberToString(m: ClassMember): string { + const vis = m.visibility ? `${m.visibility} ` : '' + const name = m.isMethod ? `${m.name}(${m.params || ''})` : m.name + const type = m.type ? `: ${m.type}` : '' + return `${vis}${name}${type}` +} diff --git a/ui/vendor/beautiful-mermaid/class/parser.ts b/ui/vendor/beautiful-mermaid/class/parser.ts new file mode 100644 index 0000000..527da53 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/class/parser.ts @@ -0,0 +1,290 @@ +import type { ClassDiagram, ClassNode, ClassRelationship, ClassMember, RelationshipType, ClassNamespace } from './types.ts' +import { normalizeBrTags } from '../multiline-utils.ts' + +// ============================================================================ +// Class diagram parser +// +// Parses Mermaid classDiagram syntax into a ClassDiagram structure. +// +// Supported syntax: +// class Animal { +String name; +eat() void } +// class Shape { <> } +// Animal <|-- Dog (inheritance) +// Car *-- Engine (composition) +// Car o-- Wheel (aggregation) +// A --> B (association) +// A ..> B (dependency) +// A ..|> B (realization) +// A "1" --> "*" B : label (with cardinality + label) +// Animal : +String name (inline attribute) +// namespace MyNamespace { class A { } } +// ============================================================================ + +/** + * Parse a Mermaid class diagram. + * Expects the first line to be "classDiagram". + */ +export function parseClassDiagram(lines: string[]): ClassDiagram { + const diagram: ClassDiagram = { + classes: [], + relationships: [], + namespaces: [], + } + + // Track classes by ID for deduplication + const classMap = new Map() + // Track namespace nesting + let currentNamespace: ClassNamespace | null = null + // Track class body parsing + let currentClass: ClassNode | null = null + let braceDepth = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside a class body block --- + if (currentClass && braceDepth > 0) { + if (line === '}') { + braceDepth-- + if (braceDepth === 0) { + currentClass = null + } + continue + } + + // Check for annotation like <> + const annotMatch = line.match(/^<<(\w+)>>$/) + if (annotMatch) { + currentClass.annotation = annotMatch[1]! + continue + } + + // Parse member: visibility, name, type, optional parens for method + const member = parseMember(line) + if (member) { + if (member.isMethod) { + currentClass.methods.push(member.member) + } else { + currentClass.attributes.push(member.member) + } + } + continue + } + + // --- Namespace block start --- + const nsMatch = line.match(/^namespace\s+(\S+)\s*\{$/) + if (nsMatch) { + currentNamespace = { name: nsMatch[1]!, classIds: [] } + continue + } + + // --- Namespace end --- + if (line === '}' && currentNamespace) { + diagram.namespaces.push(currentNamespace) + currentNamespace = null + continue + } + + // --- Class block start: `class ClassName {` or `class ClassName` --- + const classBlockMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$/) + if (classBlockMatch) { + const id = classBlockMatch[1]! + const generic = classBlockMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + currentClass = cls + braceDepth = 1 + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Standalone class declaration (no body): `class ClassName` --- + const classOnlyMatch = line.match(/^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$/) + if (classOnlyMatch) { + const id = classOnlyMatch[1]! + const generic = classOnlyMatch[2] + const cls = ensureClass(classMap, id) + if (generic) { + cls.label = `${id}<${generic}>` + } + if (currentNamespace) { + currentNamespace.classIds.push(id) + } + continue + } + + // --- Inline annotation: `class ClassName { <> }` (single line) --- + const inlineAnnotMatch = line.match(/^class\s+(\S+?)\s*\{\s*<<(\w+)>>\s*\}$/) + if (inlineAnnotMatch) { + const cls = ensureClass(classMap, inlineAnnotMatch[1]!) + cls.annotation = inlineAnnotMatch[2]! + continue + } + + // --- Inline attribute: `ClassName : +String name` --- + const inlineAttrMatch = line.match(/^(\S+?)\s*:\s*(.+)$/) + if (inlineAttrMatch) { + // Make sure this isn't a relationship line (those have arrows) + const rest = inlineAttrMatch[2]! + if (!rest.match(/<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>/)) { + const cls = ensureClass(classMap, inlineAttrMatch[1]!) + const member = parseMember(rest) + if (member) { + if (member.isMethod) { + cls.methods.push(member.member) + } else { + cls.attributes.push(member.member) + } + } + continue + } + } + + // --- Relationship --- + // Pattern: [FROM] ["card"] ARROW ["card"] [TO] [: label] + // Arrows: <|--, *--, o--, -->, ..|>, ..> + // Can also be reversed: --o, --*, --|> + const rel = parseRelationship(line) + if (rel) { + // Ensure both classes exist + ensureClass(classMap, rel.from) + ensureClass(classMap, rel.to) + diagram.relationships.push(rel) + continue + } + } + + diagram.classes = [...classMap.values()] + return diagram +} + +/** Ensure a class exists in the map, creating a default if needed */ +function ensureClass(classMap: Map, id: string): ClassNode { + let cls = classMap.get(id) + if (!cls) { + cls = { id, label: id, attributes: [], methods: [] } + classMap.set(id, cls) + } + return cls +} + +/** Parse a class member line (attribute or method) */ +function parseMember(line: string): { member: ClassMember; isMethod: boolean } | null { + const trimmed = line.trim().replace(/;$/, '') + if (!trimmed) return null + + // Extract visibility prefix + let visibility: ClassMember['visibility'] = '' + let rest = trimmed + if (/^[+\-#~]/.test(rest)) { + visibility = rest[0] as ClassMember['visibility'] + rest = rest.slice(1).trim() + } + + // Check if it's a method (has parentheses) + const methodMatch = rest.match(/^(.+?)\(([^)]*)\)(?:\s*(.+))?$/) + if (methodMatch) { + const name = methodMatch[1]!.trim() + const params = methodMatch[2]?.trim() || undefined // Store the parameter string + const type = methodMatch[3]?.trim() + // Check for static ($) or abstract (*) markers + const isStatic = name.endsWith('$') || rest.includes('$') + const isAbstract = name.endsWith('*') || rest.includes('*') + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: true, + params, + }, + isMethod: true, + } + } + + // It's an attribute: [Type] name or name Type + // Common patterns: "String name", "+int age", "name" + const parts = rest.split(/\s+/) + let name: string + let type: string | undefined + + if (parts.length >= 2) { + // "Type name" pattern + type = parts[0] + name = parts.slice(1).join(' ') + } else { + name = parts[0] ?? rest + } + + const isStatic = name.endsWith('$') + const isAbstract = name.endsWith('*') + + return { + member: { + visibility, + name: name.replace(/[$*]$/, ''), + type: type || undefined, + isStatic, + isAbstract, + isMethod: false, + }, + isMethod: false, + } +} + +/** Parse a relationship line into a ClassRelationship */ +function parseRelationship(line: string): ClassRelationship | null { + // Relationship regex — handles all arrow types with optional cardinality and labels + // Pattern: FROM ["card"] ARROW ["card"] TO [: label] + const match = line.match( + /^(\S+?)\s+(?:"([^"]*?)"\s+)?(<\|--|<\|\.\.|\*--|o--|-->|--\*|--o|--\|>|\.\.>|\.\.\|>|<--|<\.\.?|--)\s+(?:"([^"]*?)"\s+)?(\S+?)(?:\s*:\s*(.+))?$/ + ) + if (!match) return null + + const from = match[1]! + const rawFromCardinality = match[2] + const fromCardinality = rawFromCardinality ? normalizeBrTags(rawFromCardinality) : undefined + const arrow = match[3]!.trim() + const rawToCardinality = match[4] + const toCardinality = rawToCardinality ? normalizeBrTags(rawToCardinality) : undefined + const to = match[5]! + const rawLabel = match[6]?.trim() + const label = rawLabel ? normalizeBrTags(rawLabel) : undefined + + const parsed = parseArrow(arrow) + if (!parsed) return null + + return { from, to, type: parsed.type, markerAt: parsed.markerAt, label, fromCardinality, toCardinality } +} + +/** + * Map arrow syntax to relationship type and marker placement side. + * Prefix markers (`<|--`, `*--`, `o--`) place the UML shape at the 'from' end. + * Suffix markers (`..|>`, `-->`, `..>`, `--*`, `--o`) place it at the 'to' end. + */ +function parseArrow(arrow: string): { type: RelationshipType; markerAt: 'from' | 'to' } | null { + // Trim whitespace that might be captured by the regex + const a = arrow.trim() + switch (a) { + case '<|--': return { type: 'inheritance', markerAt: 'from' } + case '--|>': return { type: 'inheritance', markerAt: 'to' } + case '<|..': return { type: 'realization', markerAt: 'from' } + case '..|>': return { type: 'realization', markerAt: 'to' } + case '*--': return { type: 'composition', markerAt: 'from' } + case '--*': return { type: 'composition', markerAt: 'to' } + case 'o--': return { type: 'aggregation', markerAt: 'from' } + case '--o': return { type: 'aggregation', markerAt: 'to' } + case '-->': return { type: 'association', markerAt: 'to' } + case '<--': return { type: 'association', markerAt: 'from' } + case '..>': return { type: 'dependency', markerAt: 'to' } + case '<..': return { type: 'dependency', markerAt: 'from' } + case '--': return { type: 'association', markerAt: 'to' } + default: return null + } +} diff --git a/ui/vendor/beautiful-mermaid/class/renderer.ts b/ui/vendor/beautiful-mermaid/class/renderer.ts new file mode 100644 index 0000000..a5c9d32 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/class/renderer.ts @@ -0,0 +1,397 @@ +import type { PositionedClassDiagram, PositionedClassNode, PositionedClassRelationship, ClassMember, RelationshipType } from './types.ts' +import type { DiagramColors } from '../theme.ts' +import { svgOpenTag, buildStyleBlock } from '../theme.ts' +import { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, estimateTextWidth, TEXT_BASELINE_SHIFT } from '../styles.ts' +import { CLS } from './layout.ts' +import { renderMultilineText, escapeXml as escapeXmlUtil } from '../multiline-utils.ts' + +// ============================================================================ +// Class diagram SVG renderer +// +// Renders positioned class diagrams to SVG. +// All colors use CSS custom properties (var(--_xxx)) from the theme system. +// +// Render order: +// 1. Relationship lines (behind boxes) +// 2. Class boxes (header + attributes + methods compartments) +// 3. Relationship endpoint markers (diamonds, triangles) +// 4. Labels and cardinality +// ============================================================================ + +/** Font sizes specific to class diagrams */ +const CLS_FONT = { + memberSize: 11, + memberWeight: 400, + annotationSize: 10, + annotationWeight: 500, +} as const + +/** + * Render a positioned class diagram as an SVG string. + * + * @param colors - DiagramColors with bg/fg and optional enrichment variables. + * @param transparent - If true, renders with transparent background. + */ +export function renderClassSvg( + diagram: PositionedClassDiagram, + colors: DiagramColors, + font: string = 'Inter', + transparent: boolean = false +): string { + const parts: string[] = [] + + // SVG root with CSS variables + style block (with mono font) + defs + parts.push(svgOpenTag(diagram.width, diagram.height, colors, transparent)) + parts.push(buildStyleBlock(font, true)) + parts.push('') + parts.push(relationshipMarkerDefs()) + parts.push('') + + // 1. Relationship lines (rendered behind boxes) + for (const rel of diagram.relationships) { + parts.push(renderRelationship(rel)) + } + + // 2. Class boxes + for (const cls of diagram.classes) { + parts.push(renderClassBox(cls)) + } + + // 3. Relationship labels and cardinality + for (const rel of diagram.relationships) { + parts.push(renderRelationshipLabels(rel)) + } + + parts.push('') + return parts.join('\n') +} + +// ============================================================================ +// Marker definitions +// ============================================================================ + +/** + * Marker definitions for class relationship endpoints. + * Each relationship type has a distinct marker: + * - inheritance: hollow triangle + * - composition: filled diamond + * - aggregation: hollow diamond + * - association: open arrow (simple >) + * - dependency: open arrow (simple >) + * - realization: hollow triangle (same as inheritance) + * + * Uses var(--_arrow) for fill/stroke and var(--bg) for hollow marker fills. + */ +function relationshipMarkerDefs(): string { + return ( + // Hollow triangle (inheritance, realization) — points at target + ` ` + + `\n ` + + `\n ` + + // Filled diamond (composition) — points at source + `\n ` + + `\n ` + + `\n ` + + // Hollow diamond (aggregation) — points at source + `\n ` + + `\n ` + + `\n ` + + // Open arrow (association, dependency) + `\n ` + + `\n ` + + `\n ` + ) +} + +// ============================================================================ +// Class box rendering +// ============================================================================ + +/** + * Render a class box with 3 compartments: header, attributes, methods. + * Wrapped in with semantic data attributes. + */ +function renderClassBox(cls: PositionedClassNode): string { + const { x, y, width, height, headerHeight, attrHeight, methodHeight } = cls + const parts: string[] = [] + + // Semantic wrapper with class metadata + // data-id: class identifier + // data-label: class name + // data-annotation: stereotype (interface, abstract, etc.) + const annotationAttr = cls.annotation ? ` data-annotation="${escapeAttr(cls.annotation)}"` : '' + parts.push( + `` + ) + + // Outer rectangle (full box) + parts.push( + ` ` + ) + + // Header background + parts.push( + ` ` + ) + + // Annotation (<>, <>, etc.) + let nameY = y + headerHeight / 2 + if (cls.annotation) { + const annotY = y + 12 + parts.push( + ` <<${escapeXml(cls.annotation)}>>` + ) + nameY = y + headerHeight / 2 + 6 + } + + // Class name (supports multi-line via
tags) + parts.push( + ' ' + renderMultilineText( + cls.label, + x + width / 2, + nameY, + FONT_SIZES.nodeLabel, + `text-anchor="middle" font-size="${FONT_SIZES.nodeLabel}" font-weight="700" fill="var(--_text)"` + ) + ) + + // Divider line between header and attributes + const attrTop = y + headerHeight + parts.push( + ` ` + ) + + // Attributes + const memberRowH = 20 + for (let i = 0; i < cls.attributes.length; i++) { + const member = cls.attributes[i]! + const memberY = attrTop + 4 + i * memberRowH + memberRowH / 2 + parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY)) + } + + // Divider line between attributes and methods + const methodTop = attrTop + attrHeight + parts.push( + ` ` + ) + + // Methods + for (let i = 0; i < cls.methods.length; i++) { + const member = cls.methods[i]! + const memberY = methodTop + 4 + i * memberRowH + memberRowH / 2 + parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY)) + } + + parts.push('
') + + return parts.join('\n') +} + +/** + * Render a single class member with syntax highlighting. + * Uses elements to color each part of the member differently: + * - visibility symbol (+/-/#/~) → textFaint + * - member name (incl. parens for methods) → textSecondary + * - colon separator → textFaint + * - type annotation → textMuted + */ +function renderMember(member: ClassMember, x: number, y: number): string { + const fontStyle = member.isAbstract ? ' font-style="italic"' : '' + const decoration = member.isStatic ? ' text-decoration="underline"' : '' + + // Build tspan parts for syntax-highlighted member text + const spans: string[] = [] + + if (member.visibility) { + spans.push(`${escapeXml(member.visibility)} `) + } + + // Add parentheses for methods to distinguish from attributes, including parameters if present + const displayName = member.isMethod + ? `${member.name}(${member.params || ''})` + : member.name + spans.push(`${escapeXml(displayName)}`) + + if (member.type) { + spans.push(`: `) + spans.push(`${escapeXml(member.type)}`) + } + + return ( + `` + + `${spans.join('')}` + ) +} + +// ============================================================================ +// Relationship rendering +// ============================================================================ + +/** + * Render a relationship line with appropriate markers and semantic attributes. + * Includes data-* attributes for programmatic inspection. + */ +function renderRelationship(rel: PositionedClassRelationship): string { + if (rel.points.length < 2) return '' + + const pathData = rel.points.map(p => `${p.x},${p.y}`).join(' ') + const isDashed = rel.type === 'dependency' || rel.type === 'realization' + const dashArray = isDashed ? ' stroke-dasharray="6 4"' : '' + + // Determine markers based on relationship type and which end has the marker + const markers = getRelationshipMarkers(rel.type, rel.markerAt) + + // Build semantic data attributes for relationship inspection: + // - class="class-relationship": CSS targeting + // - data-from/data-to: source and target class IDs + // - data-type: relationship type (inheritance, composition, etc.) + // - data-marker-at: which end has the marker (from/to) + // - data-from-cardinality/data-to-cardinality: multiplicity if present + // - data-label: relationship label if present + const dataAttrs = [ + 'class="class-relationship"', + `data-from="${escapeAttr(rel.from)}"`, + `data-to="${escapeAttr(rel.to)}"`, + `data-type="${rel.type}"`, + `data-marker-at="${rel.markerAt}"`, + ] + if (rel.label) { + dataAttrs.push(`data-label="${escapeAttr(rel.label)}"`) + } + if (rel.fromCardinality) { + dataAttrs.push(`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`) + } + if (rel.toCardinality) { + dataAttrs.push(`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`) + } + + return ( + `` + ) +} + +/** + * Get marker-start/marker-end attributes for a relationship type. + * Uses `markerAt` from the parser to place the marker on the correct end: + * - 'from' → marker-start (prefix arrows like `<|--`, `*--`, `o--`) + * - 'to' → marker-end (suffix arrows like `..|>`, `-->`, `--*`) + */ +function getRelationshipMarkers(type: RelationshipType, markerAt: 'from' | 'to'): string { + const markerId = getMarkerDefId(type) + if (!markerId) return '' + + if (markerAt === 'from') { + return ` marker-start="url(#${markerId})"` + } else { + return ` marker-end="url(#${markerId})"` + } +} + +/** Map relationship type to its SVG marker definition ID */ +function getMarkerDefId(type: RelationshipType): string | null { + switch (type) { + case 'inheritance': + case 'realization': + return 'cls-inherit' + case 'composition': + return 'cls-composition' + case 'aggregation': + return 'cls-aggregation' + case 'association': + case 'dependency': + return 'cls-arrow' + default: + return null + } +} + +/** Render relationship labels and cardinality text (supports multi-line) */ +function renderRelationshipLabels(rel: PositionedClassRelationship): string { + if (!rel.label && !rel.fromCardinality && !rel.toCardinality) return '' + if (rel.points.length < 2) return '' + + const parts: string[] = [] + + // Label — prefer layout-computed position (collision-aware), fall back to midpoint + if (rel.label) { + const pos = rel.labelPosition ?? midpoint(rel.points) + parts.push( + renderMultilineText(rel.label, pos.x, pos.y - 8, FONT_SIZES.edgeLabel, + `font-size="${FONT_SIZES.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`) + ) + } + + // From cardinality (near start) + if (rel.fromCardinality) { + const p = rel.points[0]! + const next = rel.points[1]! + const offset = cardinalityOffset(p, next) + parts.push( + renderMultilineText(rel.fromCardinality, p.x + offset.x, p.y + offset.y, FONT_SIZES.edgeLabel, + `font-size="${FONT_SIZES.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`) + ) + } + + // To cardinality (near end) + if (rel.toCardinality) { + const p = rel.points[rel.points.length - 1]! + const prev = rel.points[rel.points.length - 2]! + const offset = cardinalityOffset(p, prev) + parts.push( + renderMultilineText(rel.toCardinality, p.x + offset.x, p.y + offset.y, FONT_SIZES.edgeLabel, + `font-size="${FONT_SIZES.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`) + ) + } + + return parts.join('\n') +} + +/** Get the midpoint of a point array */ +function midpoint(points: Array<{ x: number; y: number }>): { x: number; y: number } { + if (points.length === 0) return { x: 0, y: 0 } + const mid = Math.floor(points.length / 2) + return points[mid]! +} + +/** Calculate offset for cardinality label perpendicular to edge direction */ +function cardinalityOffset( + from: { x: number; y: number }, + to: { x: number; y: number } +): { x: number; y: number } { + const dx = to.x - from.x + const dy = to.y - from.y + // Place label perpendicular to the edge, 14px away + if (Math.abs(dx) > Math.abs(dy)) { + // Mostly horizontal — offset vertically + return { x: dx > 0 ? 14 : -14, y: -10 } + } + // Mostly vertical — offset horizontally + return { x: -14, y: dy > 0 ? 14 : -14 } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +// Use shared escapeXml from multiline-utils +const escapeXml = escapeXmlUtil + +/** + * Escape a string for use as an XML/HTML attribute value. + * Escapes quotes and ampersands to prevent attribute injection. + */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} diff --git a/ui/vendor/beautiful-mermaid/class/types.ts b/ui/vendor/beautiful-mermaid/class/types.ts new file mode 100644 index 0000000..b4f96b1 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/class/types.ts @@ -0,0 +1,121 @@ +// ============================================================================ +// Class diagram types +// +// Models the parsed and positioned representations of a Mermaid class diagram. +// Class diagrams show UML class relationships, inheritance, composition, etc. +// ============================================================================ + +/** Parsed class diagram — logical structure from mermaid text */ +export interface ClassDiagram { + /** All class definitions */ + classes: ClassNode[] + /** Relationships between classes */ + relationships: ClassRelationship[] + /** Optional namespace groupings */ + namespaces: ClassNamespace[] +} + +export interface ClassNode { + id: string + label: string + /** Annotation like <>, <>, <>, <> */ + annotation?: string + /** Class attributes (fields/properties) */ + attributes: ClassMember[] + /** Class methods (functions) */ + methods: ClassMember[] +} + +export interface ClassMember { + /** Visibility: + public, - private, # protected, ~ package */ + visibility: '+' | '-' | '#' | '~' | '' + /** Member name */ + name: string + /** Type annotation (e.g., "String", "int", "void") */ + type?: string + /** Whether the member is static (underlined in UML) */ + isStatic?: boolean + /** Whether the member is abstract (italic in UML) */ + isAbstract?: boolean + /** Whether the member is a method (renders with parentheses) */ + isMethod?: boolean + /** Method parameters (e.g., "data", "key, val") — only for methods */ + params?: string +} + +/** Relationship types following UML conventions */ +export type RelationshipType = + | 'inheritance' // A <|-- B (solid line, hollow triangle) + | 'composition' // A *-- B (solid line, filled diamond) + | 'aggregation' // A o-- B (solid line, hollow diamond) + | 'association' // A --> B (solid line, open arrow) + | 'dependency' // A ..> B (dashed line, open arrow) + | 'realization' // A ..|> B (dashed line, hollow triangle) + +export interface ClassRelationship { + from: string + to: string + type: RelationshipType + /** + * Which end of the relationship line has the UML marker (triangle, diamond, arrow). + * Determined by the arrow syntax direction: + * - Prefix markers like `<|--`, `*--`, `o--` → 'from' (marker on left/from side) + * - Suffix markers like `..|>`, `-->`, `..>`, `--*`, `--o` → 'to' (marker on right/to side) + */ + markerAt: 'from' | 'to' + /** Label on the relationship line */ + label?: string + /** Cardinality at the "from" end (e.g., "1", "*", "0..1") */ + fromCardinality?: string + /** Cardinality at the "to" end */ + toCardinality?: string +} + +export interface ClassNamespace { + name: string + classIds: string[] +} + +// ============================================================================ +// Positioned class diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedClassDiagram { + width: number + height: number + classes: PositionedClassNode[] + relationships: PositionedClassRelationship[] +} + +export interface PositionedClassNode { + id: string + label: string + annotation?: string + attributes: ClassMember[] + methods: ClassMember[] + x: number + y: number + width: number + height: number + /** Height of the header section (name + annotation) */ + headerHeight: number + /** Height of the attributes section */ + attrHeight: number + /** Height of the methods section */ + methodHeight: number +} + +export interface PositionedClassRelationship { + from: string + to: string + type: RelationshipType + /** Which end of the line has the UML marker — propagated from ClassRelationship */ + markerAt: 'from' | 'to' + label?: string + fromCardinality?: string + toCardinality?: string + /** Path points from source to target */ + points: Array<{ x: number; y: number }> + /** Dagre-computed label center position (avoids overlaps between nearby edges) */ + labelPosition?: { x: number; y: number } +} diff --git a/ui/vendor/beautiful-mermaid/elk-instance.ts b/ui/vendor/beautiful-mermaid/elk-instance.ts new file mode 100644 index 0000000..6406f3d --- /dev/null +++ b/ui/vendor/beautiful-mermaid/elk-instance.ts @@ -0,0 +1,113 @@ +/** + * Shared ELK instance singleton. + * + * Uses elk.bundled.js (pure synchronous JS, ~1.6 MB) for all environments. + * The singleton is created lazily on first use and cached forever. + * + * ELK's FakeWorker wraps both postMessage and onmessage in setTimeout(0), + * making the normal API fully async. To bypass this: + * 1. During construction, we capture setTimeout(0) callbacks and flush them + * synchronously — this registers the layout algorithms immediately. + * 2. For layout calls, we call dispatcher.saveDispatch() directly (skipping + * the FakeWorker's postMessage setTimeout) and intercept the result via + * rawWorker.onmessage (which the dispatcher calls synchronously). + */ + +import type { ElkNode } from 'elkjs' +// @ts-ignore — static import of bundled ELK +import ELKBundled from 'elkjs/lib/elk.bundled.js' + +interface RawFakeWorker { + postMessage(msg: unknown): void + onmessage: ((e: { data: Record }) => void) | null + dispatcher: { + saveDispatch(msg: { data: Record }): void + } +} + +let elk: unknown = null +let rawWorker: RawFakeWorker | null = null + +/** + * Ensure the ELK singleton exists. + * + * Patches setTimeout during construction to capture and synchronously flush + * the algorithm registration callback that ELK queues via setTimeout(0). + * Without this, layout calls fail with "algorithm not found" until the + * next macrotask. + */ +function ensureElk(): void { + if (elk) return + + // Capture setTimeout(0) callbacks queued during ELK construction + const pending: (() => void)[] = [] + const origSetTimeout = globalThis.setTimeout + // @ts-ignore — simplified signature for our interception + globalThis.setTimeout = (fn: () => void, delay?: number) => { + if (delay === 0) { pending.push(fn); return 0 } + return origSetTimeout(fn, delay) + } + + // Bun defines `self` (= globalThis) but not `document`, which tricks + // elk-worker.min.js into taking the Web Worker branch instead of the + // CJS branch. Temporarily hide `self` so it exports {Worker: FakeWorker}. + const g = globalThis as Record + const hadSelf = 'self' in g + const origSelf = g.self + if (hadSelf && typeof g.document === 'undefined') { + delete g.self + } + + elk = new ELKBundled() + + // Restore self + if (hadSelf) g.self = origSelf + + // Restore setTimeout immediately + globalThis.setTimeout = origSetTimeout + + // Flush captured callbacks synchronously — registers layout algorithms + pending.forEach(fn => fn()) + + // Cache the raw FakeWorker for elkLayoutSync() + rawWorker = (elk as unknown as { worker: { worker: RawFakeWorker } }).worker.worker +} + +/** + * Run ELK layout synchronously. + * + * Bypasses BOTH of ELK's setTimeout(0) wrappers: + * - FakeWorker.postMessage wraps dispatch in setTimeout(0) — bypassed by + * calling dispatcher.saveDispatch() directly + * - PromisedWorker.onmessage wraps receive in setTimeout(0) — bypassed by + * replacing rawWorker.onmessage with a direct interceptor + */ +export function elkLayoutSync(graph: ElkNode): ElkNode { + ensureElk() + + let result: ElkNode | undefined + let error: unknown + + // Replace onmessage to intercept the result synchronously + // (the dispatcher calls this directly, without setTimeout) + const origOnmessage = rawWorker!.onmessage + rawWorker!.onmessage = (answer: { data: Record }) => { + if (answer.data.error) { + error = answer.data.error + } else { + result = answer.data.data as ElkNode + } + } + + // Call dispatcher.saveDispatch directly — bypasses FakeWorker.postMessage's + // setTimeout(0) wrapper. The dispatcher processes the layout synchronously + // and calls rawWorker.onmessage with the result. + rawWorker!.dispatcher.saveDispatch({ data: { id: 0, cmd: 'layout', graph } as unknown as Record }) + + // Restore original handler + rawWorker!.onmessage = origOnmessage + + if (error) throw error + if (!result) throw new Error('ELK layout did not return synchronously') + return result +} diff --git a/ui/vendor/beautiful-mermaid/er/layout.ts b/ui/vendor/beautiful-mermaid/er/layout.ts new file mode 100644 index 0000000..36d7775 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/er/layout.ts @@ -0,0 +1,161 @@ +/** + * ER diagram layout engine (ELK.js). + * + * Each entity box has: + * 1. Header (entity name) + * 2. Attribute rows (type, name, keys) + */ + +import type { ElkNode, ElkExtendedEdge } from 'elkjs' +import type { ErDiagram, ErEntity, PositionedErDiagram, PositionedErEntity, PositionedErRelationship } from './types.ts' +import type { RenderOptions, Point } from '../types.ts' +import { estimateTextWidth, estimateMonoTextWidth, FONT_SIZES, FONT_WEIGHTS } from '../styles.ts' +import { measureMultilineText } from '../text-metrics.ts' +import { elkLayoutSync } from '../elk-instance.ts' + +/** Layout constants for ER diagrams */ +const ER = { + padding: 40, + boxPadX: 14, + headerHeight: 34, + rowHeight: 22, + minWidth: 140, + attrFontSize: 11, + attrFontWeight: 400, + nodeSpacing: 70, + layerSpacing: 90, +} as const + +type EntitySizeMap = Map + +/** Build ELK graph and size map from an ER diagram. */ +function buildErElkGraph( + diagram: ErDiagram, + _options: RenderOptions +): { elkGraph: ElkNode; entitySizes: EntitySizeMap } { + const entitySizes: EntitySizeMap = new Map() + + for (const entity of diagram.entities) { + const headerTextW = estimateTextWidth(entity.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel) + let maxAttrW = 0 + for (const attr of entity.attributes) { + const attrText = `${attr.type} ${attr.name}${attr.keys.length > 0 ? ' ' + attr.keys.join(',') : ''}` + const w = estimateMonoTextWidth(attrText, ER.attrFontSize) + if (w > maxAttrW) maxAttrW = w + } + const width = Math.max(ER.minWidth, headerTextW + ER.boxPadX * 2, maxAttrW + ER.boxPadX * 2) + const height = ER.headerHeight + Math.max(entity.attributes.length, 1) * ER.rowHeight + entitySizes.set(entity.id, { width, height }) + } + + const elkGraph: ElkNode = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.spacing.nodeNode': String(ER.nodeSpacing), + 'elk.layered.spacing.nodeNodeBetweenLayers': String(ER.layerSpacing), + 'elk.padding': `[top=${ER.padding},left=${ER.padding},bottom=${ER.padding},right=${ER.padding}]`, + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.edgeLabels.placement': 'CENTER', + }, + children: [], + edges: [], + } + + for (const entity of diagram.entities) { + const size = entitySizes.get(entity.id)! + elkGraph.children!.push({ id: entity.id, width: size.width, height: size.height }) + } + + for (let i = 0; i < diagram.relationships.length; i++) { + const rel = diagram.relationships[i]! + const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + const edge: ElkExtendedEdge = { id: `e${i}`, sources: [rel.entity1], targets: [rel.entity2] } + if (rel.label) { + edge.labels = [{ text: rel.label, width: metrics.width + 8, height: metrics.height + 6 }] + } + elkGraph.edges!.push(edge) + } + + return { elkGraph, entitySizes } +} + +/** Extract positioned entities and relationships from ELK result. */ +function extractErLayout( + result: ElkNode, + diagram: ErDiagram, + entitySizes: EntitySizeMap +): PositionedErDiagram { + const entityLookup = new Map() + for (const entity of diagram.entities) entityLookup.set(entity.id, entity) + + const positionedEntities: PositionedErEntity[] = [] + for (const child of result.children ?? []) { + const entity = entityLookup.get(child.id) + if (entity) { + positionedEntities.push({ + id: entity.id, + label: entity.label, + attributes: entity.attributes, + x: child.x ?? 0, + y: child.y ?? 0, + width: child.width ?? entitySizes.get(entity.id)!.width, + height: child.height ?? entitySizes.get(entity.id)!.height, + headerHeight: ER.headerHeight, + rowHeight: ER.rowHeight, + }) + } + } + + const relationships: PositionedErRelationship[] = [] + for (let i = 0; i < (result.edges?.length ?? 0); i++) { + const elkEdge = result.edges![i]! + const rel = diagram.relationships[i]! + + const points: Point[] = [] + if (elkEdge.sections && elkEdge.sections.length > 0) { + const section = elkEdge.sections[0]! + points.push({ x: section.startPoint.x, y: section.startPoint.y }) + if (section.bendPoints) { + for (const bp of section.bendPoints) { + points.push({ x: bp.x, y: bp.y }) + } + } + points.push({ x: section.endPoint.x, y: section.endPoint.y }) + } + + relationships.push({ + entity1: rel.entity1, + entity2: rel.entity2, + cardinality1: rel.cardinality1, + cardinality2: rel.cardinality2, + label: rel.label, + identifying: rel.identifying, + points, + }) + } + + return { + width: result.width ?? 600, + height: result.height ?? 400, + entities: positionedEntities, + relationships, + } +} + +/** + * Lay out a parsed ER diagram using ELK.js (synchronous). + */ +export function layoutErDiagramSync( + diagram: ErDiagram, + options: RenderOptions = {} +): PositionedErDiagram { + if (diagram.entities.length === 0) { + return { width: 0, height: 0, entities: [], relationships: [] } + } + + const { elkGraph, entitySizes } = buildErElkGraph(diagram, options) + const result = elkLayoutSync(elkGraph) + return extractErLayout(result, diagram, entitySizes) +} diff --git a/ui/vendor/beautiful-mermaid/er/parser.ts b/ui/vendor/beautiful-mermaid/er/parser.ts new file mode 100644 index 0000000..a4edaa8 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/er/parser.ts @@ -0,0 +1,181 @@ +import type { ErDiagram, ErEntity, ErAttribute, ErRelationship, Cardinality } from './types.ts' +import { normalizeBrTags } from '../multiline-utils.ts' + +// ============================================================================ +// ER diagram parser +// +// Parses Mermaid erDiagram syntax into an ErDiagram structure. +// +// Supported syntax: +// CUSTOMER ||--o{ ORDER : places +// CUSTOMER { +// string name PK +// int age +// string email UK "user email" +// } +// +// Cardinality notation: +// || exactly one +// o| zero or one (also |o) +// }| one or more (also |{) +// o{ zero or more (also {o) +// +// Line style: +// -- identifying (solid line) +// .. non-identifying (dashed line) +// ============================================================================ + +/** + * Parse a Mermaid ER diagram. + * Expects the first line to be "erDiagram". + */ +export function parseErDiagram(lines: string[]): ErDiagram { + const diagram: ErDiagram = { + entities: [], + relationships: [], + } + + // Track entities by ID for deduplication + const entityMap = new Map() + // Track entity body parsing + let currentEntity: ErEntity | null = null + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- Inside entity body --- + if (currentEntity) { + if (line === '}') { + currentEntity = null + continue + } + + // Attribute line: type name [PK|FK|UK] ["comment"] + const attr = parseAttribute(line) + if (attr) { + currentEntity.attributes.push(attr) + } + continue + } + + // --- Entity block start: `ENTITY_NAME {` --- + const entityBlockMatch = line.match(/^(\S+)\s*\{$/) + if (entityBlockMatch) { + const id = entityBlockMatch[1]! + const entity = ensureEntity(entityMap, id) + currentEntity = entity + continue + } + + // --- Relationship: `ENTITY1 cardinality1--cardinality2 ENTITY2 : label` --- + const rel = parseRelationshipLine(line) + if (rel) { + // Ensure both entities exist + ensureEntity(entityMap, rel.entity1) + ensureEntity(entityMap, rel.entity2) + diagram.relationships.push(rel) + continue + } + } + + diagram.entities = [...entityMap.values()] + return diagram +} + +/** Ensure an entity exists in the map */ +function ensureEntity(entityMap: Map, id: string): ErEntity { + let entity = entityMap.get(id) + if (!entity) { + entity = { id, label: id, attributes: [] } + entityMap.set(id, entity) + } + return entity +} + +/** Parse an attribute line inside an entity block */ +function parseAttribute(line: string): ErAttribute | null { + // Format: type name [PK|FK|UK [...]] ["comment"] + const match = line.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/) + if (!match) return null + + const type = match[1]! + const name = match[2]! + const rest = match[3]?.trim() ?? '' + + // Extract key constraints (PK, FK, UK) and optional comment + const keys: ErAttribute['keys'] = [] + let comment: string | undefined + + // Extract quoted comment first (supports
tags) + const commentMatch = rest.match(/"([^"]*)"/) + if (commentMatch) { + comment = normalizeBrTags(commentMatch[1]!) + } + + // Extract key constraints + const restWithoutComment = rest.replace(/"[^"]*"/, '').trim() + for (const part of restWithoutComment.split(/\s+/)) { + const upper = part.toUpperCase() + if (upper === 'PK' || upper === 'FK' || upper === 'UK') { + keys.push(upper as 'PK' | 'FK' | 'UK') + } + } + + return { type, name, keys, comment } +} + +/** + * Parse a relationship line. + * + * Cardinality symbols on each side of the line style: + * Left side (entity1): || |o o| }| |{ o{ {o + * Line: -- (identifying) or .. (non-identifying) + * Right side (entity2): || o| |o |{ }| {o o{ + * + * Full pattern example: CUSTOMER ||--o{ ORDER : places + */ +function parseRelationshipLine(line: string): ErRelationship | null { + // Match: ENTITY1 ENTITY2 : label + const match = line.match(/^(\S+)\s+([|o}{]+(?:--|\.\.)[|o}{]+)\s+(\S+)\s*:\s*(.+)$/) + if (!match) return null + + const entity1 = match[1]! + const cardinalityStr = match[2]! + const entity2 = match[3]! + // Strip surrounding quotes if present, then normalize br tags + const rawLabel = match[4]!.trim().replace(/^["']|["']$/g, '') + const label = normalizeBrTags(rawLabel) + + // Split the cardinality string into left side, line style, right side + const lineMatch = cardinalityStr.match(/^([|o}{]+)(--|\.\.?)([|o}{]+)$/) + if (!lineMatch) return null + + const leftStr = lineMatch[1]! + const lineStyle = lineMatch[2]! + const rightStr = lineMatch[3]! + + const cardinality1 = parseCardinality(leftStr) + const cardinality2 = parseCardinality(rightStr) + const identifying = lineStyle === '--' + + if (!cardinality1 || !cardinality2) return null + + return { entity1, entity2, cardinality1, cardinality2, label, identifying } +} + +/** Parse a cardinality notation string into a Cardinality type */ +function parseCardinality(str: string): Cardinality | null { + // Normalize: sort the characters to handle both orders (e.g., |o and o|) + const sorted = str.split('').sort().join('') + + // Exact one: || → sorted "||" + if (sorted === '||') return 'one' + // Zero or one: o| or |o → sorted "o|" (o=111 < |=124 in char codes) + if (sorted === 'o|') return 'zero-one' + // One or more: }| or |{ → sorted "|}" or "{|" + if (sorted === '|}' || sorted === '{|') return 'many' + // Zero or more: o{ or {o → sorted "{o" or "o{" + if (sorted === '{o' || sorted === 'o{') return 'zero-many' + + return null +} diff --git a/ui/vendor/beautiful-mermaid/er/renderer.ts b/ui/vendor/beautiful-mermaid/er/renderer.ts new file mode 100644 index 0000000..e45b018 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/er/renderer.ts @@ -0,0 +1,420 @@ +import type { PositionedErDiagram, PositionedErEntity, PositionedErRelationship, ErAttribute, Cardinality } from './types.ts' +import type { DiagramColors } from '../theme.ts' +import { svgOpenTag, buildStyleBlock } from '../theme.ts' +import { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, estimateTextWidth, TEXT_BASELINE_SHIFT } from '../styles.ts' +import { renderMultilineText, escapeXml as escapeXmlUtil } from '../multiline-utils.ts' +import { measureMultilineText } from '../text-metrics.ts' + +// ============================================================================ +// ER diagram SVG renderer +// +// Renders positioned ER diagrams to SVG. +// All colors use CSS custom properties (var(--_xxx)) from the theme system. +// +// Render order: +// 1. Relationship lines (behind boxes) +// 2. Entity boxes (header + attribute rows) +// 3. Cardinality markers (crow's foot notation) +// 4. Relationship labels +// ============================================================================ + +/** Font sizes specific to ER diagrams */ +const ER_FONT = { + attrSize: 11, + attrWeight: 400, + keySize: 9, + keyWeight: 600, +} as const + +/** + * Render a positioned ER diagram as an SVG string. + * + * @param colors - DiagramColors with bg/fg and optional enrichment variables. + * @param transparent - If true, renders with transparent background. + */ +export function renderErSvg( + diagram: PositionedErDiagram, + colors: DiagramColors, + font: string = 'Inter', + transparent: boolean = false +): string { + const parts: string[] = [] + + // SVG root with CSS variables + style block (with mono font) + defs + parts.push(svgOpenTag(diagram.width, diagram.height, colors, transparent)) + parts.push(buildStyleBlock(font, true)) + parts.push('') + parts.push('') // No marker defs — we draw crow's foot inline + + // 1. Relationship lines + for (const rel of diagram.relationships) { + parts.push(renderRelationshipLine(rel)) + } + + // 2. Entity boxes + for (const entity of diagram.entities) { + parts.push(renderEntityBox(entity)) + } + + // 3. Cardinality markers at relationship endpoints + for (const rel of diagram.relationships) { + parts.push(renderCardinality(rel)) + } + + // 4. Relationship labels + for (const rel of diagram.relationships) { + parts.push(renderRelationshipLabel(rel)) + } + + parts.push('') + return parts.join('\n') +} + +// ============================================================================ +// Entity box rendering +// ============================================================================ + +/** + * Render an entity box with header and attribute rows. + * Wrapped in with semantic data attributes. + */ +function renderEntityBox(entity: PositionedErEntity): string { + const { id, x, y, width, height, headerHeight, rowHeight, label, attributes } = entity + const parts: string[] = [] + + // Semantic wrapper with entity metadata + parts.push( + `` + ) + + // Outer rectangle + parts.push( + ` ` + ) + + // Header background + parts.push( + ` ` + ) + + // Entity name (supports multi-line via
tags) + parts.push( + ' ' + renderMultilineText( + label, + x + width / 2, + y + headerHeight / 2, + FONT_SIZES.nodeLabel, + `text-anchor="middle" font-size="${FONT_SIZES.nodeLabel}" font-weight="700" fill="var(--_text)"` + ) + ) + + // Divider + const attrTop = y + headerHeight + parts.push( + ` ` + ) + + // Attribute rows + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]! + const rowY = attrTop + i * rowHeight + rowHeight / 2 + parts.push(' ' + renderAttribute(attr, x, rowY, width).replace(/\n/g, '\n ')) + } + + // Empty row placeholder when no attributes + if (attributes.length === 0) { + parts.push( + ` (no attributes)` + ) + } + + parts.push('
') + return parts.join('\n') +} + +/** + * Render a single attribute row with monospace syntax highlighting. + * Layout: [PK badge] type name (left-aligned in mono, name right-aligned) + * Uses elements for per-part coloring, matching the class diagram style. + * + * Key badge uses var(--_key-badge) for background tint. + * Comments are shown as tooltips via SVG element. + */ +function renderAttribute(attr: ErAttribute, boxX: number, y: number, boxWidth: number): string { + const parts: string[] = [] + + // Wrap in a group if there's a comment (for tooltip support) + const hasComment = attr.comment && attr.comment.length > 0 + if (hasComment) { + // Replace <br> with newlines for tooltip display + const tooltipText = attr.comment!.replace(/<br\s*\/?>/gi, '\n') + parts.push(`<g><title>${escapeXml(tooltipText)}`) + } + + // Key badges on the left (keep proportional font — they're visual tags, not code) + let keyWidth = 0 + if (attr.keys.length > 0) { + const keyText = attr.keys.join(',') + keyWidth = estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8 + parts.push( + `` + ) + parts.push( + `${attr.keys.join(',')}` + ) + } + + // Type (left-aligned after keys, monospace with syntax highlighting) + const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0) + parts.push( + `` + + `${escapeXml(attr.type)}` + ) + + // Name (right-aligned, monospace with syntax highlighting) + const nameX = boxX + boxWidth - 8 + parts.push( + `` + + `${escapeXml(attr.name)}` + ) + + // Close the group if we opened one + if (hasComment) { + parts.push('
') + } + + return parts.join('\n') +} + +// ============================================================================ +// Relationship rendering +// ============================================================================ + +/** + * Render a relationship line with semantic data attributes. + */ +function renderRelationshipLine(rel: PositionedErRelationship): string { + if (rel.points.length < 2) return '' + + const pathData = rel.points.map(p => `${p.x},${p.y}`).join(' ') + const dashArray = !rel.identifying ? ' stroke-dasharray="6 4"' : '' + + // Semantic data attributes for relationship inspection + const labelAttr = rel.label ? ` data-label="${escapeAttr(rel.label)}"` : '' + const dataAttrs = [ + 'class="er-relationship"', + `data-entity1="${escapeAttr(rel.entity1)}"`, + `data-entity2="${escapeAttr(rel.entity2)}"`, + `data-cardinality1="${rel.cardinality1}"`, + `data-cardinality2="${rel.cardinality2}"`, + `data-identifying="${rel.identifying}"`, + ] + + return ( + `` + ) +} + +/** Render a relationship label at the midpoint (supports multi-line) */ +function renderRelationshipLabel(rel: PositionedErRelationship): string { + if (!rel.label || rel.points.length < 2) return '' + + const mid = midpoint(rel.points) + const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + + // Background pill for readability + const bgW = metrics.width + 8 + const bgH = metrics.height + 6 + + return ( + `` + + `\n${renderMultilineText(rel.label, mid.x, mid.y, FONT_SIZES.edgeLabel, + `text-anchor="middle" font-size="${FONT_SIZES.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`)}` + ) +} + +/** + * Render crow's foot cardinality markers at both endpoints of a relationship. + * + * Crow's foot notation: + * 'one': ─║─ (single vertical line) + * 'zero-one': ─o║─ (circle + single line) + * 'many': ─╢─ (crow's foot + single line) + * 'zero-many': ─o╣─ (circle + crow's foot) + */ +function renderCardinality(rel: PositionedErRelationship): string { + if (rel.points.length < 2) return '' + const parts: string[] = [] + + // Entity1 side (first point, direction toward second point) + const p1 = rel.points[0]! + const p2 = rel.points[1]! + parts.push(renderCrowsFoot(p1, p2, rel.cardinality1)) + + // Entity2 side (last point, direction toward second-to-last point) + const pN = rel.points[rel.points.length - 1]! + const pN1 = rel.points[rel.points.length - 2]! + parts.push(renderCrowsFoot(pN, pN1, rel.cardinality2)) + + return parts.join('\n') +} + +/** + * Render a crow's foot marker at a given endpoint. + * `point` is the endpoint, `toward` gives the direction the line comes from. + */ +function renderCrowsFoot( + point: { x: number; y: number }, + toward: { x: number; y: number }, + cardinality: Cardinality +): string { + const parts: string[] = [] + const sw = STROKE_WIDTHS.connector + 0.25 + + // Calculate direction from toward → point (unit vector) + const dx = point.x - toward.x + const dy = point.y - toward.y + const len = Math.sqrt(dx * dx + dy * dy) + if (len === 0) return '' + const ux = dx / len + const uy = dy / len + + // Perpendicular direction + const px = -uy + const py = ux + + // Marker sits 4px from the endpoint, extending 12px back along the edge + const tipX = point.x - ux * 4 + const tipY = point.y - uy * 4 + const backX = point.x - ux * 16 + const backY = point.y - uy * 16 + + // Single line: always present for 'one' and part of others + const hasOneLine = cardinality === 'one' || cardinality === 'zero-one' + const hasCrowsFoot = cardinality === 'many' || cardinality === 'zero-many' + const hasCircle = cardinality === 'zero-one' || cardinality === 'zero-many' + + // Draw single vertical line (perpendicular to edge) at the tip + if (hasOneLine) { + const halfW = 6 + parts.push( + `` + ) + // Second line slightly back for "exactly one" emphasis + const line2X = tipX - ux * 4 + const line2Y = tipY - uy * 4 + parts.push( + `` + ) + } + + // Crow's foot (three lines fanning out from tip) + if (hasCrowsFoot) { + const fanW = 7 + // Center line + const cfTipX = tipX + const cfTipY = tipY + // Three lines from tip to back, fanning out + parts.push( + // Top fan line + `` + ) + parts.push( + // Center line + `` + ) + parts.push( + // Bottom fan line + `` + ) + } + + // Circle (for zero variants) + if (hasCircle) { + const circleOffset = hasCrowsFoot ? 20 : 12 + const circleX = point.x - ux * circleOffset + const circleY = point.y - uy * circleOffset + parts.push( + `` + ) + } + + return parts.join('\n') +} + +/** Compute the arc-length midpoint of a polyline path. + * Walks along each segment, finds the point at exactly 50% of total path length. + * This ensures the label sits ON the path even for orthogonal routes with bends, + * unlike the naive first/last geometric center which floats in space for L/Z shapes. */ +function midpoint(points: Array<{ x: number; y: number }>): { x: number; y: number } { + if (points.length === 0) return { x: 0, y: 0 } + if (points.length === 1) return points[0]! + + // Compute total path length + let totalLen = 0 + for (let i = 1; i < points.length; i++) { + const dx = points[i]!.x - points[i - 1]!.x + const dy = points[i]!.y - points[i - 1]!.y + totalLen += Math.sqrt(dx * dx + dy * dy) + } + + if (totalLen === 0) return points[0]! + + // Walk to 50% of total length, interpolating within the segment that crosses the halfway mark + const halfLen = totalLen / 2 + let walked = 0 + for (let i = 1; i < points.length; i++) { + const dx = points[i]!.x - points[i - 1]!.x + const dy = points[i]!.y - points[i - 1]!.y + const segLen = Math.sqrt(dx * dx + dy * dy) + if (walked + segLen >= halfLen) { + const t = segLen > 0 ? (halfLen - walked) / segLen : 0 + return { + x: points[i - 1]!.x + dx * t, + y: points[i - 1]!.y + dy * t, + } + } + walked += segLen + } + + return points[points.length - 1]! +} + +// ============================================================================ +// Utilities +// ============================================================================ + +// Use shared escapeXml from multiline-utils +const escapeXml = escapeXmlUtil + +/** + * Escape a string for use as an XML/HTML attribute value. + */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} diff --git a/ui/vendor/beautiful-mermaid/er/types.ts b/ui/vendor/beautiful-mermaid/er/types.ts new file mode 100644 index 0000000..702f3c8 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/er/types.ts @@ -0,0 +1,91 @@ +// ============================================================================ +// ER diagram types +// +// Models the parsed and positioned representations of a Mermaid ER diagram. +// ER diagrams show database entities, their attributes, and relationships. +// ============================================================================ + +/** Parsed ER diagram — logical structure from mermaid text */ +export interface ErDiagram { + /** All entity definitions */ + entities: ErEntity[] + /** Relationships between entities */ + relationships: ErRelationship[] +} + +export interface ErEntity { + id: string + /** Display name (same as id unless aliased) */ + label: string + /** Entity attributes (columns) */ + attributes: ErAttribute[] +} + +export interface ErAttribute { + /** Data type (string, int, varchar, etc.) */ + type: string + /** Attribute name */ + name: string + /** Key constraints: PK, FK, UK */ + keys: Array<'PK' | 'FK' | 'UK'> + /** Optional comment */ + comment?: string +} + +/** + * Cardinality notation (crow's foot): + * 'one' || exactly one + * 'zero-one' |o zero or one + * 'many' }| one or more + * 'zero-many' o{ zero or more + */ +export type Cardinality = 'one' | 'zero-one' | 'many' | 'zero-many' + +export interface ErRelationship { + entity1: string + entity2: string + /** Cardinality at entity1's end */ + cardinality1: Cardinality + /** Cardinality at entity2's end */ + cardinality2: Cardinality + /** Relationship verb/label (e.g., "places", "contains") */ + label: string + /** Whether the relationship is identifying (solid line) or non-identifying (dashed) */ + identifying: boolean +} + +// ============================================================================ +// Positioned ER diagram — ready for SVG rendering +// ============================================================================ + +export interface PositionedErDiagram { + width: number + height: number + entities: PositionedErEntity[] + relationships: PositionedErRelationship[] +} + +export interface PositionedErEntity { + id: string + label: string + attributes: ErAttribute[] + x: number + y: number + width: number + height: number + /** Height of the header row */ + headerHeight: number + /** Height per attribute row */ + rowHeight: number +} + +export interface PositionedErRelationship { + entity1: string + entity2: string + cardinality1: Cardinality + cardinality2: Cardinality + label: string + identifying: boolean + /** Path points from entity1 to entity2 */ + points: Array<{ x: number; y: number }> +} diff --git a/ui/vendor/beautiful-mermaid/index.ts b/ui/vendor/beautiful-mermaid/index.ts new file mode 100644 index 0000000..b4a6454 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/index.ts @@ -0,0 +1,177 @@ +// ============================================================================ +// beautiful-mermaid — public API +// +// Renders Mermaid diagrams to styled SVG strings. +// Framework-agnostic, no DOM required. Pure TypeScript. +// +// Supported diagram types: +// - Flowcharts (graph TD / flowchart LR) +// - State diagrams (stateDiagram-v2) +// - Sequence diagrams (sequenceDiagram) +// - Class diagrams (classDiagram) +// - ER diagrams (erDiagram) +// +// Theming uses CSS custom properties (--bg, --fg, + optional enrichment). +// See src/theme.ts for the full variable system. +// +// Usage: +// import { renderMermaidSVG } from 'beautiful-mermaid' +// const svg = renderMermaidSVG('graph TD\n A --> B') +// ============================================================================ + +export type { RenderOptions, MermaidGraph, PositionedGraph } from './types.ts' +export type { DiagramColors, ThemeName } from './theme.ts' +export { fromShikiTheme, THEMES, DEFAULTS } from './theme.ts' +export { parseMermaid } from './parser.ts' +export { renderMermaidASCII, renderMermaidAscii } from './ascii/index.ts' +export type { AsciiRenderOptions } from './ascii/index.ts' + +import { decodeXML } from 'entities' +import { parseMermaid } from './parser.ts' +import { layoutGraphSync } from './layout.ts' +import { renderSvg } from './renderer.ts' +import type { RenderOptions } from './types.ts' +import type { DiagramColors } from './theme.ts' +import { DEFAULTS } from './theme.ts' + +import { parseSequenceDiagram } from './sequence/parser.ts' +import { layoutSequenceDiagram } from './sequence/layout.ts' +import { renderSequenceSvg } from './sequence/renderer.ts' +import { parseClassDiagram } from './class/parser.ts' +import { layoutClassDiagramSync } from './class/layout.ts' +import { renderClassSvg } from './class/renderer.ts' +import { parseErDiagram } from './er/parser.ts' +import { layoutErDiagramSync } from './er/layout.ts' +import { renderErSvg } from './er/renderer.ts' +import { parseXYChart } from './xychart/parser.ts' +import { layoutXYChart } from './xychart/layout.ts' +import { renderXYChartSvg } from './xychart/renderer.ts' + +/** + * Detect the diagram type from the mermaid source text. + * Returns the type keyword used for routing to the correct pipeline. + */ +function detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' { + const firstLine = text.trim().split(/[\n;]/)[0]?.trim().toLowerCase() ?? '' + + if (/^xychart(-beta)?\b/.test(firstLine)) return 'xychart' + if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence' + if (/^classdiagram\s*$/.test(firstLine)) return 'class' + if (/^erdiagram\s*$/.test(firstLine)) return 'er' + + // Default: flowchart/state (handled by parseMermaid internally) + return 'flowchart' +} + +/** + * Build a DiagramColors object from render options. + * Uses DEFAULTS for bg/fg when not provided, and passes through + * optional enrichment colors (line, accent, muted, surface, border). + */ +function buildColors(options: RenderOptions): DiagramColors { + return { + bg: options.bg ?? DEFAULTS.bg, + fg: options.fg ?? DEFAULTS.fg, + line: options.line, + accent: options.accent, + muted: options.muted, + surface: options.surface, + border: options.border, + } +} + +/** + * Render Mermaid diagram text to an SVG string — synchronously. + * + * Uses elk.bundled.js with a direct FakeWorker bypass (no setTimeout(0) delay). + * The ELK singleton is created lazily on first use and cached forever. + * + * Use this in React components with useMemo() to avoid flash: + * const svg = useMemo(() => renderMermaidSVG(code, opts), [code]) + * + * @param text - Mermaid source text + * @param options - Rendering options (colors, font, spacing) + * @returns A self-contained SVG string + * + * @example + * ```ts + * const svg = renderMermaidSVG('graph TD\n A --> B') + * + * // With theme + * const svg = renderMermaidSVG('graph TD\n A --> B', { + * bg: '#1a1b26', fg: '#a9b1d6' + * }) + * + * // With CSS variables (for live theme switching) + * const svg = renderMermaidSVG('graph TD\n A --> B', { + * bg: 'var(--background)', fg: 'var(--foreground)', transparent: true + * }) + * ``` + */ +export function renderMermaidSVG( + text: string, + options: RenderOptions = {} +): string { + // Decode XML entities that may leak from markdown parsers (e.g. rehype-raw). + // Without this, escapeXml() double-encodes them: < → &lt; → literal "<" in SVG. + text = decodeXML(text) + + const colors = buildColors(options) + const font = options.font ?? 'Inter' + const transparent = options.transparent ?? false + const diagramType = detectDiagramType(text) + + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + + switch (diagramType) { + case 'sequence': { + const diagram = parseSequenceDiagram(lines) + const positioned = layoutSequenceDiagram(diagram, options) + return renderSequenceSvg(positioned, colors, font, transparent) + } + case 'class': { + const diagram = parseClassDiagram(lines) + const positioned = layoutClassDiagramSync(diagram, options) + return renderClassSvg(positioned, colors, font, transparent) + } + case 'er': { + const diagram = parseErDiagram(lines) + const positioned = layoutErDiagramSync(diagram, options) + return renderErSvg(positioned, colors, font, transparent) + } + case 'xychart': { + const chart = parseXYChart(lines) + const positioned = layoutXYChart(chart, options) + return renderXYChartSvg(positioned, colors, font, transparent, options.interactive ?? false) + } + case 'flowchart': + default: { + const graph = parseMermaid(text) + const positioned = layoutGraphSync(graph, options) + return renderSvg(positioned, colors, font, transparent) + } + } +} + +/** + * Render Mermaid diagram text to an SVG string — async. + * + * Same result as renderMermaidSVG() but returns a Promise. + * Useful in async contexts (server handlers, data loaders, etc.) + */ +export async function renderMermaidSVGAsync( + text: string, + options: RenderOptions = {} +): Promise { + return renderMermaidSVG(text, options) +} + +// --------------------------------------------------------------------------- +// Backward-compatible aliases +// --------------------------------------------------------------------------- + +/** @deprecated Use `renderMermaidSVG` */ +export const renderMermaidSync = renderMermaidSVG + +/** @deprecated Use `renderMermaidSVGAsync` */ +export const renderMermaid = renderMermaidSVGAsync diff --git a/ui/vendor/beautiful-mermaid/layout-engine.ts b/ui/vendor/beautiful-mermaid/layout-engine.ts new file mode 100644 index 0000000..8557308 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/layout-engine.ts @@ -0,0 +1,1421 @@ +/** + * Layout engine for beautiful-mermaid (ELK.js based). + * + * Converts MermaidGraph to ELK's JSON format, runs layout, and converts + * the result back to PositionedGraph. This is the core layout engine used + * by all graph-based diagram types (flowcharts, state, ER, class). + * + * ELK (Eclipse Layout Kernel) features: + * - Native orthogonal edge routing (no post-processing needed) + * - Proper handling of compound nodes (subgraphs) + * - Support for disconnected graphs + * - Direction overrides per subgraph + * - Sophisticated algorithms for complex graphs + * + * Uses elk.bundled.js (pure synchronous JS, no WASM/Workers). + * Safe for Electron, Node, and browser environments. + */ + +import type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs' +import type { + MermaidGraph, + MermaidSubgraph, + MermaidEdge, + Direction, + PositionedGraph, + PositionedNode, + PositionedEdge, + PositionedGroup, + Point, + RenderOptions, +} from './types.ts' +import { FONT_SIZES, FONT_WEIGHTS, NODE_PADDING, ARROW_HEAD } from './styles.ts' +import { measureMultilineText } from './text-metrics.ts' +import { elkLayoutSync } from './elk-instance.ts' +import { clipEdgeToShape } from './shape-clipping.ts' + +// ============================================================================ +// Layout options +// ============================================================================ + +/** Default render options (layout-only) */ +const DEFAULTS = { + font: 'Inter', + padding: 40, + nodeSpacing: 28, + layerSpacing: 48, + mergeEdges: true, + thoroughness: 3, +} as const + +/** Convert Mermaid direction to ELK direction */ +function directionToElk(dir: MermaidGraph['direction']): string { + switch (dir) { + case 'LR': return 'RIGHT' + case 'RL': return 'LEFT' + case 'BT': return 'UP' + case 'TD': + case 'TB': + default: return 'DOWN' + } +} + +// ============================================================================ +// Node sizing (same logic as Dagre adapter) +// ============================================================================ + +function estimateNodeSize(id: string, label: string, shape: string): { width: number; height: number } { + const metrics = measureMultilineText(label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel) + + let width = metrics.width + NODE_PADDING.horizontal * 2 + let height = metrics.height + NODE_PADDING.vertical * 2 + + if (shape === 'diamond') { + const side = Math.max(width, height) + NODE_PADDING.diamondExtra + width = side + height = side + } + + if (shape === 'circle' || shape === 'doublecircle') { + const diameter = Math.ceil(Math.sqrt(width * width + height * height)) + 8 + width = shape === 'doublecircle' ? diameter + 12 : diameter + height = width + } + + if (shape === 'hexagon') { + width += NODE_PADDING.horizontal + } + + if (shape === 'trapezoid' || shape === 'trapezoid-alt') { + width += NODE_PADDING.horizontal + } + + if (shape === 'asymmetric') { + width += 12 + } + + if (shape === 'cylinder') { + height += 14 + } + + if (shape === 'state-start' || shape === 'state-end') { + return { width: 28, height: 28 } + } + + width = Math.max(width, 60) + height = Math.max(height, 36) + + return { width, height } +} + +// ============================================================================ +// Graph conversion: MermaidGraph → ELK JSON +// ============================================================================ + +interface ElkGraphNode extends ElkNode { + children?: ElkGraphNode[] + edges?: ElkExtendedEdge[] +} + +/** + * Tracks port-to-edge mappings for hierarchical port edges. + * Used to combine external and internal edge sections during extraction. + */ +interface HierarchicalEdgeInfo { + originalIndex: number + externalEdgeId: string + internalEdgeId: string + subgraphId: string + direction: 'incoming' | 'outgoing' +} + +/** + * Convert a MermaidGraph to ELK's nested JSON input format. + * + * Uses SEPARATE hierarchy handling for proper subgraph direction override support. + * Cross-hierarchy edges use hierarchical ports to connect external and internal sections. + */ +function mermaidToElk( + graph: MermaidGraph, + opts: Required> +): ElkGraphNode { + // Collect all node IDs that belong to subgraphs + const subgraphNodeIds = new Set() + const subgraphIds = new Set() + for (const sg of graph.subgraphs) { + subgraphIds.add(sg.id) + collectSubgraphNodeIds(sg, subgraphNodeIds, subgraphIds) + } + + // Build node-to-subgraph mapping for edge distribution + const nodeToSubgraph = buildNodeToSubgraphMap(graph.subgraphs) + + // Classify edges into three categories: + // 1. Internal edges (both endpoints in same subgraph) + // 2. Root-level edges (neither endpoint in a subgraph) + // 3. Cross-hierarchy edges (endpoints in different levels) + const edgesBySubgraph = new Map>() + edgesBySubgraph.set(null, []) // Root-level edges + + // Track cross-hierarchy edges for hierarchical port creation + const crossHierarchyEdges: Array<{ + index: number + edge: typeof graph.edges[0] + sourceSubgraph: string | undefined + targetSubgraph: string | undefined + }> = [] + + for (let i = 0; i < graph.edges.length; i++) { + const edge = graph.edges[i]! + const sourceSubgraph = nodeToSubgraph.get(edge.source) + const targetSubgraph = nodeToSubgraph.get(edge.target) + + if (sourceSubgraph && sourceSubgraph === targetSubgraph) { + // Internal edge: both endpoints in same subgraph + if (!edgesBySubgraph.has(sourceSubgraph)) { + edgesBySubgraph.set(sourceSubgraph, []) + } + edgesBySubgraph.get(sourceSubgraph)!.push({ index: i, edge }) + } else if (!sourceSubgraph && !targetSubgraph) { + // Root-level edge: neither endpoint in a subgraph + edgesBySubgraph.get(null)!.push({ index: i, edge }) + } else { + // Cross-hierarchy edge: need hierarchical ports + crossHierarchyEdges.push({ index: i, edge, sourceSubgraph, targetSubgraph }) + } + } + + // Determine if we need SEPARATE hierarchy handling + // We use SEPARATE when any subgraph has a direction override + const hasDirectionOverride = graph.subgraphs.some(sg => sg.direction !== undefined) + + // Build the root ELK graph + const elkGraph: ElkGraphNode = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': directionToElk(graph.direction), + 'elk.spacing.nodeNode': String(opts.nodeSpacing), + 'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing), + 'elk.spacing.edgeEdge': '12', + 'elk.layered.spacing.edgeEdgeBetweenLayers': '12', + 'elk.layered.spacing.edgeNodeBetweenLayers': '12', + 'elk.padding': `[top=${opts.padding},left=${opts.padding},bottom=${opts.padding},right=${opts.padding}]`, + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.contentAlignment': 'H_CENTER V_CENTER', + 'elk.layered.thoroughness': String(DEFAULTS.thoroughness), + 'elk.layered.highDegreeNodes.treatment': 'true', + 'elk.layered.highDegreeNodes.threshold': '8', + 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.wrapping.strategy': 'OFF', + // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling) + // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing) + 'elk.hierarchyHandling': hasDirectionOverride ? 'SEPARATE' : 'INCLUDE_CHILDREN', + }, + children: [], + edges: [], + } + + // Track hierarchical ports per subgraph for cross-hierarchy edges + const subgraphPorts = new Map>() + + // Process cross-hierarchy edges to create port entries + if (hasDirectionOverride) { + for (const { index, edge, sourceSubgraph, targetSubgraph } of crossHierarchyEdges) { + // Handle outgoing edges from subgraph + if (sourceSubgraph) { + const portId = `${sourceSubgraph}_out_${index}` + if (!subgraphPorts.has(sourceSubgraph)) { + subgraphPorts.set(sourceSubgraph, []) + } + subgraphPorts.get(sourceSubgraph)!.push({ + portId, + edgeIndex: index, + direction: 'outgoing', + internalNodeId: edge.source, + }) + } + + // Handle incoming edges to subgraph + if (targetSubgraph) { + const portId = `${targetSubgraph}_in_${index}` + if (!subgraphPorts.has(targetSubgraph)) { + subgraphPorts.set(targetSubgraph, []) + } + subgraphPorts.get(targetSubgraph)!.push({ + portId, + edgeIndex: index, + direction: 'incoming', + internalNodeId: edge.target, + }) + } + } + } + + // Add top-level nodes (those not in any subgraph) + for (const [id, node] of graph.nodes) { + if (!subgraphNodeIds.has(id) && !subgraphIds.has(id)) { + const size = estimateNodeSize(id, node.label, node.shape) + elkGraph.children!.push({ + id, + width: size.width, + height: size.height, + labels: [{ text: node.label }], + }) + } + } + + // Add subgraphs as compound nodes with children and their internal edges + for (const sg of graph.subgraphs) { + elkGraph.children!.push(subgraphToElk(sg, graph, opts, edgesBySubgraph, subgraphPorts)) + } + + // Add root-level edges + for (const { index, edge } of edgesBySubgraph.get(null)!) { + const elkEdge: ElkExtendedEdge = { + id: `e${index}`, + sources: [edge.source], + targets: [edge.target], + } + if (edge.label) { + const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + elkEdge.labels = [{ + text: edge.label, + width: metrics.width + 8, + height: metrics.height + 6, + layoutOptions: { + 'elk.edgeLabels.inline': 'true', + 'elk.edgeLabels.placement': 'CENTER', + }, + }] + } + elkGraph.edges!.push(elkEdge) + } + + // Add cross-hierarchy edges (using ports when SEPARATE, direct when INCLUDE_CHILDREN) + for (const { index, edge, sourceSubgraph, targetSubgraph } of crossHierarchyEdges) { + const elkEdge: ElkExtendedEdge = { + id: `e${index}`, + sources: hasDirectionOverride && sourceSubgraph ? [`${sourceSubgraph}_out_${index}`] : [edge.source], + targets: hasDirectionOverride && targetSubgraph ? [`${targetSubgraph}_in_${index}`] : [edge.target], + } + if (edge.label) { + const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + elkEdge.labels = [{ + text: edge.label, + width: metrics.width + 8, + height: metrics.height + 6, + layoutOptions: { + 'elk.edgeLabels.inline': 'true', + 'elk.edgeLabels.placement': 'CENTER', + }, + }] + } + elkGraph.edges!.push(elkEdge) + } + + return elkGraph +} + +/** + * Convert a MermaidSubgraph to an ELK compound node. + * Includes internal edges (edges where both endpoints are in this subgraph) + * so that the subgraph's direction override is respected by ELK. + * + * When using SEPARATE hierarchy handling (for direction override support), + * also adds hierarchical ports for cross-hierarchy edges. + */ +function subgraphToElk( + sg: MermaidSubgraph, + graph: MermaidGraph, + opts: Required>, + edgesBySubgraph: Map>, + subgraphPorts: Map> +): ElkGraphNode { + const layoutOptions: LayoutOptions = { + 'elk.algorithm': 'layered', + 'elk.padding': '[top=44,left=16,bottom=16,right=16]', // Top = headerHeight(28) + gap(16) to match bottom padding + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.contentAlignment': 'H_CENTER V_CENTER', + 'elk.spacing.edgeEdge': '12', + 'elk.layered.spacing.edgeEdgeBetweenLayers': '12', + 'elk.layered.spacing.edgeNodeBetweenLayers': '12', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing), + 'elk.spacing.nodeNode': String(opts.nodeSpacing), + } + + // Apply direction override if specified + if (sg.direction) { + layoutOptions['elk.direction'] = directionToElk(sg.direction) + } + + const elkNode: ElkGraphNode = { + id: sg.id, + layoutOptions, + labels: sg.label ? [{ text: sg.label }] : undefined, + children: [], + edges: [], + } + + // Add hierarchical ports for cross-hierarchy edges (when using SEPARATE) + const ports = subgraphPorts.get(sg.id) ?? [] + if (ports.length > 0) { + // ELK supports ports but types don't include it + (elkNode as unknown as Record).ports = ports.map(p => ({ + id: p.portId, + // Port side is determined by ELK based on edge direction + })) + } + + // Add direct child nodes + for (const nodeId of sg.nodeIds) { + const node = graph.nodes.get(nodeId) + if (node) { + const size = estimateNodeSize(nodeId, node.label, node.shape) + elkNode.children!.push({ + id: nodeId, + width: size.width, + height: size.height, + labels: [{ text: node.label }], + }) + } + } + + // Add nested subgraphs recursively + for (const child of sg.children) { + elkNode.children!.push(subgraphToElk(child, graph, opts, edgesBySubgraph, subgraphPorts)) + } + + // Add internal edges (edges where both endpoints are in this subgraph) + const internalEdges = edgesBySubgraph.get(sg.id) ?? [] + for (const { index, edge } of internalEdges) { + const elkEdge: ElkExtendedEdge = { + id: `e${index}`, + sources: [edge.source], + targets: [edge.target], + } + if (edge.label) { + const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + elkEdge.labels = [{ + text: edge.label, + width: metrics.width + 8, + height: metrics.height + 6, + layoutOptions: { + 'elk.edgeLabels.inline': 'true', + 'elk.edgeLabels.placement': 'CENTER', + }, + }] + } + elkNode.edges!.push(elkEdge) + } + + // Add internal edge segments for hierarchical ports (port → node or node → port) + // These connect the boundary ports to actual internal nodes + for (const port of ports) { + const internalEdgeId = `e${port.edgeIndex}_internal` + const elkEdge: ElkExtendedEdge = port.direction === 'incoming' + ? { id: internalEdgeId, sources: [port.portId], targets: [port.internalNodeId] } + : { id: internalEdgeId, sources: [port.internalNodeId], targets: [port.portId] } + elkNode.edges!.push(elkEdge) + } + + return elkNode +} + +/** Recursively collect all node IDs that belong to any subgraph */ +function collectSubgraphNodeIds(sg: MermaidSubgraph, nodeIds: Set, subgraphIds: Set): void { + for (const id of sg.nodeIds) { + nodeIds.add(id) + } + for (const child of sg.children) { + subgraphIds.add(child.id) + collectSubgraphNodeIds(child, nodeIds, subgraphIds) + } +} + +/** + * Build a mapping from node ID to its containing subgraph ID. + * For nested subgraphs, maps to the innermost containing subgraph. + * Nodes not in any subgraph are not included in the map. + */ +function buildNodeToSubgraphMap(subgraphs: MermaidSubgraph[]): Map { + const map = new Map() + + function traverse(sg: MermaidSubgraph): void { + // Map all direct child nodes to this subgraph + for (const nodeId of sg.nodeIds) { + map.set(nodeId, sg.id) + } + // Recursively process nested subgraphs (they override parent mapping) + for (const child of sg.children) { + traverse(child) + } + } + + for (const sg of subgraphs) { + traverse(sg) + } + + return map +} + +// ============================================================================ +// Result conversion: ELK output → PositionedGraph +// ============================================================================ + +/** + * Convert ELK layout result to our PositionedGraph format. + */ +/** Margin routing info for cross-hierarchy edges */ +interface MarginInfo { + leftX: number + rightX: number +} + +/** Recursively flatten all group bounding boxes (including nested children) */ +function flattenGroupBounds(groups: PositionedGroup[]): Array<{ x: number; y: number; right: number; bottom: number }> { + const bounds: Array<{ x: number; y: number; right: number; bottom: number }> = [] + for (const g of groups) { + bounds.push({ x: g.x, y: g.y, right: g.x + g.width, bottom: g.y + g.height }) + bounds.push(...flattenGroupBounds(g.children)) + } + return bounds +} + +function elkToPositioned( + elkResult: ElkNode, + graph: MermaidGraph, + mergeEdges: boolean = false +): PositionedGraph { + const nodes: PositionedNode[] = [] + const edges: PositionedEdge[] = [] + const groups: PositionedGroup[] = [] + + // Build set of subgraph IDs for distinguishing compound nodes from leaf nodes + const subgraphIds = new Set() + for (const sg of graph.subgraphs) { + collectAllSubgraphIds(sg, subgraphIds) + } + + // Extract nodes and groups recursively + extractNodesAndGroups(elkResult, graph, subgraphIds, nodes, groups, 0, 0) + + // Compute margin positions for cross-hierarchy edge routing. + // Margins sit outside all group bounding boxes so edges don't cross through subgraphs. + const allBounds = flattenGroupBounds(groups) + const margins: MarginInfo | undefined = allBounds.length > 0 + ? { + leftX: Math.min(...allBounds.map(b => b.x)) - 20, + rightX: Math.max(...allBounds.map(b => b.right)) + 20, + } + : undefined + + // Extract edges recursively from all levels (root and subgraphs) + // Edges are distributed to subgraphs for direction override to work, + // so we need to collect them from all children with proper offsets + extractEdgesRecursively(elkResult, graph, edges, 0, 0, margins) + + // Snap same-layer nodes to the same position along the flow axis. + // ELK's orthogonal routing staggers nodes within a layer to create room for + // edge bends, but this looks bad. We fix it by aligning layers, then let + // edge bundling and clipping recalculate edge paths from corrected positions. + alignLayerNodes(nodes, edges, graph.direction) + + // Bundle fan-out/fan-in edge paths into shared trunks when mergeEdges is enabled + if (mergeEdges) { + bundleEdgePaths(edges, nodes, groups, graph.direction) + } + + // Apply shape-aware edge clipping for non-rectangular shapes. + // ELK treats all nodes as rectangles, so we need to clip edge endpoints + // to the actual shape boundaries (e.g., diamond vertices). + const nodeMap = new Map(nodes.map(n => [n.id, n])) + for (const edge of edges) { + const sourceNode = nodeMap.get(edge.source) + const targetNode = nodeMap.get(edge.target) + + if (sourceNode) { + edge.points = clipEdgeToShape(edge.points, sourceNode, true) + } + if (targetNode) { + edge.points = clipEdgeToShape(edge.points, targetNode, false) + } + } + + // Calculate final bounds including all edge points + // ELK should include edges in its dimensions, but we verify and expand if needed + let width = elkResult.width ?? 800 + let height = elkResult.height ?? 600 + const arrowMargin = ARROW_HEAD.width + const padding = DEFAULTS.padding + + for (const edge of edges) { + for (const p of edge.points) { + width = Math.max(width, p.x + arrowMargin + padding) + height = Math.max(height, p.y + arrowMargin + padding) + } + if (edge.labelPosition) { + width = Math.max(width, edge.labelPosition.x + 60 + padding) + height = Math.max(height, edge.labelPosition.y + 20 + padding) + } + } + + return { + width, + height, + nodes, + edges, + groups, + } +} + +/** + * Recursively extract positioned nodes and groups from ELK result. + */ +function extractNodesAndGroups( + elkNode: ElkNode, + graph: MermaidGraph, + subgraphIds: Set, + nodes: PositionedNode[], + groups: PositionedGroup[], + offsetX: number, + offsetY: number +): void { + if (!elkNode.children) return + + for (const child of elkNode.children) { + const x = (child.x ?? 0) + offsetX + const y = (child.y ?? 0) + offsetY + const width = child.width ?? 0 + const height = child.height ?? 0 + + if (subgraphIds.has(child.id)) { + // This is a subgraph/group + const childGroups: PositionedGroup[] = [] + + // Recursively process children + extractNodesAndGroups(child, graph, subgraphIds, nodes, childGroups, x, y) + + const mermaidSg = findSubgraph(graph.subgraphs, child.id) + groups.push({ + id: child.id, + label: mermaidSg?.label ?? '', + x, + y, + width, + height, + children: childGroups, + }) + } else { + // This is a leaf node + const mNode = graph.nodes.get(child.id) + if (mNode) { + // Resolve inline styles from nodeStyles map and classDefs + const inlineStyle = resolveNodeStyle(child.id, graph) + + nodes.push({ + id: child.id, + label: mNode.label, + shape: mNode.shape, + x, + y, + width, + height, + inlineStyle, + }) + } + + // Also check for nested children (shouldn't happen for leaf nodes, but be safe) + if (child.children && child.children.length > 0) { + extractNodesAndGroups(child, graph, subgraphIds, nodes, groups, x, y) + } + } + } +} + +/** + * Edge segment extracted from ELK result. + * Used to combine external and internal segments of hierarchical edges. + */ +interface EdgeSegment { + edgeIndex: number + isInternal: boolean // true for port-to-node segments (e.g., "e3_internal") + points: Point[] + labelPosition?: Point +} + +/** + * Calculate the midpoint along a polyline path. + * Walks the path to find the point at half the total length. + */ +function calculatePathMidpoint(points: Point[]): Point { + if (points.length === 0) return { x: 0, y: 0 } + if (points.length === 1) return points[0]! + + // Calculate total length + let totalLength = 0 + for (let i = 1; i < points.length; i++) { + const dx = points[i]!.x - points[i - 1]!.x + const dy = points[i]!.y - points[i - 1]!.y + totalLength += Math.sqrt(dx * dx + dy * dy) + } + + // Walk to halfway point + let remaining = totalLength / 2 + for (let i = 1; i < points.length; i++) { + const dx = points[i]!.x - points[i - 1]!.x + const dy = points[i]!.y - points[i - 1]!.y + const segLen = Math.sqrt(dx * dx + dy * dy) + if (remaining <= segLen) { + const t = remaining / segLen + return { + x: points[i - 1]!.x + t * dx, + y: points[i - 1]!.y + t * dy, + } + } + remaining -= segLen + } + + return points[points.length - 1]! +} + +/** + * Recursively extract edges from ELK result including those inside subgraphs. + * Edges are distributed to subgraphs for direction override to work, + * so we need to collect them from all levels with proper coordinate offsets. + * + * For hierarchical edges (cross-hierarchy with ports), combines external and + * internal segments into a single continuous edge path. + */ +function extractEdgesRecursively( + elkNode: ElkNode, + graph: MermaidGraph, + edges: PositionedEdge[], + offsetX: number, + offsetY: number, + margins?: MarginInfo +): void { + // First pass: collect all edge segments + const segments = new Map() + collectEdgeSegments(elkNode, segments, 0, 0) + + // Track margin-routed edge count for spacing offsets + let marginEdgeIndex = 0 + + // Second pass: combine segments and create positioned edges + for (const [edgeIndex, seg] of segments) { + const originalEdge = graph.edges[edgeIndex] + if (!originalEdge) continue + + // Combine points from all segments in correct order: + // - For incoming cross-hierarchy (external → subgraph): external then incoming + // - For outgoing cross-hierarchy (subgraph → external): outgoing then external + // - For both (subgraph A → subgraph B): outgoing → external → incoming + const allPoints: Point[] = [] + + // First: outgoing internal segment (source node → exit port) + if (seg.outgoing && seg.outgoing.points.length > 0) { + allPoints.push(...seg.outgoing.points) + } + + // Second: external segment (exit port → entry port, or source → entry port, or exit port → target) + if (seg.external && seg.external.points.length > 0) { + if (allPoints.length > 0) { + // Skip first point to avoid duplicate at outgoing port + allPoints.push(...seg.external.points.slice(1)) + } else { + allPoints.push(...seg.external.points) + } + } + + // Third: incoming internal segment (entry port → target node) + if (seg.incoming && seg.incoming.points.length > 0) { + if (allPoints.length > 0) { + // Skip first point to avoid duplicate at incoming port + allPoints.push(...seg.incoming.points.slice(1)) + } else { + allPoints.push(...seg.incoming.points) + } + } + + // Label position: use ELK's inline label position (on-edge with collision avoidance) + // Fall back to midpoint for hierarchical edges or when ELK position unavailable + let labelPosition: Point | undefined + if (originalEdge.label && allPoints.length >= 2) { + const elkLabelPos = seg.external?.labelPosition + labelPosition = elkLabelPos ?? calculatePathMidpoint(allPoints) + } + + // Ensure all edge segments are orthogonal (horizontal or vertical only). + // In SEPARATE hierarchy mode, ELK may produce diagonal segments for + // cross-hierarchy edges where it only returns start/end points without + // proper orthogonal bend points. + // When margins are available, route through the diagram margins instead + // of Z-paths through the middle (which cross through subgraphs). + const orthogonalPoints = orthogonalizeEdgePoints(allPoints, margins, marginEdgeIndex) + if (orthogonalPoints !== allPoints) { + marginEdgeIndex++ + } + + // Recalculate label position for margin-routed edges + if (originalEdge.label && orthogonalPoints !== allPoints && orthogonalPoints.length >= 2) { + labelPosition = calculatePathMidpoint(orthogonalPoints) + } + + edges.push({ + source: originalEdge.source, + target: originalEdge.target, + label: originalEdge.label, + style: originalEdge.style, + hasArrowStart: originalEdge.hasArrowStart, + hasArrowEnd: originalEdge.hasArrowEnd, + points: orthogonalPoints, + labelPosition, + inlineStyle: resolveEdgeStyle(edgeIndex, graph), + }) + } +} + +/** + * Post-process edge points to ensure all segments are purely orthogonal. + * + * When ELK uses SEPARATE hierarchy handling (required for subgraph direction + * overrides), cross-hierarchy edges may only get start/end coordinates without + * intermediate bend points, producing diagonal lines. + * + * When margins are provided, routes diagonal segments through the left or right + * margin of the diagram (outside all subgraphs). Alternates sides and adds + * spacing offsets to prevent overlapping parallel edges. + * + * Without margins, falls back to Z-path through the vertical midpoint. + * + * Returns the original array reference (identity) if no changes were needed, + * so callers can detect whether routing was applied. + */ +function orthogonalizeEdgePoints( + points: Point[], + margins?: MarginInfo, + edgeIndex: number = 0 +): Point[] { + if (points.length < 2) return points + + // Check if any segment needs orthogonalization + let needsWork = false + for (let i = 1; i < points.length; i++) { + const dx = Math.abs(points[i]!.x - points[i - 1]!.x) + const dy = Math.abs(points[i]!.y - points[i - 1]!.y) + if (dx > 1 && dy > 1) { needsWork = true; break } + } + if (!needsWork) return points + + const EDGE_SPACING = 12 + const result: Point[] = [points[0]!] + + for (let i = 1; i < points.length; i++) { + const prev = result[result.length - 1]! + const curr = points[i]! + const dx = Math.abs(curr.x - prev.x) + const dy = Math.abs(curr.y - prev.y) + + if (dx > 1 && dy > 1) { + if (margins) { + // Margin routing: exit horizontally → travel vertically along margin → enter horizontally + // Alternate left/right margins and offset for parallel edge spacing + const useRight = edgeIndex % 2 === 0 + const offset = Math.floor(edgeIndex / 2) * EDGE_SPACING + const marginX = useRight + ? margins.rightX + offset + : margins.leftX - offset + + result.push({ x: marginX, y: prev.y }) + result.push({ x: marginX, y: curr.y }) + } else { + // Fallback: Z-path through vertical midpoint + const midY = (prev.y + curr.y) / 2 + result.push({ x: prev.x, y: midY }) + result.push({ x: curr.x, y: midY }) + } + } + + result.push(curr) + } + + return result +} + +/** + * Recursively collect edge segments from ELK result. + */ +function collectEdgeSegments( + elkNode: ElkNode, + segments: Map, + offsetX: number, + offsetY: number +): void { + if (elkNode.edges) { + for (const elkEdge of elkNode.edges) { + // Parse edge ID: "e{index}" or "e{index}_internal" + const isInternal = elkEdge.id.endsWith('_internal') + const edgeIndex = parseInt(elkEdge.id.substring(1), 10) + if (isNaN(edgeIndex)) continue + + // Extract points + const points: Point[] = [] + if (elkEdge.sections && elkEdge.sections.length > 0) { + const section = elkEdge.sections[0]! + points.push({ + x: section.startPoint.x + offsetX, + y: section.startPoint.y + offsetY, + }) + if (section.bendPoints) { + for (const bp of section.bendPoints) { + points.push({ x: bp.x + offsetX, y: bp.y + offsetY }) + } + } + points.push({ + x: section.endPoint.x + offsetX, + y: section.endPoint.y + offsetY, + }) + } + + // Extract label position + let labelPosition: Point | undefined + if (elkEdge.labels && elkEdge.labels.length > 0) { + const label = elkEdge.labels[0]! + if (label.x != null && label.y != null) { + labelPosition = { + x: label.x + (label.width ?? 0) / 2 + offsetX, + y: label.y + (label.height ?? 0) / 2 + offsetY, + } + } + } + + // Store segment + if (!segments.has(edgeIndex)) { + segments.set(edgeIndex, {}) + } + const seg = segments.get(edgeIndex)! + + if (isInternal) { + // Determine if this is an incoming or outgoing internal segment + // by checking if source is a port (incoming) or target is a port (outgoing) + const source = elkEdge.sources?.[0] ?? '' + const target = elkEdge.targets?.[0] ?? '' + const sourceIsPort = source.includes('_in_') || source.includes('_out_') + const targetIsPort = target.includes('_in_') || target.includes('_out_') + + if (sourceIsPort) { + // Port → node: incoming internal segment + seg.incoming = { edgeIndex, isInternal, points, labelPosition } + } else if (targetIsPort) { + // Node → port: outgoing internal segment + seg.outgoing = { edgeIndex, isInternal, points, labelPosition } + } + } else { + seg.external = { edgeIndex, isInternal, points, labelPosition } + } + } + } + + // Recurse into children with accumulated offset + if (elkNode.children) { + for (const child of elkNode.children) { + collectEdgeSegments(child, segments, offsetX + (child.x ?? 0), offsetY + (child.y ?? 0)) + } + } +} + +/** Find a subgraph by ID in a nested structure */ +function findSubgraph(subgraphs: MermaidSubgraph[], id: string): MermaidSubgraph | undefined { + for (const sg of subgraphs) { + if (sg.id === id) return sg + const found = findSubgraph(sg.children, id) + if (found) return found + } + return undefined +} + +/** Recursively collect all subgraph IDs */ +function collectAllSubgraphIds(sg: MermaidSubgraph, out: Set): void { + out.add(sg.id) + for (const child of sg.children) { + collectAllSubgraphIds(child, out) + } +} + +/** + * Resolve inline styles for a node from classDefs and nodeStyles. + * Class styles are applied first, then explicit style directives override. + */ +function resolveNodeStyle( + nodeId: string, + graph: MermaidGraph +): Record | undefined { + let result: Record | undefined + + // First, apply class styles (if node has a class assignment) + const className = graph.classAssignments.get(nodeId) + if (className) { + const classDef = graph.classDefs.get(className) + if (classDef) { + result = { ...classDef } + } + } + + // Then, apply explicit style directives (override class styles) + const nodeStyle = graph.nodeStyles.get(nodeId) + if (nodeStyle) { + result = result ? { ...result, ...nodeStyle } : { ...nodeStyle } + } + + return result +} + +/** + * Resolve inline styles for an edge from linkStyles map. + * Default link style is applied first, then index-specific overrides. + */ +function resolveEdgeStyle( + edgeIndex: number, + graph: MermaidGraph +): Record | undefined { + let result: Record | undefined + + const defaultStyle = graph.linkStyles.get('default') + if (defaultStyle) { + result = { ...defaultStyle } + } + + const indexStyle = graph.linkStyles.get(edgeIndex) + if (indexStyle) { + result = result ? { ...result, ...indexStyle } : { ...indexStyle } + } + + return result +} + +// ============================================================================ +// Layer alignment — snap same-layer nodes to a uniform position +// ============================================================================ + +/** + * ELK's orthogonal edge routing staggers nodes within the same layer to create + * space for edge bends. This post-processing step groups nodes into layers and + * snaps them to the same flow-axis coordinate (Y for TD/TB, X for LR/RL). + * + * Grouping uses proximity along the flow axis: within a layer, ELK's stagger + * is always less than layerSpacing (bounded by edge routing channels), while + * adjacent layers are separated by at least layerSpacing + nodeHeight. + * A threshold of 0.75 * layerSpacing cleanly separates these cases. + * + * Directly connected nodes (sharing an edge) are never merged into the same + * layer group as an additional safety check. + * + * Edge endpoints connected to shifted nodes are adjusted proportionally. + * Intermediate bend points are left unchanged — edge bundling or clipping + * will recalculate them afterwards. + */ +function alignLayerNodes( + nodes: PositionedNode[], + edges: PositionedEdge[], + direction: Direction +): void { + if (nodes.length === 0) return + + const isHorizontal = direction === 'LR' || direction === 'RL' + + // Build set of directly-connected node pairs. + // Nodes connected by an edge must not be merged into the same layer. + const connectedPairs = new Set() + for (const edge of edges) { + connectedPairs.add(`${edge.source}:${edge.target}`) + connectedPairs.add(`${edge.target}:${edge.source}`) + } + + // ELK's stagger creates small gaps between adjacent nodes in the same layer + // (typically edgeEdge spacing = 12px per routing channel). Adjacent layers + // are separated by at least layerSpacing (48px). We use single-linkage + // clustering: a node joins the current layer if the gap from the previous + // node (in sorted order) is within threshold, AND it has no direct edge to + // any node already in the layer. + const THRESHOLD = DEFAULTS.layerSpacing * 0.6 + + // Sort nodes by flow-axis position + const sorted = [...nodes].sort((a, b) => + isHorizontal ? a.x - b.x : a.y - b.y + ) + + const layers: PositionedNode[][] = [] + let currentLayer: PositionedNode[] = [sorted[0]!] + + for (let i = 1; i < sorted.length; i++) { + const pos = isHorizontal ? sorted[i]!.x : sorted[i]!.y + const prevPos = isHorizontal ? sorted[i - 1]!.x : sorted[i - 1]!.y + // Single-linkage: compare with previous node, not layer start + const gap = pos - prevPos + // Check if this node is connected to any node already in the current layer + const hasEdgeToLayer = currentLayer.some(n => + connectedPairs.has(`${n.id}:${sorted[i]!.id}`) + ) + if (gap <= THRESHOLD && !hasEdgeToLayer) { + currentLayer.push(sorted[i]!) + } else { + layers.push(currentLayer) + currentLayer = [sorted[i]!] + } + } + layers.push(currentLayer) + + // Snap each layer's nodes to the layer's center position + const deltas = new Map() // nodeId → shift amount + + for (const layer of layers) { + if (layer.length <= 1) continue + + const positions = layer.map(n => isHorizontal ? n.x : n.y) + const min = Math.min(...positions) + const max = Math.max(...positions) + if (max - min <= 1) continue // Already aligned + + // Use the center of the range as the snap target + const target = (min + max) / 2 + + for (const node of layer) { + const oldPos = isHorizontal ? node.x : node.y + const delta = target - oldPos + if (Math.abs(delta) > 0.5) { + if (isHorizontal) { + node.x = target + } else { + node.y = target + } + deltas.set(node.id, delta) + } + } + } + + if (deltas.size === 0) return + + // Build node lookup for edge adjustment + const nodeMap = new Map(nodes.map(n => [n.id, n])) + + // Adjust edge endpoints to match shifted node positions + for (const edge of edges) { + if (edge.points.length < 2) continue + + const srcDelta = deltas.get(edge.source) + const tgtDelta = deltas.get(edge.target) + + if (srcDelta != null) { + // Shift first point and any subsequent points in the initial vertical/horizontal run + const first = edge.points[0]! + if (isHorizontal) { + first.x += srcDelta + // Shift second point if it's part of a straight vertical exit + if (edge.points.length > 1 && edge.points[1]!.x === first.x - srcDelta) { + edge.points[1]!.x += srcDelta + } + } else { + first.y += srcDelta + if (edge.points.length > 1 && edge.points[1]!.y === first.y - srcDelta) { + edge.points[1]!.y += srcDelta + } + } + } + + if (tgtDelta != null) { + const last = edge.points[edge.points.length - 1]! + if (isHorizontal) { + last.x += tgtDelta + if (edge.points.length > 1) { + const prev = edge.points[edge.points.length - 2]! + if (prev.x === last.x - tgtDelta) prev.x += tgtDelta + } + } else { + last.y += tgtDelta + if (edge.points.length > 1) { + const prev = edge.points[edge.points.length - 2]! + if (prev.y === last.y - tgtDelta) prev.y += tgtDelta + } + } + } + } +} + +// ============================================================================ +// Edge bundling — merge fan-out / fan-in edge paths into shared trunks +// ============================================================================ + +/** + * Find all groups (outermost first) that geometrically contain the given point. + */ +function findGroupsContainingPoint( + x: number, y: number, + groups: PositionedGroup[] +): PositionedGroup[] { + const result: PositionedGroup[] = [] + for (const g of groups) { + if (x >= g.x && x <= g.x + g.width && y >= g.y && y <= g.y + g.height) { + result.push(g) + result.push(...findGroupsContainingPoint(x, y, g.children)) + } + } + return result +} + +/** + * If `junction` falls inside a group that doesn't contain the reference node, + * move it just outside the outermost such group boundary. + */ +function adjustJunctionForGroups( + junctionMain: number, // the junction coordinate along the flow axis (Y for TD, X for LR) + refX: number, // reference node center X (for finding its groups) + refY: number, // reference node center Y + groups: PositionedGroup[], + direction: Direction +): number { + const GAP = 12 + const isLR = direction === 'LR' + const isRL = direction === 'RL' + const isBT = direction === 'BT' + const isHorizontal = isLR || isRL + + // Groups containing the reference node + const refGroupIds = new Set(findGroupsContainingPoint(refX, refY, groups).map(g => g.id)) + + // Check where the junction point would be along the trunk + const probeX = isHorizontal ? junctionMain : refX + const probeY = isHorizontal ? refY : junctionMain + const junctionGroups = findGroupsContainingPoint(probeX, probeY, groups) + + // Find outermost group containing the junction but NOT the reference node + const crossingGroup = junctionGroups.find(g => !refGroupIds.has(g.id)) + if (!crossingGroup) return junctionMain + + // Move junction just outside this group + if (isLR) return crossingGroup.x - GAP + if (isRL) return crossingGroup.x + crossingGroup.width + GAP + if (isBT) return crossingGroup.y + crossingGroup.height + GAP + return crossingGroup.y - GAP // TD +} + +/** + * Bundle fan-out and fan-in edge paths so they share a common trunk segment. + * + * For fan-out (one source → N targets), all edges exit the source at the same + * point, travel along a shared trunk, then branch to their individual targets. + * The overlapping trunk segments render as a single visible line. + * + * Junction points are placed outside subgraph boundaries so branches split + * before entering a group, not inside it. + * + * Constraints: edges in a bundle must share the same style and have no labels. + * Self-loops and backward edges (against the graph direction) are excluded. + */ +function bundleEdgePaths( + edges: PositionedEdge[], + nodes: PositionedNode[], + groups: PositionedGroup[], + direction: Direction +): void { + const nodeMap = new Map(nodes.map(n => [n.id, n])) + const processed = new Set() + + const isLR = direction === 'LR' + const isRL = direction === 'RL' + const isBT = direction === 'BT' + const isHorizontal = isLR || isRL + + // --- Fan-out: group edges by shared source --- + const fanOutGroups = new Map() + for (const edge of edges) { + if (edge.source === edge.target) continue + if (!fanOutGroups.has(edge.source)) fanOutGroups.set(edge.source, []) + fanOutGroups.get(edge.source)!.push(edge) + } + + for (const [sourceId, group] of fanOutGroups) { + if (group.length < 2) continue + + const style = group[0]!.style + if (group.some(e => e.label || e.style !== style)) continue + + const source = nodeMap.get(sourceId) + if (!source) continue + + // Only bundle edges going in the forward direction + const forward = group.filter(e => { + const t = nodeMap.get(e.target) + if (!t) return false + if (isLR) return t.x > source.x + source.width + if (isRL) return t.x + t.width < source.x + if (isBT) return t.y + t.height < source.y + return t.y > source.y + source.height // TD/TB + }) + if (forward.length < 2) continue + + const targets = forward.map(e => ({ edge: e, node: nodeMap.get(e.target)! })) + const srcCX = source.x + source.width / 2 + const srcCY = source.y + source.height / 2 + + if (isHorizontal) { + const exitX = isLR ? source.x + source.width : source.x + const exitY = srcCY + + const nearestX = isLR + ? Math.min(...targets.map(t => t.node.x)) + : Math.max(...targets.map(t => t.node.x + t.node.width)) + let junctionX = exitX + (nearestX - exitX) / 2 + junctionX = adjustJunctionForGroups(junctionX, srcCX, srcCY, groups, direction) + + for (const { edge, node: target } of targets) { + const entryX = isLR ? target.x : target.x + target.width + const entryY = target.y + target.height / 2 + edge.points = [ + { x: exitX, y: exitY }, + { x: junctionX, y: exitY }, + { x: junctionX, y: entryY }, + { x: entryX, y: entryY }, + ] + processed.add(edge) + } + } else { + const exitX = srcCX + const exitY = isBT ? source.y : source.y + source.height + + const nearestY = isBT + ? Math.max(...targets.map(t => t.node.y + t.node.height)) + : Math.min(...targets.map(t => t.node.y)) + let junctionY = exitY + (nearestY - exitY) / 2 + junctionY = adjustJunctionForGroups(junctionY, srcCX, srcCY, groups, direction) + + for (const { edge, node: target } of targets) { + const entryX = target.x + target.width / 2 + const entryY = isBT ? target.y + target.height : target.y + edge.points = [ + { x: exitX, y: exitY }, + { x: exitX, y: junctionY }, + { x: entryX, y: junctionY }, + { x: entryX, y: entryY }, + ] + processed.add(edge) + } + } + } + + // --- Fan-in: group edges by shared target (skip already-bundled edges) --- + const fanInGroups = new Map() + for (const edge of edges) { + if (processed.has(edge) || edge.source === edge.target) continue + if (!fanInGroups.has(edge.target)) fanInGroups.set(edge.target, []) + fanInGroups.get(edge.target)!.push(edge) + } + + for (const [targetId, group] of fanInGroups) { + if (group.length < 2) continue + + const style = group[0]!.style + if (group.some(e => e.label || e.style !== style)) continue + + const target = nodeMap.get(targetId) + if (!target) continue + + const forward = group.filter(e => { + const s = nodeMap.get(e.source) + if (!s) return false + if (isLR) return s.x + s.width < target.x + if (isRL) return s.x > target.x + target.width + if (isBT) return s.y > target.y + target.height + return s.y + s.height < target.y // TD/TB + }) + if (forward.length < 2) continue + + const sources = forward.map(e => ({ edge: e, node: nodeMap.get(e.source)! })) + const tgtCX = target.x + target.width / 2 + const tgtCY = target.y + target.height / 2 + + if (isHorizontal) { + const entryX = isLR ? target.x : target.x + target.width + const entryY = tgtCY + + const farthestX = isLR + ? Math.max(...sources.map(s => s.node.x + s.node.width)) + : Math.min(...sources.map(s => s.node.x)) + let junctionX = farthestX + (entryX - farthestX) / 2 + junctionX = adjustJunctionForGroups(junctionX, tgtCX, tgtCY, groups, direction) + + for (const { edge, node: src } of sources) { + const exitX = isLR ? src.x + src.width : src.x + const exitY = src.y + src.height / 2 + edge.points = [ + { x: exitX, y: exitY }, + { x: junctionX, y: exitY }, + { x: junctionX, y: entryY }, + { x: entryX, y: entryY }, + ] + } + } else { + const entryX = tgtCX + const entryY = isBT ? target.y + target.height : target.y + + const farthestY = isBT + ? Math.min(...sources.map(s => s.node.y)) + : Math.max(...sources.map(s => s.node.y + s.node.height)) + let junctionY = farthestY + (entryY - farthestY) / 2 + junctionY = adjustJunctionForGroups(junctionY, tgtCX, tgtCY, groups, direction) + + for (const { edge, node: src } of sources) { + const exitX = src.x + src.width / 2 + const exitY = isBT ? src.y : src.y + src.height + edge.points = [ + { x: exitX, y: exitY }, + { x: exitX, y: junctionY }, + { x: entryX, y: junctionY }, + { x: entryX, y: entryY }, + ] + } + } + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Lay out a parsed MermaidGraph using ELK.js (synchronous). + * Returns a fully positioned graph ready for rendering. + */ +export function layoutGraphSync( + graph: MermaidGraph, + options: RenderOptions = {} +): PositionedGraph { + const opts = { ...DEFAULTS, ...options } + const elkGraph = mermaidToElk(graph, opts) + const result = elkLayoutSync(elkGraph) + return elkToPositioned(result, graph, DEFAULTS.mergeEdges) +} + +/** + * Convert MermaidGraph to ELK format (for benchmarking conversion overhead). + */ +export function convertToElkFormat( + graph: MermaidGraph, + options: RenderOptions = {} +): ElkNode { + const opts = { ...DEFAULTS, ...options } + return mermaidToElk(graph, opts) +} diff --git a/ui/vendor/beautiful-mermaid/layout.ts b/ui/vendor/beautiful-mermaid/layout.ts new file mode 100644 index 0000000..c6eff0e --- /dev/null +++ b/ui/vendor/beautiful-mermaid/layout.ts @@ -0,0 +1,8 @@ +/** + * Layout module for flowchart and state diagrams. + * + * Uses ELK.js for graph layout — battle-tested, full subgraph support, + * orthogonal edge routing, and direction overrides. + */ + +export { layoutGraphSync } from './layout-engine.ts' diff --git a/ui/vendor/beautiful-mermaid/multiline-utils.ts b/ui/vendor/beautiful-mermaid/multiline-utils.ts new file mode 100644 index 0000000..980f197 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/multiline-utils.ts @@ -0,0 +1,219 @@ +// ============================================================================ +// Multi-line Text Rendering Utilities +// +// Shared utilities for rendering multi-line text in SVG using elements. +// Supports inline formatting: , , , mapped to SVG attributes. +// Used across all diagram types (flowcharts, state, sequence, class, ER). +// ============================================================================ + +import { LINE_HEIGHT_RATIO } from './text-metrics.ts' + +/** + * Normalize label text: strip surrounding quotes, convert
tags and + * literal \n sequences to newline characters. Strips unsupported HTML tags + * but preserves formatting tags (, , , ) for SVG rendering. + */ +export function normalizeBrTags(label: string): string { + // Strip surrounding double quotes (Mermaid uses them for special chars in labels) + const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label + return unquoted + .replace(//gi, '\n') + .replace(/\\n/g, '\n') + .replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '') + // Markdown formatting → HTML tags (order matters: ** before *) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(?$1') + .replace(/~~(.+?)~~/g, '$1') +} + +/** + * Strip all inline formatting tags from text, keeping only plain text. + * Used for text measurement where tag characters shouldn't affect width. + */ +export function stripFormattingTags(text: string): string { + return text.replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '') +} + +/** + * Escape special XML characters in text content. + */ +export function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// ============================================================================ +// Inline formatting: , , , → SVG tspan attributes +// ============================================================================ + +interface StyledSegment { + text: string + bold: boolean + italic: boolean + underline: boolean + strikethrough: boolean +} + +/** Regex to match opening/closing formatting tags */ +const FORMAT_TAG_REGEX = /<(\/)?(?:(b|strong)|(i|em)|(u)|(s|del))\s*>/gi + +/** + * Parse a line of text into styled segments based on inline formatting tags. + * Supports nesting: `bold both bold`. + */ +function parseInlineFormatting(line: string): StyledSegment[] { + const segments: StyledSegment[] = [] + let bold = false, italic = false, underline = false, strikethrough = false + let lastIndex = 0 + + // Reset lastIndex for global regex + FORMAT_TAG_REGEX.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = FORMAT_TAG_REGEX.exec(line)) !== null) { + // Capture text before this tag + if (match.index > lastIndex) { + segments.push({ text: line.slice(lastIndex, match.index), bold, italic, underline, strikethrough }) + } + lastIndex = match.index + match[0].length + + const isClosing = Boolean(match[1]) + // match[2] = b|strong, match[3] = i|em, match[4] = u, match[5] = s|del + if (match[2]) bold = !isClosing + else if (match[3]) italic = !isClosing + else if (match[4]) underline = !isClosing + else if (match[5]) strikethrough = !isClosing + } + + // Remaining text after last tag + if (lastIndex < line.length) { + segments.push({ text: line.slice(lastIndex), bold, italic, underline, strikethrough }) + } + + return segments +} + +/** Check if a line contains any formatting tags */ +const HAS_FORMAT_TAGS = /<\/?(?:b|strong|i|em|u|s|del)\s*>/i + +/** + * Render a line's content as SVG, with inline formatting applied as tspan attributes. + * Returns raw SVG content (no wrapping tspan — caller provides positioning). + */ +function renderLineContent(line: string): string { + // Fast path: no formatting tags + if (!HAS_FORMAT_TAGS.test(line)) return escapeXml(line) + + const segments = parseInlineFormatting(line) + if (segments.length === 0) return '' + + // If all segments are unstyled, just escape + const allPlain = segments.every(s => !s.bold && !s.italic && !s.underline && !s.strikethrough) + if (allPlain) return segments.map(s => escapeXml(s.text)).join('') + + return segments.map(seg => { + const escaped = escapeXml(seg.text) + if (!seg.bold && !seg.italic && !seg.underline && !seg.strikethrough) return escaped + + const attrs: string[] = [] + if (seg.bold) attrs.push('font-weight="bold"') + if (seg.italic) attrs.push('font-style="italic"') + // SVG text-decoration can combine values + const deco: string[] = [] + if (seg.underline) deco.push('underline') + if (seg.strikethrough) deco.push('line-through') + if (deco.length) attrs.push(`text-decoration="${deco.join(' ')}"`) + + return `${escaped}` + }).join('') +} + +// ============================================================================ +// Multi-line text rendering +// ============================================================================ + +/** + * Render a multi-line text element with proper vertical centering. + * + * For single-line text, returns a simple element. + * For multi-line text (containing \n), returns with children. + * Inline formatting tags (, , , ) are rendered as SVG attributes. + * + * @param text - The text to render (may contain \n and formatting tags) + * @param cx - Center x coordinate + * @param cy - Center y coordinate + * @param fontSize - Font size in pixels + * @param attrs - Additional SVG attributes (e.g., 'text-anchor="middle" fill="var(--_text)"') + * @param baselineShift - Baseline shift for vertical alignment (default 0.35) + * @returns SVG text element string + */ +export function renderMultilineText( + text: string, + cx: number, + cy: number, + fontSize: number, + attrs: string, + baselineShift: number = 0.35 +): string { + const lines = text.split('\n') + + // Single line — simple text element + if (lines.length === 1) { + const dy = fontSize * baselineShift + return `${renderLineContent(text)}` + } + + // Multi-line — use tspan elements with vertical centering + const lineHeight = fontSize * LINE_HEIGHT_RATIO + // First line dy: shift up by (n-1)/2 line heights, then add baseline shift + const firstDy = -((lines.length - 1) / 2) * lineHeight + fontSize * baselineShift + + const tspans = lines.map((line, i) => { + const dy = i === 0 ? firstDy : lineHeight + return `${renderLineContent(line)}` + }).join('') + + return `${tspans}` +} + +/** + * Render a multi-line text element with a background rectangle (pill). + * + * Used for edge labels that need a background for readability. + * + * @param text - The text to render (may contain \n) + * @param cx - Center x coordinate + * @param cy - Center y coordinate + * @param textWidth - Pre-calculated text width (max line width) + * @param textHeight - Pre-calculated text height (lines × lineHeight) + * @param fontSize - Font size in pixels + * @param padding - Padding around text + * @param textAttrs - SVG attributes for the text element + * @param bgAttrs - SVG attributes for the background rect + * @returns SVG elements string (rect + text) + */ +export function renderMultilineTextWithBackground( + text: string, + cx: number, + cy: number, + textWidth: number, + textHeight: number, + fontSize: number, + padding: number, + textAttrs: string, + bgAttrs: string +): string { + const bgWidth = textWidth + padding * 2 + const bgHeight = textHeight + padding * 2 + + const rect = `` + + const textEl = renderMultilineText(text, cx, cy, fontSize, textAttrs) + + return `${rect}\n${textEl}` +} diff --git a/ui/vendor/beautiful-mermaid/parser.ts b/ui/vendor/beautiful-mermaid/parser.ts new file mode 100644 index 0000000..685835e --- /dev/null +++ b/ui/vendor/beautiful-mermaid/parser.ts @@ -0,0 +1,645 @@ +import type { MermaidGraph, MermaidNode, MermaidEdge, MermaidSubgraph, Direction, NodeShape, EdgeStyle } from './types.ts' +import { normalizeBrTags } from './multiline-utils.ts' + +// ============================================================================ +// Mermaid parser — flowcharts and state diagrams +// +// Supports: +// Flowcharts: graph TD / flowchart LR +// State diagrams: stateDiagram-v2 +// +// Line-by-line regex approach — the grammar is regular enough +// that we don't need a grammar generator or full parser combinator. +// ============================================================================ + +/** + * Parse Mermaid text into a logical graph structure. + * Auto-detects diagram type (flowchart or state diagram). + * Throws on invalid/unsupported input. + */ +export function parseMermaid(text: string): MermaidGraph { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) + + if (lines.length === 0) { + throw new Error('Empty mermaid diagram') + } + + // Detect diagram type from header + const header = lines[0]! + + // State diagram: "stateDiagram-v2" or "stateDiagram" + if (/^stateDiagram(-v2)?\s*$/i.test(header)) { + return parseStateDiagram(lines) + } + + // Flowchart: "graph TD" or "flowchart LR" + return parseFlowchart(lines) +} + +// ============================================================================ +// Flowchart parser +// ============================================================================ + +function parseFlowchart(lines: string[]): MermaidGraph { + const headerMatch = lines[0]!.match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i) + if (!headerMatch) { + throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`) + } + + const direction = headerMatch[1]!.toUpperCase() as Direction + + const graph: MermaidGraph = { + direction, + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Subgraph stack for nested subgraphs. + const subgraphStack: MermaidSubgraph[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- classDef: `classDef name prop:val,prop:val` --- + const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/) + if (classDefMatch) { + const name = classDefMatch[1]! + const propsStr = classDefMatch[2]! + const props = parseStyleProps(propsStr) + graph.classDefs.set(name, props) + continue + } + + // --- class assignment: `class A,B className` --- + const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/) + if (classAssignMatch) { + const nodeIds = classAssignMatch[1]!.split(',').map(s => s.trim()) + const className = classAssignMatch[2]! + for (const id of nodeIds) { + graph.classAssignments.set(id, className) + } + continue + } + + // --- style statement: `style A,B fill:#f00,stroke:#333` --- + const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/) + if (styleMatch) { + const nodeIds = styleMatch[1]!.split(',').map(s => s.trim()) + const props = parseStyleProps(styleMatch[2]!) + for (const id of nodeIds) { + graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props }) + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- direction override inside subgraph: `direction LR` --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch && subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + continue + } + + // --- subgraph start: `subgraph Label` or `subgraph id [Label]` --- + const subgraphMatch = line.match(/^subgraph\s+(.+)$/) + if (subgraphMatch) { + const rest = subgraphMatch[1]!.trim() + // Check for "subgraph id [Label]" form + // ID can contain hyphens (e.g. "us-east"), so use [\w-]+ not \w+ + const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/) + let id: string + let label: string + if (bracketMatch) { + id = bracketMatch[1]! + label = normalizeBrTags(bracketMatch[2]!) + } else { + // Use the label text as id (slugified) + label = normalizeBrTags(rest) + id = rest.replace(/\s+/g, '_').replace(/[^\w]/g, '') + } + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + subgraphStack.push(sg) + continue + } + + // --- subgraph end --- + if (line === 'end') { + const completed = subgraphStack.pop() + if (completed) { + if (subgraphStack.length > 0) { + subgraphStack[subgraphStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- Edge/node definitions --- + parseEdgeLine(line, graph, subgraphStack) + } + + return graph +} + +// ============================================================================ +// State diagram parser +// +// Supported syntax: +// stateDiagram-v2 +// s1 : Description +// state "Description" as s1 +// s1 --> s2 : label +// [*] --> s1 (start pseudostate) +// s1 --> [*] (end pseudostate) +// state CompositeState { +// inner1 --> inner2 +// } +// ============================================================================ + +function parseStateDiagram(lines: string[]): MermaidGraph { + const graph: MermaidGraph = { + direction: 'TD', + nodes: new Map(), + edges: [], + subgraphs: [], + classDefs: new Map(), + classAssignments: new Map(), + nodeStyles: new Map(), + linkStyles: new Map(), + } + + // Track composite state nesting (like subgraphs) + const compositeStack: MermaidSubgraph[] = [] + // Track all composite state IDs to avoid creating duplicate nodes + const compositeStateIds = new Set() + // Counter for unique [*] pseudostate IDs + let startCount = 0 + let endCount = 0 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + + // --- direction override --- + const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i) + if (dirMatch) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction + } else { + graph.direction = dirMatch[1]!.toUpperCase() as Direction + } + continue + } + + // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` --- + const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/) + if (linkStyleMatch) { + const target = linkStyleMatch[1]!.trim() + const props = parseStyleProps(linkStyleMatch[2]!) + if (target === 'default') { + graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props }) + } else { + const indices = target.split(',').map(s => parseInt(s.trim(), 10)) + for (const idx of indices) { + if (!isNaN(idx)) { + graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props }) + } + } + } + continue + } + + // --- composite state start: `state CompositeState {` --- + const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u) + if (compositeMatch) { + const label = compositeMatch[1] ?? compositeMatch[2]! + const id = compositeMatch[2]! + const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] } + compositeStack.push(sg) + // Track this ID to avoid creating a duplicate node for the composite state + compositeStateIds.add(id) + // Remove any existing node that was created when parsing transitions before + // this composite state definition (e.g., "A --> Processing" before "state Processing {") + graph.nodes.delete(id) + continue + } + + // --- composite state end --- + if (line === '}') { + const completed = compositeStack.pop() + if (completed) { + if (compositeStack.length > 0) { + compositeStack[compositeStack.length - 1]!.children.push(completed) + } else { + graph.subgraphs.push(completed) + } + } + continue + } + + // --- state alias: `state "Description" as s1` (without brace) --- + const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u) + if (stateAliasMatch) { + const label = normalizeBrTags(stateAliasMatch[1]!) + const id = stateAliasMatch[2]! + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + + // --- transition: `s1 --> s2` or `s1 --> s2 : label` or `[*] --> s1` --- + const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u) + if (transitionMatch) { + let sourceId = transitionMatch[1]! + let targetId = transitionMatch[3]! + const rawTransitionLabel = transitionMatch[4]?.trim() + const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : undefined + + // Handle [*] pseudostates — each occurrence gets a unique ID + if (sourceId === '[*]') { + startCount++ + sourceId = `_start${startCount > 1 ? startCount : ''}` + registerStateNode(graph, compositeStack, { id: sourceId, label: '', shape: 'state-start' }) + } else if (!compositeStateIds.has(sourceId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, sourceId) + } + + if (targetId === '[*]') { + endCount++ + targetId = `_end${endCount > 1 ? endCount : ''}` + registerStateNode(graph, compositeStack, { id: targetId, label: '', shape: 'state-end' }) + } else if (!compositeStateIds.has(targetId)) { + // Only create a node if this isn't a composite state + ensureStateNode(graph, compositeStack, targetId) + } + + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style: 'solid', + hasArrowStart: false, + hasArrowEnd: true, + }) + continue + } + + // --- state description: `s1 : Description` --- + const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u) + if (stateDescMatch) { + const id = stateDescMatch[1]! + const label = normalizeBrTags(stateDescMatch[2]!.trim()) + registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' }) + continue + } + } + + return graph +} + +/** Register a state node and track in composite state if applicable */ +function registerStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(node.id)) { + current.nodeIds.push(node.id) + } + } +} + +/** Ensure a state node exists with default rounded shape */ +function ensureStateNode( + graph: MermaidGraph, + compositeStack: MermaidSubgraph[], + id: string +): void { + if (!graph.nodes.has(id)) { + registerStateNode(graph, compositeStack, { id, label: id, shape: 'rounded' }) + } else { + // Track in composite if applicable + if (compositeStack.length > 0) { + const current = compositeStack[compositeStack.length - 1]! + if (!current.nodeIds.includes(id)) { + current.nodeIds.push(id) + } + } + } +} + +// ============================================================================ +// Shared utilities +// ============================================================================ + +/** Parse "fill:#f00,stroke:#333" style property strings into a Record */ +function parseStyleProps(propsStr: string): Record { + // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`) + const cleaned = propsStr.replace(/;\s*$/, '') + const props: Record = {} + for (const pair of cleaned.split(',')) { + const colonIdx = pair.indexOf(':') + if (colonIdx > 0) { + const key = pair.slice(0, colonIdx).trim() + const val = pair.slice(colonIdx + 1).trim() + if (key && val) { + props[key] = val + } + } + } + return props +} + +// ============================================================================ +// Flowchart edge line parser +// +// Handles chained edges like: A[Label] --> B(Label) -.-> C{Label} +// Also handles & parallel links: A & B --> C & D +// ============================================================================ + +/** + * Arrow regex — matches all arrow operators with optional labels. + * + * Supported operators: + * --> --- solid arrow / solid line + * -.-> -.- dotted arrow / dotted line + * ==> === thick arrow / thick line + * <--> <-.-> <==> bidirectional variants + * + * Optional label: -->|label text| + */ +const ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/ + +/** + * Text-embedded label regex — matches "-- label -->", "-. label .->", "== label ==>" syntax. + * Tried as fallback when ARROW_REGEX doesn't match. + * + * Based on PR #36 by @liuxiaopai-ai (https://github.com/lukilabs/beautiful-mermaid/pull/36) + */ +const TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/ + +/** + * Node shape patterns — ordered from most specific delimiters to least. + * Multi-char delimiters must be tried before single-char to avoid false matches. + */ +const NODE_PATTERNS: Array<{ regex: RegExp; shape: NodeShape }> = [ + // Triple delimiters (must be first) + { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: 'doublecircle' }, // A(((text))) + + // Double delimiters with mixed brackets + { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: 'stadium' }, // A([text]) + { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: 'circle' }, // A((text)) + { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: 'subroutine' }, // A[[text]] + { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: 'cylinder' }, // A[(text)] + + // Trapezoid variants — must come before plain [text] + { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: 'trapezoid' }, // A[/text\] + { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: 'trapezoid-alt' }, // A[\text/] + + // Asymmetric flag shape + { regex: /^([\w-]+)>(.+?)\]/, shape: 'asymmetric' }, // A>text] + + // Double curly braces (hexagon) — must come before single {text} + { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: 'hexagon' }, // A{{text}} + + // Single-char delimiters (last — most common, least specific) + { regex: /^([\w-]+)\[(.+?)\]/, shape: 'rectangle' }, // A[text] + { regex: /^([\w-]+)\((.+?)\)/, shape: 'rounded' }, // A(text) + { regex: /^([\w-]+)\{(.+?)\}/, shape: 'diamond' }, // A{text} +] + +/** Regex for a bare node reference (just an ID, no shape brackets) */ +const BARE_NODE_REGEX = /^([\w-]+)/ + +/** Regex for ::: class shorthand suffix — matches :::className immediately after a node */ +const CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/ + +/** + * Parse a line that contains node definitions and edges. + * Handles chaining: A --> B --> C produces edges A→B and B→C. + * Handles parallel links: A & B --> C & D produces 4 edges. + */ +function parseEdgeLine( + line: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): void { + let remaining = line.trim() + + // Parse the first node group (possibly with & separators) + const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!firstGroup || firstGroup.ids.length === 0) return + + remaining = firstGroup.remaining.trim() + let prevGroupIds = firstGroup.ids + + // Parse arrow + node-group pairs until the line is exhausted + while (remaining.length > 0) { + let hasArrowStart: boolean + let style: EdgeStyle + let hasArrowEnd: boolean + let edgeLabel: string | undefined + + const arrowMatch = remaining.match(ARROW_REGEX) + if (arrowMatch) { + hasArrowStart = Boolean(arrowMatch[1]) + const arrowOp = arrowMatch[2]! + const rawEdgeLabel = arrowMatch[3]?.trim() + edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : undefined + remaining = remaining.slice(arrowMatch[0].length).trim() + style = arrowStyleFromOp(arrowOp) + hasArrowEnd = arrowOp.endsWith('>') + } else { + // Fallback: text-embedded label syntax (-- Yes -->, -. Maybe .->, == Sure ==>) + const textMatch = remaining.match(TEXT_ARROW_REGEX) + if (!textMatch) break + hasArrowStart = Boolean(textMatch[1]) + const rawLabel = textMatch[3]!.trim() + edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : undefined + const openOp = textMatch[2]! + const closeOp = textMatch[4]! + remaining = remaining.slice(textMatch[0].length).trim() + style = textArrowStyleFromOps(openOp, closeOp) + hasArrowEnd = closeOp.endsWith('>') + } + + // Parse the next node group + const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack) + if (!nextGroup || nextGroup.ids.length === 0) break + + remaining = nextGroup.remaining.trim() + + // Emit Cartesian product of edges: every source × every target + for (const sourceId of prevGroupIds) { + for (const targetId of nextGroup.ids) { + graph.edges.push({ + source: sourceId, + target: targetId, + label: edgeLabel, + style, + hasArrowStart, + hasArrowEnd, + }) + } + } + + prevGroupIds = nextGroup.ids + } +} + +interface ConsumedNodeGroup { + ids: string[] + remaining: string +} + +/** + * Consume one or more nodes separated by `&`. + * E.g. "A & B & C --> ..." returns ids: ['A', 'B', 'C'] + */ +function consumeNodeGroup( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNodeGroup | null { + const first = consumeNode(text, graph, subgraphStack) + if (!first) return null + + const ids = [first.id] + let remaining = first.remaining.trim() + + // Check for & separators + while (remaining.startsWith('&')) { + remaining = remaining.slice(1).trim() + const next = consumeNode(remaining, graph, subgraphStack) + if (!next) break + ids.push(next.id) + remaining = next.remaining.trim() + } + + return { ids, remaining } +} + +interface ConsumedNode { + id: string + remaining: string +} + +/** + * Try to consume a node definition from the start of `text`. + * If the node has a shape+label (e.g. A[Text]), it's registered in the graph. + * If it's a bare reference (e.g. A), we look it up or create a default. + * Also handles ::: class shorthand suffix. + */ +function consumeNode( + text: string, + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[] +): ConsumedNode | null { + let id: string | null = null + let remaining: string = text + + // Try each node pattern (shape-qualified) + for (const { regex, shape } of NODE_PATTERNS) { + const match = text.match(regex) + if (match) { + id = match[1]! + const label = normalizeBrTags(match[2]!) + registerNode(graph, subgraphStack, { id, label, shape }) + remaining = text.slice(match[0].length) + break + } + } + + // Bare node reference — only register if node doesn't exist yet. + // If it already exists, do NOT track it in the current subgraph; + // nodes belong to the subgraph where they're first defined. + if (id === null) { + const bareMatch = text.match(BARE_NODE_REGEX) + if (bareMatch) { + id = bareMatch[1]! + if (!graph.nodes.has(id)) { + registerNode(graph, subgraphStack, { id, label: id, shape: 'rectangle' }) + } + remaining = text.slice(bareMatch[0].length) + } + } + + if (id === null) return null + + // Check for ::: class shorthand suffix immediately after the node + const classMatch = remaining.match(CLASS_SHORTHAND_REGEX) + if (classMatch) { + graph.classAssignments.set(id, classMatch[1]!) + remaining = remaining.slice(classMatch[0].length) + } + + return { id, remaining } +} + +/** Register a node in the graph and track it in the current subgraph */ +function registerNode( + graph: MermaidGraph, + subgraphStack: MermaidSubgraph[], + node: MermaidNode +): void { + const isNew = !graph.nodes.has(node.id) + if (isNew) { + graph.nodes.set(node.id, node) + } + trackInSubgraph(subgraphStack, node.id) +} + +/** Add node ID to the innermost subgraph if we're inside one */ +function trackInSubgraph(subgraphStack: MermaidSubgraph[], nodeId: string): void { + if (subgraphStack.length > 0) { + const current = subgraphStack[subgraphStack.length - 1]! + if (!current.nodeIds.includes(nodeId)) { + current.nodeIds.push(nodeId) + } + } +} + +/** Map arrow operator string to edge style (ignoring direction) */ +function arrowStyleFromOp(op: string): EdgeStyle { + if (op === '-.->') return 'dotted' + if (op === '-.-') return 'dotted' + if (op === '==>') return 'thick' + if (op === '===') return 'thick' + // '-->'' and '---' are both solid + return 'solid' +} + +/** Map text-embedded arrow open/close operators to edge style */ +function textArrowStyleFromOps(openOp: string, closeOp: string): EdgeStyle { + if (openOp === '-.' || closeOp === '.->' || closeOp === '-.-') return 'dotted' + if (openOp === '==' || closeOp === '==>' || closeOp === '===') return 'thick' + return 'solid' +} diff --git a/ui/vendor/beautiful-mermaid/renderer.ts b/ui/vendor/beautiful-mermaid/renderer.ts new file mode 100644 index 0000000..33f385c --- /dev/null +++ b/ui/vendor/beautiful-mermaid/renderer.ts @@ -0,0 +1,667 @@ +import type { PositionedGraph, PositionedNode, PositionedEdge, PositionedGroup, Point } from './types.ts' +import type { DiagramColors } from './theme.ts' +import { svgOpenTag, buildStyleBlock } from './theme.ts' +import { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, ARROW_HEAD, KAWAII, estimateTextWidth, TEXT_BASELINE_SHIFT } from './styles.ts' +import { measureMultilineText } from './text-metrics.ts' +import { renderMultilineText, renderMultilineTextWithBackground, escapeXml } from './multiline-utils.ts' + +// ============================================================================ +// SVG renderer — converts a PositionedGraph into an SVG string. +// +// Pure string concatenation, no DOM manipulation. +// Renders back-to-front: groups → edges → arrow heads → edge labels → nodes → node labels. +// +// All colors are referenced via CSS custom properties (var(--_xxx)) defined +// in the ', + ].join('\n') +} + +/** + * Build the SVG opening tag with CSS variables set as inline styles. + * Only includes optional variables that are actually provided — unset ones + * will fall back to the color-mix() derivations in the ` + + return { style, defs: '' } +} + + +// ============================================================================ +// Bar path with all corners rounded +// ============================================================================ + +function roundedTopBarPath(x: number, y: number, w: number, h: number, radius: number): string { + const rr = Math.min(radius, w / 2, h / 2) + if (rr <= 0) { + return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z` + } + return [ + `M${r(x)},${r(y + rr)}`, // start below top-left + `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left + `L${r(x + w - rr)},${r(y)}`, // top edge + `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right + `L${r(x + w)},${r(y + h - rr)}`, // right edge + `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right + `L${r(x + rr)},${r(y + h)}`, // bottom edge + `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left + 'Z', + ].join(' ') +} + +// ============================================================================ +// Bar path with all corners rounded (for horizontal charts) +// ============================================================================ + +function roundedRightBarPath(x: number, y: number, w: number, h: number, radius: number): string { + const rr = Math.min(radius, w / 2, h / 2) + if (rr <= 0) { + return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z` + } + return [ + `M${r(x + rr)},${r(y)}`, // start after top-left + `L${r(x + w - rr)},${r(y)}`, // top edge + `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right + `L${r(x + w)},${r(y + h - rr)}`, // right edge + `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right + `L${r(x + rr)},${r(y + h)}`, // bottom edge + `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left + `L${r(x)},${r(y + rr)}`, // left edge + `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left + 'Z', + ].join(' ') +} + +// ============================================================================ +// Smooth line interpolation — Natural cubic spline +// +// Computes the mathematically smoothest curve through all data points by +// minimizing total curvature (integrated second derivative). Treats y as a +// function of x, so the curve can never go backwards. +// +// Algorithm: tridiagonal system for second derivatives (Thomas algorithm), +// then convert each cubic segment to SVG cubic Bezier commands. +// ============================================================================ + +function smoothCurvePath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return '' + if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}` + if (points.length === 2) { + return `M${r(points[0]!.x)},${r(points[0]!.y)} L${r(points[1]!.x)},${r(points[1]!.y)}` + } + + const n = points.length + + // 1. Interval widths and secant slopes + const h: number[] = [] + const delta: number[] = [] + for (let i = 0; i < n - 1; i++) { + h.push(points[i + 1]!.x - points[i]!.x) + delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!) + } + + // 2. Solve tridiagonal system for second derivatives c[] (natural boundary: c[0] = c[n-1] = 0) + const c = new Array(n).fill(0) + if (n > 2) { + // Forward elimination + const cp = new Array(n).fill(0) // modified upper diagonal + const dp = new Array(n).fill(0) // modified right-hand side + for (let i = 1; i < n - 1; i++) { + const diag = 2 * (h[i - 1]! + h[i]!) + const rhs = 3 * (delta[i]! - delta[i - 1]!) + if (i === 1) { + cp[i] = h[i]! / diag + dp[i] = rhs / diag + } else { + const w = diag - h[i - 1]! * cp[i - 1]! + cp[i] = h[i]! / w + dp[i] = (rhs - h[i - 1]! * dp[i - 1]!) / w + } + } + // Back substitution + for (let i = n - 2; i >= 1; i--) { + c[i] = dp[i]! - cp[i]! * c[i + 1]! + } + } + + // 3. Compute first derivatives (slopes) at each knot + const slopes = new Array(n).fill(0) + for (let i = 0; i < n - 1; i++) { + slopes[i] = delta[i]! - h[i]! * (2 * c[i]! + c[i + 1]!) / 3 + } + // Slope at last point: derivative of last segment at its end + slopes[n - 1] = delta[n - 2]! + h[n - 2]! * (c[n - 2]!) / 3 + + // 4. Convert to cubic Bezier — control points strictly between endpoints in x + let path = `M${r(points[0]!.x)},${r(points[0]!.y)}` + for (let i = 0; i < n - 1; i++) { + const seg = h[i]! / 3 + const cp1x = points[i]!.x + seg + const cp1y = points[i]!.y + slopes[i]! * seg + const cp2x = points[i + 1]!.x - seg + const cp2y = points[i + 1]!.y - slopes[i + 1]! * seg + path += ` C${r(cp1x)},${r(cp1y)} ${r(cp2x)},${r(cp2y)} ${r(points[i + 1]!.x)},${r(points[i + 1]!.y)}` + } + + return path +} + +// ============================================================================ +// Tooltip rendering +// ============================================================================ + +/** + * Multi-value tooltip: category label on top, each series value below with legend text label. + */ +function multiTooltipAbove(cx: number, topY: number, label: string, entries: Array<{ text: string; legendLabel: string }>): string { + const lineH = 20 + const padY = 6 + const labelGap = 10 + const headingW = estimateTextWidth(label, TIP.fontSize, 600) + const maxRowW = Math.max(...entries.map(e => { + const legendW = estimateTextWidth(e.legendLabel, TIP.fontSize, TIP.fontWeight) + const valW = estimateTextWidth(e.text, TIP.fontSize, TIP.fontWeight) + return legendW + labelGap + valW + })) + const bgW = Math.max(headingW, maxRowW) + TIP.padX * 2 + const bgH = padY + lineH + entries.length * lineH + padY + + const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize) + const bgX = cx - bgW / 2 + + const ptrX = cx + const ptrY = tipY + bgH + const ps = TIP.pointerSize + const pointer = `` + + let svg = `` + svg += pointer + + // Category label (bold, centered) + let textY = tipY + padY + lineH / 2 + svg += `${escapeXml(label)}` + + // Value lines: legend label left-aligned, value right-aligned + const rowLeft = bgX + TIP.padX + const rowRight = bgX + bgW - TIP.padX + for (const entry of entries) { + textY += lineH + svg += `${escapeXml(entry.legendLabel)}` + svg += `${escapeXml(entry.text)}` + } + + return svg +} + +function tooltipAbove(cx: number, topY: number, text: string): string { + const textW = estimateTextWidth(text, TIP.fontSize, TIP.fontWeight) + const bgW = textW + TIP.padX * 2 + const bgH = TIP.height + const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize) + const bgX = cx - bgW / 2 + const textX = cx + const textY = tipY + bgH / 2 + + const ptrX = cx + const ptrY = tipY + bgH + const ps = TIP.pointerSize + const pointer = `` + + return ( + `` + + pointer + + `${escapeXml(text)}` + ) +} + +function formatTipValue(v: number): string { + if (Number.isInteger(v)) return v.toLocaleString('en-US') + return v.toFixed(Math.abs(v) < 10 ? 1 : 0) +} + +function r(n: number): string { + return String(Math.round(n * 10) / 10) +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + + diff --git a/ui/vendor/beautiful-mermaid/xychart/types.ts b/ui/vendor/beautiful-mermaid/xychart/types.ts new file mode 100644 index 0000000..920c981 --- /dev/null +++ b/ui/vendor/beautiful-mermaid/xychart/types.ts @@ -0,0 +1,150 @@ +// ============================================================================ +// XY Chart types +// +// Models the parsed and positioned representations of a Mermaid xychart-beta +// diagram. Supports bar charts, line charts, and combinations with categorical +// or numeric x-axes. +// ============================================================================ + +/** Parsed XY chart — logical structure from mermaid text */ +export interface XYChart { + /** Optional chart title */ + title?: string + /** Chart orientation: vertical (default) or horizontal */ + horizontal: boolean + /** X-axis configuration */ + xAxis: XYAxis + /** Y-axis configuration */ + yAxis: XYAxis + /** Data series (bar and/or line) */ + series: XYChartSeries[] +} + +/** Axis configuration — categorical (labels) or numeric (range) */ +export interface XYAxis { + /** Optional axis title/label */ + title?: string + /** Categorical labels (e.g., ["jan", "feb", "mar"]) — mutually exclusive with range */ + categories?: string[] + /** Numeric range — mutually exclusive with categories */ + range?: { min: number; max: number } +} + +/** A single data series (bar or line) */ +export interface XYChartSeries { + /** Series type */ + type: 'bar' | 'line' + /** Data values — one per category, or evenly spaced across numeric range */ + data: number[] +} + +// ============================================================================ +// Positioned XY chart — ready for SVG rendering +// ============================================================================ + +export interface PositionedXYChart { + width: number + height: number + /** Whether this is a horizontal (rotated) chart */ + horizontal?: boolean + /** Title text and position (if present) */ + title?: PositionedTitle + /** Positioned x-axis with tick marks and labels */ + xAxis: PositionedAxis + /** Positioned y-axis with tick marks and labels */ + yAxis: PositionedAxis + /** The plot area bounds (inside axes) */ + plotArea: PlotArea + /** Positioned bar groups */ + bars: PositionedBar[] + /** Positioned line polylines */ + lines: PositionedLine[] + /** Horizontal grid lines for readability */ + gridLines: GridLine[] + /** Legend items (shown when multiple series) */ + legend: LegendItem[] +} + +export interface LegendItem { + /** Display label */ + label: string + /** Position of the swatch/icon */ + x: number + y: number + /** Series type determines swatch shape (rect for bar, line+dot for line) */ + type: 'bar' | 'line' + /** Series index within its type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series (for unified color assignment) */ + colorIndex: number +} + +export interface PositionedTitle { + text: string + x: number + y: number +} + +export interface PositionedAxis { + /** Optional axis title text and position */ + title?: { text: string; x: number; y: number; rotate?: number } + /** Tick positions along the axis */ + ticks: AxisTick[] + /** Axis line: start and end coordinates */ + line: { x1: number; y1: number; x2: number; y2: number } +} + +export interface AxisTick { + /** Label text for this tick */ + label: string + /** Position of the tick mark on the axis */ + x: number + y: number + /** End of the tick mark (short perpendicular line) */ + tx: number + ty: number + /** Label anchor position */ + labelX: number + labelY: number + /** Text anchor for label */ + textAnchor: 'start' | 'middle' | 'end' +} + +export interface PlotArea { + x: number + y: number + width: number + height: number +} + +export interface PositionedBar { + /** Bar rectangle in SVG coordinates */ + x: number + y: number + width: number + height: number + /** Original data value */ + value: number + /** Category label for this bar (e.g. "Jan") */ + label?: string + /** Series index within bar type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface PositionedLine { + /** Polyline points */ + points: Array<{ x: number; y: number; value: number; label?: string }> + /** Series index within line type (for layout grouping) */ + seriesIndex: number + /** Global color index across all series */ + colorIndex: number +} + +export interface GridLine { + x1: number + y1: number + x2: number + y2: number +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index ce8f191..ee2f0b5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,6 +1,15 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; export default defineConfig({ root: "ui", + resolve: { + alias: { + // Vendored renderer (ui/vendor/beautiful-mermaid); typed via ui/types. + "beautiful-mermaid-axp": fileURLToPath( + new URL("./vendor/beautiful-mermaid/index.ts", import.meta.url), + ), + }, + }, build: { outDir: "../dist/ui", emptyOutDir: true, target: "es2022" }, }); From 85ffeb2d54677f71d25d384d3122acb2b80fc25f Mon Sep 17 00:00:00 2001 From: maceip Date: Sun, 6 Sep 2026 10:52:38 -0700 Subject: [PATCH 2/2] Measure diagram labels with Pretext and wrap long ones into balanced lines beautiful-mermaid sized nodes from a character-width table for Inter and could not wrap a label, so a wordy node became a very wide box. Two hooks in the vendored renderer, no-ops outside a browser: setTextMeasurer for exact single-line widths in the page's real font, and setLabelWrapper, which breaks node labels over 190px (edge labels over 140px) into lines and then narrows the width by binary search until one more line would be needed, so lines come out balanced. ui/src/diagram-text.ts implements both with @chenglou/pretext (MIT, canvas-measured, off the DOM); Diagram.tsx installs them. Review samples and the demo fixture gain a long label. --- THIRD_PARTY_NOTICES.md | 3 + docs/design/diagrams.md | 21 ++++++ docs/design/diagrams/review.mmd | 2 +- docs/design/diagrams/review.svg | 26 +++---- package-lock.json | 8 +++ package.json | 1 + scripts/ui-notices.mjs | 1 + test/workspace-fixture.ts | 2 +- ui/src/Diagram.tsx | 11 ++- ui/src/diagram-text.ts | 79 +++++++++++++++++++++ ui/types/beautiful-mermaid-axp.d.ts | 15 ++++ ui/vendor/beautiful-mermaid/index.ts | 23 ++++++ ui/vendor/beautiful-mermaid/text-metrics.ts | 16 +++++ 13 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 ui/src/diagram-text.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9b11b14..c994786 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,6 +22,9 @@ its license sits beside the source. It depends on ELK (Eclipse Layout Kernel, EPL-2.0: https://github.com/kieler/elkjs) and entities (BSD-2-Clause), whose texts the UI build collects. +- Pretext (`@chenglou/pretext`), Pretext contributors, MIT: + https://github.com/chenglou/pretext. Measures diagram labels with the real + font and wraps long ones into balanced lines. - DM Sans and IBM Plex Mono font packages, SIL Open Font License 1.1: https://fontsource.org/fonts/dm-sans and https://fontsource.org/fonts/ibm-plex-mono diff --git a/docs/design/diagrams.md b/docs/design/diagrams.md index 1ec71ae..3adfe9d 100644 --- a/docs/design/diagrams.md +++ b/docs/design/diagrams.md @@ -57,6 +57,27 @@ Colours come from `ui/src/diagram-theme.ts` and mirror the workspace tokens without rewriting upstream's (which fails only `noUnusedLocals`). - `scripts/design/render-diagrams.mts` regenerates the sample SVGs. +## Real text metrics (Pretext) + +beautiful-mermaid sizes nodes from a per-character width table calibrated +for Inter and cannot wrap a long label, so a wordy node became a very wide +box. [Pretext](https://github.com/chenglou/pretext) (MIT) measures text with +the page's real font through canvas, off the DOM, and lays out lines itself. +Two hooks were added to the vendored renderer, both no-ops outside a browser: + +- `setTextMeasurer`: exact single-line widths, so boxes fit AXP Runde rather + than an estimate of Inter. +- `setLabelWrapper`: before layout, any node label wider than 190px (edge + label: 140px) is broken into lines; a binary search then narrows the width + until one more line would be needed, so the lines come out balanced + ("Checkpoint saved with / the bundle and patch") instead of one long and one + short. + +`ui/src/diagram-text.ts` implements both with Pretext and installs them from +`Diagram.tsx`. The Node-side sample renderer keeps the estimates. Evaluated +and kept for this one job; it was not adopted for prose (Justif already sets +paragraphs in the DOM) or for the family photo (no text layout there). + ## Not done yet - Sequence, class and ER diagrams render through their own sub-renderers and diff --git a/docs/design/diagrams/review.mmd b/docs/design/diagrams/review.mmd index 8178dd0..9b8838e 100644 --- a/docs/design/diagrams/review.mmd +++ b/docs/design/diagrams/review.mmd @@ -1,6 +1,6 @@ graph LR subgraph Contributor - A[Agent edits worktree] --> B[Checkpoint bundle] + A[Agent edits the worktree and runs the repository tests before saving] --> B[Checkpoint bundle] B --> C>Sign manifest] end subgraph Maintainer diff --git a/docs/design/diagrams/review.svg b/docs/design/diagrams/review.svg index e0b7e9a..e973f03 100644 --- a/docs/design/diagrams/review.svg +++ b/docs/design/diagrams/review.svg @@ -1,4 +1,4 @@ - +
- - - Agent edits worktree + + + Agent edits the worktree and runs the repository tests before saving - - Checkpoint bundle + + Checkpoint bundle - - Sign manifest + + Sign manifest diff --git a/package-lock.json b/package-lock.json index 443b243..9b92dca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", + "@chenglou/pretext": "^0.0.8", "@eslint/js": "^10.0.0", "@fontsource-variable/dm-sans": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0", @@ -108,6 +109,13 @@ "keyv": "^5.6.0" } }, + "node_modules/@chenglou/pretext": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.8.tgz", + "integrity": "sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", diff --git a/package.json b/package.json index 93eb2c2..058d63c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", + "@chenglou/pretext": "^0.0.8", "@eslint/js": "^10.0.0", "@fontsource-variable/dm-sans": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0", diff --git a/scripts/ui-notices.mjs b/scripts/ui-notices.mjs index 7821d20..b976769 100644 --- a/scripts/ui-notices.mjs +++ b/scripts/ui-notices.mjs @@ -14,6 +14,7 @@ const roots = [ "@fontsource/ibm-plex-mono", "elkjs", "entities", + "@chenglou/pretext", ]; const packages = new Map(); async function visit(name, parent = process.cwd()) { diff --git a/test/workspace-fixture.ts b/test/workspace-fixture.ts index ecc8612..ecb2f7e 100644 --- a/test/workspace-fixture.ts +++ b/test/workspace-fixture.ts @@ -114,7 +114,7 @@ export async function workspaceFixture() { ? "The parser rejects empty input with a generic error. The patch explains how to fix the input without changing the return type.\n\nThe patch is ready for review." : i === 1 ? "I found the first-run entry point. Before changing it, I need permission to edit the welcome screen." - : "I am checking that tasks survive a restart and that retrying a message does not start the same task twice. Here is the path a task takes:\n\n```mermaid\ngraph TD\n A[Email arrives] --> B{Sender allowed?}\n B -->|no| C[Warn locally]\n B -->|yes| D[Save task]\n D --> E([Acknowledge])\n D --> F{Session free?}\n F -->|no| G[Queue]\n G --> F\n F -->|yes| H[Start turn]\n H --> I[(Checkpoint)]\n I --> J([Reply with result])\n```", + : "I am checking that tasks survive a restart and that retrying a message does not start the same task twice. Here is the path a task takes:\n\n```mermaid\ngraph TD\n A[Email arrives] --> B{Sender allowed?}\n B -->|no| C[Warn locally]\n B -->|yes| D[Save task]\n D --> E([Acknowledge])\n D --> F{Session free?}\n F -->|no| G[Queue]\n G --> F\n F -->|yes| H[Start turn]\n H --> I[(Checkpoint saved with the bundle and patch)]\n I --> J([Reply with result])\n```", }, }, ], diff --git a/ui/src/Diagram.tsx b/ui/src/Diagram.tsx index f78218f..6b15d2c 100644 --- a/ui/src/Diagram.tsx +++ b/ui/src/Diagram.tsx @@ -1,6 +1,15 @@ import { useMemo } from "react"; -import { renderMermaidSVG } from "beautiful-mermaid-axp"; +import { + renderMermaidSVG, + setLabelWrapper, + setTextMeasurer, +} from "beautiful-mermaid-axp"; import { AXP_DIAGRAM_COLORS } from "./diagram-theme.js"; +import { measure, wrap } from "./diagram-text.js"; + +// Real font metrics and balanced label wrapping, from Pretext (see diagram-text.ts). +setTextMeasurer(measure); +setLabelWrapper(wrap); /* Renders a ```mermaid block as an inline SVG using the vendored * beautiful-mermaid renderer with the AXP kawaii treatment. Rendering is diff --git a/ui/src/diagram-text.ts b/ui/src/diagram-text.ts new file mode 100644 index 0000000..2159756 --- /dev/null +++ b/ui/src/diagram-text.ts @@ -0,0 +1,79 @@ +import { + layoutWithLines, + measureLineStats, + measureNaturalWidth, + prepareWithSegments, +} from "@chenglou/pretext"; +import type { PreparedTextWithSegments } from "@chenglou/pretext"; + +/* Exact text metrics for diagrams, from Pretext (MIT). + * + * beautiful-mermaid sizes nodes from a per-character width table calibrated + * for Inter and cannot wrap a long label. In the browser we can do better: + * Pretext measures with the page's actual font through canvas and lays lines + * out itself, so node boxes fit the text and long labels break into balanced + * lines before layout runs. Both hooks are no-ops outside a browser, where the + * renderer keeps its estimates (the review-sample script runs there). */ + +const FAMILY = "AXP Runde"; +const prepared = new Map(); + +function font(fontSize: number, fontWeight: number): string { + return `${fontWeight} ${fontSize}px "${FAMILY}", system-ui, sans-serif`; +} + +function prepare(text: string, fontSize: number, fontWeight: number) { + const key = `${fontWeight}|${fontSize}|${text}`; + let ready = prepared.get(key); + if (!ready) { + ready = prepareWithSegments(text, font(fontSize, fontWeight)); + if (prepared.size > 2000) prepared.clear(); + prepared.set(key, ready); + } + return ready; +} + +/** Width of a single line of text in the diagram font. */ +export function measure( + text: string, + fontSize: number, + fontWeight: number, +): number | null { + if (typeof document === "undefined" || !text) return null; + try { + return measureNaturalWidth(prepare(text, fontSize, fontWeight)); + } catch { + return null; + } +} + +/** Break a label into lines no wider than `maxWidth`, then tighten the width + * until one more line would be needed, so the lines come out balanced rather + * than one long line and one short one. Returns the label with '\n' breaks. */ +export function wrap( + text: string, + fontSize: number, + fontWeight: number, + maxWidth: number, +): string { + if (typeof document === "undefined") return text; + try { + const ready = prepare(text, fontSize, fontWeight); + if (measureNaturalWidth(ready) <= maxWidth) return text; + const target = measureLineStats(ready, maxWidth).lineCount; + // binary search the narrowest width that still fits in `target` lines + let low = maxWidth * 0.5; + let high = maxWidth; + for (let i = 0; i < 12; i++) { + const mid = (low + high) / 2; + if (measureLineStats(ready, mid).lineCount <= target) high = mid; + else low = mid; + } + const lines = layoutWithLines(ready, high, fontSize * 1.4).lines.map( + (line) => line.text.trim(), + ); + return lines.filter(Boolean).join("\n"); + } catch { + return text; + } +} diff --git a/ui/types/beautiful-mermaid-axp.d.ts b/ui/types/beautiful-mermaid-axp.d.ts index 446bd5b..4993378 100644 --- a/ui/types/beautiful-mermaid-axp.d.ts +++ b/ui/types/beautiful-mermaid-axp.d.ts @@ -24,4 +24,19 @@ declare module "beautiful-mermaid-axp" { text: string, options?: RenderOptions, ): string; + /** AXP hook: exact single-line width, or null to use the built-in estimate. */ + export type TextMeasurer = ( + text: string, + fontSize: number, + fontWeight: number, + ) => number | null; + export function setTextMeasurer(measurer: TextMeasurer | null): void; + /** AXP hook: break a long label into newline-separated lines within maxWidth. */ + export type LabelWrapper = ( + text: string, + fontSize: number, + fontWeight: number, + maxWidth: number, + ) => string; + export function setLabelWrapper(wrapper: LabelWrapper | null): void; } diff --git a/ui/vendor/beautiful-mermaid/index.ts b/ui/vendor/beautiful-mermaid/index.ts index b4a6454..fd7023c 100644 --- a/ui/vendor/beautiful-mermaid/index.ts +++ b/ui/vendor/beautiful-mermaid/index.ts @@ -28,6 +28,7 @@ export type { AsciiRenderOptions } from './ascii/index.ts' import { decodeXML } from 'entities' import { parseMermaid } from './parser.ts' +import { FONT_SIZES, FONT_WEIGHTS } from './styles.ts' import { layoutGraphSync } from './layout.ts' import { renderSvg } from './renderer.ts' import type { RenderOptions } from './types.ts' @@ -108,6 +109,17 @@ function buildColors(options: RenderOptions): DiagramColors { * }) * ``` */ +/** AXP: optional label wrapper, installed by the host environment. Returns the + * label with '\n' line breaks so it fits `maxWidth` in balanced lines. */ +export type LabelWrapper = (text: string, fontSize: number, fontWeight: number, maxWidth: number) => string +let labelWrapper: LabelWrapper | null = null +export function setLabelWrapper(wrapper: LabelWrapper | null): void { + labelWrapper = wrapper +} +export { setTextMeasurer } from './text-metrics.ts' +export type { TextMeasurer } from './text-metrics.ts' +const MAX_LABEL_WIDTH = { node: 190, edge: 140 } as const + export function renderMermaidSVG( text: string, options: RenderOptions = {} @@ -147,6 +159,17 @@ export function renderMermaidSVG( case 'flowchart': default: { const graph = parseMermaid(text) + // AXP: wrap long labels into balanced lines before layout, so node sizing + // and rendering both see the wrapped text. A no-op unless a wrapper is + // installed (Diagram.tsx installs a Pretext-backed one in the browser). + if (labelWrapper) { + for (const node of graph.nodes.values()) { + if (!node.label.includes('\n')) node.label = labelWrapper(node.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel, MAX_LABEL_WIDTH.node) + } + for (const edge of graph.edges) { + if (edge.label && !edge.label.includes('\n')) edge.label = labelWrapper(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel, MAX_LABEL_WIDTH.edge) + } + } const positioned = layoutGraphSync(graph, options) return renderSvg(positioned, colors, font, transparent) } diff --git a/ui/vendor/beautiful-mermaid/text-metrics.ts b/ui/vendor/beautiful-mermaid/text-metrics.ts index 07766ab..f5205ce 100644 --- a/ui/vendor/beautiful-mermaid/text-metrics.ts +++ b/ui/vendor/beautiful-mermaid/text-metrics.ts @@ -172,7 +172,23 @@ export function getCharWidth(char: string): number { * @param fontWeight - Font weight (affects width slightly) * @returns Estimated width in pixels */ +/** AXP: an optional exact measurer (e.g. canvas-backed via Pretext in the + * browser). Returns null to fall back to the estimate for a given string. */ +export type TextMeasurer = (text: string, fontSize: number, fontWeight: number) => number | null +let exactMeasurer: TextMeasurer | null = null +export function setTextMeasurer(measurer: TextMeasurer | null): void { + exactMeasurer = measurer +} + export function measureTextWidth(text: string, fontSize: number, fontWeight: number): number { + if (exactMeasurer) { + const exact = exactMeasurer(text, fontSize, fontWeight) + if (exact !== null) return exact + } + return estimateTextWidthFromTable(text, fontSize, fontWeight) +} + +function estimateTextWidthFromTable(text: string, fontSize: number, fontWeight: number): number { // Base ratio calibrated for Inter font family // Heavier weights are slightly wider // Added +0.02 buffer to prevent edge truncation of characters like 's' at line ends