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..c994786 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,6 +17,14 @@ - 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. +- 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 new file mode 100644 index 0000000..3adfe9d --- /dev/null +++ b/docs/design/diagrams.md @@ -0,0 +1,88 @@ +# 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. + +## 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 + 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..9b8838e --- /dev/null +++ b/docs/design/diagrams/review.mmd @@ -0,0 +1,15 @@ +graph LR + subgraph Contributor + A[Agent edits the worktree and runs the repository tests before saving] --> 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..e973f03 --- /dev/null +++ b/docs/design/diagrams/review.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + Contributor + + + + + Maintainer + + + + + Verifier + + + + + + + + + + + + changes needed + + + + approve + + + + Agent edits the worktree and runs the repository tests before saving + + + + 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..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", @@ -34,6 +35,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", @@ -106,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", @@ -2124,6 +2134,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 +2148,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..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", @@ -84,6 +85,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..b976769 100644 --- a/scripts/ui-notices.mjs +++ b/scripts/ui-notices.mjs @@ -12,6 +12,9 @@ const roots = [ "remark-gfm", "@fontsource-variable/dm-sans", "@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 f95634b..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.", + : "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 new file mode 100644 index 0000000..6b15d2c --- /dev/null +++ b/ui/src/Diagram.tsx @@ -0,0 +1,62 @@ +import { useMemo } from "react"; +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 + * 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-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/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..4993378 --- /dev/null +++ b/ui/types/beautiful-mermaid-axp.d.ts @@ -0,0 +1,42 @@ +/* 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; + /** 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/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..fd7023c --- /dev/null +++ b/ui/vendor/beautiful-mermaid/index.ts @@ -0,0 +1,200 @@ +// ============================================================================ +// 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 { FONT_SIZES, FONT_WEIGHTS } from './styles.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 + * }) + * ``` + */ +/** 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 = {} +): 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) + // 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) + } + } +} + +/** + * 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" }, });