feat(utils): vendor mermaid rendering

This commit is contained in:
can1357
2026-06-18 18:00:06 +02:00
parent 4d8b6a9614
commit f5ebab2b83
164 changed files with 14071 additions and 1756 deletions
+13 -2
View File
@@ -1,6 +1,17 @@
MIT License
This directory contains a vendored, locally-patched copy of brush-builtins from
the brush shell project (https://github.com/reubeno/brush), used under the MIT
License. It is wired into the workspace via `[patch.crates-io]` so these
modifications can be maintained against the published release (brush-builtins
0.2.0).
Copyright (c) 2024 reuben olinsky
Copyright (c) 2024 reuben olinsky
Local modifications include: implementing previously-stubbed builtins, the
`wait` builtin (PID targeting plus the `-f`/`-n`/`-p` flags), and cancellation
support for the `read` builtin via poll. The remaining logic tracks upstream so
behavior matches the published crate.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+14 -2
View File
@@ -1,6 +1,18 @@
MIT License
This directory contains a vendored, locally-patched copy of brush-core from the
brush shell project (https://github.com/reubeno/brush), used under the MIT
License. It is wired into the workspace via `[patch.crates-io]` so these
modifications can be maintained against the published release (brush-core
0.5.0).
Copyright (c) 2024 reuben olinsky
Copyright (c) 2024 reuben olinsky
Local modifications include: Windows path handling fixes (drive-alias and
rooted-path normalization in globs), an async background-PID fix for the nohup
wrapper, treating `/dev/null` as poll-ready to avoid an infinite poll on macOS,
and indentation/formatting standardization. The remaining logic tracks upstream
so behavior matches the published crate.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-46
View File
@@ -1,46 +0,0 @@
# Third-party attribution — RTK
Portions of the shell-output minimizer adapt algorithms from **RTK**
(`rtk-ai/rtk`), used under the MIT License, which is compatible with this
workspace's MIT License.
## Ported component
- **Upstream:** [`rtk-ai/rtk`](https://github.com/rtk-ai/rtk) @ commit
`878af7de99e0ba71da2e8fd996f6b52a1836e06c`
- **Upstream path:** `src/cmds/python/pytest_cmd.rs`
- **Local path:** `crates/pi-shell/src/minimizer/filters/python.rs`
- **What was adapted:** the `build_pytest_summary` algorithm — re-implemented
here as the pytest state machine (`filter_pytest`, `pytest_success`,
`is_pytest_*`, `looks_like_pytest_summary_part`). It preserves failures,
errors, and the final summary line; strips header framing, progress dots, and
verbose `PASSED` rows; and falls through unchanged on unknown-state lines
(RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters never
cause data loss.
## License (MIT)
RTK is distributed under the MIT License. A copy of the upstream license text
is reproduced below for the pinned revision above.
```
MIT License
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.
```
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
authors.workspace = true
repository.workspace = true
# Portions of the minimizer adapt MIT-licensed algorithms from rtk-ai/rtk.
# See ATTRIBUTION-RTK.md at this crate root (packaged on publish).
# See NOTICE at this crate root (packaged on publish).
[lints]
workspace = true
+35
View File
@@ -0,0 +1,35 @@
This file documents third-party attribution for the shell-output minimizer in
this crate, which adapts an algorithm from RTK (https://github.com/rtk-ai/rtk),
used under the MIT License.
Copyright (c) the RTK authors (rtk-ai/rtk)
Adapted component: the `build_pytest_summary` algorithm from the upstream
`src/cmds/python/pytest_cmd.rs` (pinned at commit
878af7de99e0ba71da2e8fd996f6b52a1836e06c), re-implemented here in
`src/minimizer/filters/python.rs` as the pytest state machine (`filter_pytest`,
`pytest_success`, `is_pytest_*`, `looks_like_pytest_summary_part`). It preserves
failures, errors, and the final summary line; strips header framing, progress
dots, and verbose `PASSED` rows; and falls through unchanged on unknown-state
lines (RTK's defensive default) so xdist `[gwN]` prefixes and custom reporters
never cause data loss.
MIT License
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.
@@ -2,7 +2,7 @@
//!
//! Ported from rtk-ai/rtk@878af7de99e0ba71da2e8fd996f6b52a1836e06c
//! Path: `src/cmds/python/pytest_cmd.rs`
//! License: MIT (compatible with workspace MIT). See `ATTRIBUTION-RTK.md` at
//! License: MIT (compatible with workspace MIT). See `NOTICE` at
//! the `pi-shell` crate root.
//!
//! The pytest state machine (`filter_pytest`, `pytest_success`,
-2
View File
@@ -5,7 +5,6 @@
"type": "module",
"packageManager": "bun@1.3.14",
"patchedDependencies": {
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch",
"@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch"
},
"workspaces": {
@@ -55,7 +54,6 @@
"@typescript/native-preview": "7.0.0-dev.20260609.1",
"@xterm/headless": "^6.0.0",
"arktype": "^2.2.0",
"beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"date-fns": "^4.4.0",
@@ -360,7 +360,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
const legacyDbPath = path.join(tempDir, "legacy-v1-anthropic-agent.db");
const legacyDb = new Database(legacyDbPath);
legacyDb.exec(`
legacyDb.run(`
CREATE TABLE auth_schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL
@@ -442,7 +442,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
const futureDbPath = path.join(tempDir, "future-schema-agent.db");
const futureDb = new Database(futureDbPath);
futureDb.exec(`
futureDb.run(`
CREATE TABLE auth_schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL
@@ -505,7 +505,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
const legacyDbPath = path.join(tempDir, "legacy-v3-agent.db");
const legacyDb = new Database(legacyDbPath);
legacyDb.exec(`
legacyDb.run(`
CREATE TABLE auth_schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL
@@ -561,7 +561,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
const legacyDbPath = path.join(tempDir, "legacy-v1-agent.db");
const legacyDb = new Database(legacyDbPath);
legacyDb.exec(`
legacyDb.run(`
CREATE TABLE auth_schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL
@@ -612,7 +612,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
const legacyDbPath = path.join(tempDir, "legacy-agent.db");
const legacyDb = new Database(legacyDbPath);
legacyDb.exec(`
legacyDb.run(`
CREATE TABLE auth_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Changed
- Changed Mermaid fenced-block ASCII rendering to use the first-party vendored renderer in `@oh-my-pi/pi-utils` (`src/vendor/mermaid-ascii`), dropping the `beautiful-mermaid` npm package, its transitive `elkjs` (~3.13MB), and the `beautiful-mermaid` `bun patch`; CJK/emoji width handling and the layout-direction override are preserved.
### Fixed
@@ -67,7 +67,8 @@ describe("AssistantMessageComponent mermaid markdown", () => {
});
it("aligns box borders for CJK labels in display columns", () => {
// Defends the beautiful-mermaid patchedDependencies entry: Hangul is 2
// Defends the first-party vendored Mermaid ASCII renderer's CJK/East-Asian
// display-width handling (packages/utils/src/vendor/mermaid-ascii): Hangul is 2
// terminal columns wide, so every row of a single-node diagram must
// measure the same display width or the right border drifts.
const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```");
+11 -11
View File
@@ -56,11 +56,11 @@ export async function initDb(): Promise<Database> {
db = new Database(getStatsDbPath());
// Install the busy handler BEFORE any lock-taking statement. See
// https://github.com/can1357/oh-my-pi/issues/2421.
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA journal_mode = WAL");
db.run("PRAGMA busy_timeout = 5000");
db.run("PRAGMA journal_mode = WAL");
// Create tables
db.exec(`
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_file TEXT NOT NULL,
@@ -132,9 +132,9 @@ export async function initDb(): Promise<Database> {
const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
if (!messageColumns.some(column => column.name === "premium_requests")) {
db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
}
db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
// Each behavior-metric bump invalidates previously-ingested rows. We detect
// the stale schema by column name and drop the table; `IF NOT EXISTS` above
// already produced the new schema, but we want a clean wipe + re-ingest.
@@ -159,8 +159,8 @@ export async function initDb(): Promise<Database> {
const hasV4Columns = userMessageColumns.some(column => column.name === "negation");
const hasOldUserMessages = userMessageColumns.length > 0;
if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
db.exec("DROP TABLE user_messages");
db.exec(`
db.run("DROP TABLE user_messages");
db.run(`
CREATE TABLE user_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_file TEXT NOT NULL,
@@ -787,8 +787,8 @@ function backfillUserMessages(database: Database): void {
| undefined;
if (!shouldResetBackfill(row?.value)) return;
database.exec("DELETE FROM user_messages");
database.exec("DELETE FROM file_offsets");
database.run("DELETE FROM user_messages");
database.run("DELETE FROM file_offsets");
database
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
@@ -808,7 +808,7 @@ function repairUserMessageLinks(database: Database): void {
| undefined;
if (!shouldResetBackfill(row?.value)) return;
database.exec("DELETE FROM file_offsets");
database.run("DELETE FROM file_offsets");
database
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
@@ -830,7 +830,7 @@ function backfillPriorityPremiumRequests(database: Database): void {
| undefined;
if (!shouldResetBackfill(row?.value)) return;
database.exec("DELETE FROM file_offsets");
database.run("DELETE FROM file_offsets");
database
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
+8 -1
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Changed
- Mermaid diagrams are now rendered to ASCII by a first-party vendored renderer (`src/vendor/mermaid-ascii`, derived from the MIT-licensed `beautiful-mermaid`, ASCII pipeline only) with terminal display width measured via `Bun.stringWidth` (grapheme-aware, correct for wide/East-Asian glyphs and emoji). Inline label formatting (HTML formatting tags and markdown emphasis) is now reduced to plain text instead of printed raw.
### Removed
- Removed the external `beautiful-mermaid` dependency (and its transitive `elkjs`, ~3.13MB) in favor of the vendored ASCII renderer.
## [16.0.3] - 2026-06-16
@@ -150,4 +157,4 @@
### Added
- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
-1
View File
@@ -32,7 +32,6 @@
},
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
"handlebars": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:"
+1 -1
View File
@@ -1,4 +1,4 @@
import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid";
import { type AsciiRenderOptions, renderMermaidASCII } from "./vendor/mermaid-ascii";
export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
+33
View File
@@ -0,0 +1,33 @@
This directory contains an in-house Mermaid-diagram-to-ASCII renderer adapted
from beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid), used
under the MIT License.
Copyright (c) 2026 Craft Docs
Only the ASCII rendering pipeline is ported (flowchart/state, sequence, class,
ER, and xychart diagrams); the SVG renderer and its `elkjs` graph-layout
dependency, the browser entry point, and the SVG theme/style modules were
dropped. Terminal display width is reimplemented on `Bun.stringWidth`, and
inline label formatting (HTML tags, markdown emphasis) is reduced to plain text
for ASCII output. Layout and edge-routing logic is preserved faithfully so
ASCII output matches the upstream package.
MIT License
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.
+409
View File
@@ -0,0 +1,409 @@
// ============================================================================
// ASCII renderer — color utilities
//
// Provides color output for themed ASCII diagrams.
// Supports ANSI terminal modes (16/256/truecolor) and HTML <span> tags
// for browser rendering.
// ============================================================================
import type { CharRole, AsciiTheme, ColorMode } from './types'
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
}
// ============================================================================
// 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 <span> 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<string, string | undefined> } }).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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** Wrap text in a <span> with an inline color style. */
function htmlSpan(hex: string, text: string): string {
return `<span style="color:${hex}">${escapeHtml(text)}</span>`
}
// ============================================================================
// 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 <span> 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}`
}
+476
View File
@@ -0,0 +1,476 @@
// ============================================================================
// 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'
import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
/**
* 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 cell holds label content for first-label-wins collision
* handling during merges: letters/digits in any script, the continuation
* cell of a wide glyph, or any 2-column glyph. Wide glyphs are only ever
* produced by labels (CJK ideographs, Hangul, emoji) — the renderer's own
* structural glyphs (borders, the narrow arrowheads ◀▶) are 1 column — so
* width 2 is a sufficient signal for emoji labels (🚀, 🇨🇳, 👍🏽) that the
* letter/digit test misses.
*/
function isLabelChar(c: string): boolean {
return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
}
/**
* Write one cell, dissolving any wide-glyph pair the write would split:
* overwriting a WIDE_PAD orphans its lead, and overwriting a lead orphans
* its pad — the orphaned half becomes a space so serialized rows keep
* exactly one column per cell.
*/
function writeCell(canvas: Canvas, x: number, y: number, c: string): void {
const current = canvas[x]![y]!
if (current === WIDE_PAD && x > 0 && c !== WIDE_PAD) {
canvas[x - 1]![y] = ' '
} else if (current !== WIDE_PAD && canvas[x + 1]?.[y] === WIDE_PAD && c !== current) {
canvas[x + 1]![y] = ' '
}
canvas[x]![y] = c
}
/**
* When two junction characters overlap during canvas merging,
* resolve them to the correct combined junction.
* E.g., '─' overlapping '│' becomes '┼'.
*/
const JUNCTION_MAP: Record<string, Record<string, string>> = {
'─': { '│': '┼', '┌': '┬', '┐': '┬', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┬', '┴': '┴' },
'│': { '─': '┼', '┌': '├', '┐': '┤', '└': '├', '┘': '┤', '├': '├', '┤': '┤', '┬': '┼', '┴': '┼' },
'┌': { '─': '┬', '│': '├', '┐': '┬', '└': '├', '┘': '┼', '├': '├', '┤': '┼', '┬': '┬', '┴': '┼' },
'┐': { '─': '┬', '│': '┤', '┌': '┬', '└': '┼', '┘': '┤', '├': '┼', '┤': '┤', '┬': '┬', '┴': '┼' },
'└': { '─': '┴', '│': '├', '┌': '├', '┐': '┼', '┘': '┴', '├': '├', '┤': '┼', '┬': '┼', '┴': '┴' },
'┘': { '─': '┴', '│': '┤', '┌': '┼', '┐': '┤', '└': '┴', '├': '┼', '┤': '┤', '┬': '┼', '┴': '┴' },
'├': { '─': '┼', '│': '├', '┌': '├', '┐': '┼', '└': '├', '┘': '┼', '┤': '┼', '┬': '┼', '┴': '┼' },
'┤': { '─': '┼', '│': '┤', '┌': '┼', '┐': '┤', '└': '┼', '┘': '┤', '├': '┼', '┬': '┼', '┴': '┼' },
'┬': { '─': '┬', '│': '┼', '┌': '┬', '┐': '┬', '└': '┼', '┘': '┼', '├': '┼', '┤': '┼', '┴': '┼' },
'┴': { '─': '┴', '│': '┼', '┌': '┼', '┐': '┼', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┼' },
}
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]!
// WIDE_PAD cells are written atomically with their lead below
if (c === ' ' || c === WIDE_PAD) continue
const mx = x + offset.x
const my = y + offset.y
const current = merged[mx]![my]!
const isWide = overlay[x + 1]?.[y] === WIDE_PAD
if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
merged[mx]![my] = mergeJunctions(current, c)
} else if (isWide) {
// Wide glyphs land or yield as a whole pair (first label wins)
if (!isLabelChar(current) && !isLabelChar(merged[mx + 1]?.[my] ?? ' ')) {
writeCell(merged, mx, my, c)
writeCell(merged, mx + 1, my, WIDE_PAD)
}
} else if (isLabelChar(current) && isLabelChar(c)) {
// Don't overwrite existing label text with new label text
// This prevents label collisions (first label wins)
} else {
writeCell(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++) {
const c = canvas[x]![y]!
// Skip wide-glyph continuation cells: the glyph itself spans 2 columns
if (c !== WIDE_PAD) line += c
}
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++) {
const c = canvas[x]![y]!
if (c === WIDE_PAD) continue
chars.push(c)
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<string, string> = {
// 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 {
const cells = toCells(text)
increaseSize(canvas, start.x + cells.length, start.y)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!
// WIDE_PAD cells are written atomically with their lead below
if (cell === WIDE_PAD) continue
const x = start.x + i
if (cells[i + 1] === WIDE_PAD) {
// Wide glyph: needs both its cells free (or forced) to land
const pairFree = canvas[x]![start.y] === ' ' && canvas[x + 1]![start.y] === ' '
if (forceOverwrite || pairFree) {
writeCell(canvas, x, start.y, cell)
writeCell(canvas, x + 1, start.y, WIDE_PAD)
}
} else if (forceOverwrite || canvas[x]![start.y] === ' ') {
writeCell(canvas, x, start.y, cell)
}
}
}
/**
* 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<number, number>,
rowHeight: Map<number, number>,
): 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<number, number>,
rowHeight: Map<number, number>,
): 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)
}
@@ -0,0 +1,699 @@
// ============================================================================
// 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'
import type { ClassDiagram, ClassNode, ClassMember, ClassRelationship, RelationshipType } from '../class/types'
import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
import { drawMultiBox } from './draw'
import { splitLines } from './multiline-utils'
import { displayWidth, toCells } from '../text-metrics'
/** 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<string, string[][]>()
const classBoxW = new Map<string, number>()
const classBoxH = new Map<string, number>()
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, displayWidth(line))
}
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<string, ClassNode>()
for (const cls of diagram.classes) classById.set(cls.id, cls)
const parents = new Map<string, Set<string>>() // child → set of parent IDs
const children = new Map<string, Set<string>>() // 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<string, number>()
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<string, PlacedClass>()
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<string>): 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<string>): 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 => displayWidth(l))) + 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
const cells = toCells(paddedLine)
// Calculate label start, but ensure it doesn't go negative
const idealLabelStart = idealMidX - Math.floor(cells.length / 2)
const labelStart = Math.max(0, idealLabelStart)
const y = startY + lineIdx
// Ensure canvas is wide enough for the label
const labelEnd = labelStart + cells.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 < cells.length; i++) {
const lx = labelStart + i
if (lx >= 0 && y >= 0) {
setC(lx, y, cells[i]!, 'text')
}
}
}
}
}
return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
}
@@ -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'
import type {
AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiConfig,
} from './types'
import { EMPTY_STYLE } from './types'
import { mkCanvas, mkRoleCanvas } from './canvas'
/**
* 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<string, AsciiNode>()
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<string, AsciiNode>,
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<string, AsciiNode>,
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<MermaidSubgraph, AsciiSubgraph>()
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<string, AsciiSubgraph>() // 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<MermaidSubgraph, AsciiSubgraph>,
): 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]!)
}
}
File diff suppressed because it is too large Load Diff
@@ -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'
import { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types'
import { getPath, mergePath } from './pathfinder'
import { getNodeSubgraph } from './grid'
// ============================================================================
// 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<AsciiEdge>()
// Group edges by target (fan-in candidates)
const edgesByTarget = new Map<AsciiNode, AsciiEdge[]>()
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<AsciiNode, AsciiEdge[]>()
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)
}
}
@@ -0,0 +1,297 @@
// ============================================================================
// 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'
import {
Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle,
gridCoordDirection,
} from './types'
import { getPath, mergePath } from './pathfinder'
import { getEffectiveDirection, getNodeSubgraph } from './grid'
import { displayWidth } from '../text-metrics'
// ============================================================================
// 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 = displayWidth(edge.text)
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
}
@@ -0,0 +1,441 @@
// ============================================================================
// 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'
import type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types'
import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
import { drawMultiBox } from './draw'
import { splitLines } from './multiline-utils'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
/** 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<string>[] {
const visited = new Set<string>()
const components: Set<string>[] = []
// Build undirected adjacency list from relationships
const neighbors = new Map<string, Set<string>>()
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<string>): 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<string>()
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<string, string[][]>()
const entityBoxW = new Map<string, number>()
const entityBoxH = new Map<string, number>()
const entityById = new Map<string, ErEntity>()
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, displayWidth(line))
}
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<string, PlacedEntity>()
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 cells = toCells(line)
const labelStart = Math.max(startX, gapMid - Math.floor(cells.length / 2))
const labelY = lineY + 1 + lineIdx
// Ensure canvas is tall enough
increaseSize(canvas, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
increaseRoleCanvasSize(rc, Math.max(labelStart + cells.length, 1), Math.max(labelY + 1, 1))
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!
if (cell === WIDE_PAD) continue // written atomically with its lead
const lx = labelStart + i
const wide = cells[i + 1] === WIDE_PAD
// Keep wide-glyph pairs atomic within the gap region
if (lx < startX || lx + (wide ? 1 : 0) > endX) continue
setC(lx, labelY, cell, 'text')
if (wide) setC(lx + 1, labelY, WIDE_PAD, '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 cells = toCells(lines[lineIdx]!)
const labelX = lineX + 2
const y = startLabelY + lineIdx
if (y >= 0) {
for (let i = 0; i < cells.length; i++) {
const lx = labelX + i
if (lx >= 0) {
increaseSize(canvas, lx + 1, y + 1)
increaseRoleCanvasSize(rc, lx + 1, y + 1)
setC(lx, y, cells[i]!, 'text')
}
}
}
}
}
}
}
return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
}
+578
View File
@@ -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'
import { gridKey } from './types'
import { mkCanvas, setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas'
import { determinePath, determineLabelLine } from './edge-routing'
import { analyzeEdgeBundles, processBundles } from './edge-bundling'
import { drawBox } from './draw'
import { maxLineWidth, lineCount } from './multiline-utils'
import { getShapeDimensions } from './shapes/index'
// ============================================================================
// 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<string>()
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)
}
+187
View File
@@ -0,0 +1,187 @@
// ============================================================================
// 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'
import { convertToAsciiGraph } from './converter'
import { createMapping } from './grid'
import { drawGraph } from './draw'
import { canvasToString, flipCanvasVertically, flipRoleCanvasVertically } from './canvas'
import { renderSequenceAscii } from './sequence'
import { renderClassAscii } from './class-diagram'
import { renderErAscii } from './er-diagram'
import { renderXYChartAscii } from './xychart'
import { detectColorMode, DEFAULT_ASCII_THEME } from './ansi'
import type { AsciiConfig, AsciiTheme, ColorMode } from './types'
import type { Direction } from '../types'
// Re-export types for external use
export type { AsciiTheme, ColorMode }
export { DEFAULT_ASCII_THEME, detectColorMode }
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
/**
* Force the layout direction, overriding the direction parsed from the
* diagram source. Applies to the flowchart + state-diagram grid pipeline
* (sequence/class/ER/xychart renderers ignore it). Useful for re-fitting a
* wide `LR` graph into a narrow viewport by laying it out top-down.
* Default: undefined (use the source's own direction).
*/
direction?: Direction
/**
* 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 <span> 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<AsciiTheme>
}
/**
* 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)
// Honor an explicit direction override. Applied before normalization so
// the LR/TD split and the BT vertical-flip below behave exactly as if the
// source had authored this direction.
if (options.direction) {
parsed.direction = options.direction
}
// 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,
})
}
}
}
/** Lowercase alias kept as the public name used by the pi-utils wrapper. */
export const renderMermaidAscii = renderMermaidASCII
@@ -0,0 +1,78 @@
// ============================================================================
// ASCII renderer — multi-line text utilities
//
// Shared utilities for handling multi-line labels (containing \n from <br> tags)
// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and
// centered rendering across all diagram types.
// ============================================================================
import type { Canvas } from './types'
import { drawText } from './canvas'
import { displayWidth } from '../text-metrics'
/**
* 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 => displayWidth(l)), 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(displayWidth(line) / 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)
}
}
@@ -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'
import { gridKey, gridCoordEquals } from './types'
// ============================================================================
// 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<string, AsciiNode>, 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<string, AsciiNode>,
from: GridCoord,
to: GridCoord,
): GridCoord[] | null {
const pq = new MinHeap()
pq.push({ coord: from, priority: 0 })
const costSoFar = new Map<string, number>()
costSoFar.set(gridKey(from), 0)
const cameFrom = new Map<string, GridCoord | null>()
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<number>()
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))
}
@@ -0,0 +1,460 @@
// ============================================================================
// 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'
import type { SequenceDiagram, Block } from '../sequence/types'
import type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
import { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas'
import { splitLines, maxLineWidth, lineCount } from './multiline-utils'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
/** 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<string, number>()
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<number, number>()
const blockEndY = new Map<number, number>()
const divYMap = new Map<string, number>() // "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 => displayWidth(l))) + 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 + displayWidth(msg.label)
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)
}
}
/**
* Write label cells starting at x0, keeping wide-glyph pairs atomic:
* a glyph and its WIDE_PAD continuation land together or not at all,
* clamped to [minX, maxXExcl) and the canvas bounds.
*/
function setCells(x0: number, y: number, cells: string[], role: CharRole, minX = 0, maxXExcl = canvas.length): void {
const limit = Math.min(maxXExcl, canvas.length)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!
if (cell === WIDE_PAD) continue // written atomically with its lead
const x = x0 + i
const wide = cells[i + 1] === WIDE_PAD
if (x < minX || x + (wide ? 1 : 0) >= limit) continue
setC(x, y, cell, role)
if (wide) setC(x + 1, y, WIDE_PAD, 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 cells = toCells(line)
const ls = left + 1 + boxPad + Math.floor((maxW - cells.length) / 2)
setCells(ls, row, cells, '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
const selfCells = toCells(msg.label)
setCells(labelX, y0 + 1, selfCells, 'text', 0, totalW)
// 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 cells = toCells(msgLines[lineIdx]!)
const labelStart = midX - Math.floor(cells.length / 2)
const y = labelY + lineIdx
setCells(labelStart, y, cells, 'text', 0, totalW)
}
// 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 cells = toCells(hdrLines[lineIdx]!)
setCells(bLeft + 1, topY + lineIdx, cells, 'text', bLeft + 1, bRight)
}
// 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 dCells = toCells(`[${dLabel}]`)
setCells(bLeft + 1, dY, dCells, 'text', bLeft + 1, bRight)
}
}
}
// ---- 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')
const cells = toCells(np.lines[l]!)
setCells(np.x + 2, ly, cells, '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 ? '-' : '╌'
}
}
@@ -0,0 +1,27 @@
// ============================================================================
// Circle shape renderer — uses corner decorators instead of curves
// ============================================================================
import type { ShapeRenderer } from './types'
import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
import { getCorners } from './corners'
/**
* 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,
}
@@ -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'
/**
* 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<AsciiNodeShape, ShapeCorners> = {
// 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
}
@@ -0,0 +1,27 @@
// ============================================================================
// Diamond shape renderer — uses corner decorators instead of diagonals
// ============================================================================
import type { ShapeRenderer } from './types'
import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
import { getCorners } from './corners'
/**
* 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,
}
@@ -0,0 +1,27 @@
// ============================================================================
// Hexagon shape renderer — uses corner decorators instead of diagonals
// ============================================================================
import type { ShapeRenderer } from './types'
import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
import { getCorners } from './corners'
/**
* 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,
}
@@ -0,0 +1,101 @@
// ============================================================================
// Shape registry — pluggable ASCII shape renderers
// ============================================================================
import type { AsciiNodeShape, Canvas, DrawingCoord, Direction } from '../types'
import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } from './types'
// Import all shape renderers
import { rectangleRenderer } from './rectangle'
import { diamondRenderer } from './diamond'
import { circleRenderer } from './circle'
import { stateStartRenderer, stateEndRenderer } from './state'
import { roundedRenderer } from './rounded'
import { stadiumRenderer } from './stadium'
import { hexagonRenderer } from './hexagon'
import {
subroutineRenderer,
doublecircleRenderer,
cylinderRenderer,
asymmetricRenderer,
trapezoidRenderer,
trapezoidAltRenderer,
} from './special'
// 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<AsciiNodeShape, ShapeRenderer>([
// 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)
}
@@ -0,0 +1,175 @@
// ============================================================================
// 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'
import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle } from '../types'
import { mkCanvas } from '../canvas'
import { splitLines } from '../multiline-utils'
import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types'
import { dirEquals } from '../edge-routing'
import { type CornerChars, getCorners } from './corners'
import { displayWidth, toCells } from '../../text-metrics'
// ============================================================================
// 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 => displayWidth(l)), 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 cells = toCells(line)
const textX = Math.floor(w / 2) - Math.ceil(cells.length / 2) + 1
for (let j = 0; j < cells.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] = cells[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,
}
@@ -0,0 +1,27 @@
// ============================================================================
// Rounded rectangle shape renderer — uses rounded corner decorators
// ============================================================================
import type { ShapeRenderer } from './types'
import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
import { getCorners } from './corners'
/**
* 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,
}
@@ -0,0 +1,296 @@
// ============================================================================
// 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'
import { Up, Down, Left, Right } from '../types'
import { mkCanvas } from '../canvas'
import { splitLines } from '../multiline-utils'
import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types'
import { dirEquals } from '../edge-routing'
import { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle'
import { getCorners } from './corners'
import { displayWidth, toCells } from '../../text-metrics'
// ============================================================================
// 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 => displayWidth(l)), 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 cells = toCells(line)
const textX = Math.floor(width / 2) - Math.floor(cells.length / 2)
for (let j = 0; j < cells.length; j++) {
const x = textX + j
const y = startY + i
if (x > 1 && x < width - 2 && y > 0 && y < height - 1) {
canvas[x]![y] = cells[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 => displayWidth(l)), 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 cells = toCells(line)
const textX = Math.floor(width / 2) - Math.floor(cells.length / 2)
for (let j = 0; j < cells.length; j++) {
const x = textX + j
const y = startY + i
if (x > 0 && x < width - 1 && y > 1 && y < height - 2) {
canvas[x]![y] = cells[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,
}
@@ -0,0 +1,114 @@
// ============================================================================
// 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'
import { mkCanvas } from '../canvas'
import { splitLines } from '../multiline-utils'
import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types'
import { getBoxAttachmentPoint } from './rectangle'
import { displayWidth, toCells } from '../../text-metrics'
/**
* 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 => displayWidth(l)), 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 cells = toCells(line)
const textX = Math.floor(width / 2) - Math.floor(cells.length / 2)
for (let j = 0; j < cells.length; j++) {
const x = textX + j
const y = startY + i
if (x > 0 && x < width - 1 && y >= 0 && y < height) {
canvas[x]![y] = cells[j]!
}
}
}
return canvas
},
getAttachmentPoint: getBoxAttachmentPoint,
}
@@ -0,0 +1,192 @@
// ============================================================================
// State pseudo-state renderers — UML start and end states
// ============================================================================
import type { Canvas, DrawingCoord, Direction } from '../types'
import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight } from '../types'
import { mkCanvas } from '../canvas'
import type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types'
import { dirEquals } from '../edge-routing'
/**
* 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 }
},
}
@@ -0,0 +1,73 @@
// ============================================================================
// Shape renderer types — interface for pluggable ASCII shape renderers
// ============================================================================
import type { Canvas, DrawingCoord, Direction, AsciiNodeShape } from '../types'
/**
* 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<AsciiNodeShape, ShapeRenderer>
+273
View File
@@ -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'
// 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<string, string>
}
/** 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<string, AsciiNode>
columnWidth: Map<number, number>
rowHeight: Map<number, number>
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 <span> 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
}
@@ -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}`
)
}
+875
View File
@@ -0,0 +1,875 @@
// ============================================================================
// 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'
import type { XYChart } from '../xychart/types'
import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types'
import { colorizeText } from './ansi'
import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors'
import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
// ============================================================================
// 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 => displayWidth(l))) + 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 - displayWidth(chart.title!) / 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 - displayWidth(label)
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(displayWidth(label) / 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 - displayWidth(title) / 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 => displayWidth(l))) + 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 - displayWidth(chart.title!) / 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 - displayWidth(label)
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(displayWidth(label) / 2), label, 'text')
}
// Y-axis title
if (hasYTitle) {
const title = chart.yAxis.title!
writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 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 + displayWidth(items[i]!.label) // 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 += displayWidth(item.label)
}
}
// ============================================================================
// 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<CharRole | null>({ length: height }).fill(null))
}
function createHexCanvas(width: number, height: number): HexCanvas {
return Array.from({ length: width }, () => Array.from<string | null>({ 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 {
const cells = toCells(text)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]!
if (cell === WIDE_PAD) continue // written atomically with its lead
const col = startCol + i
const wide = cells[i + 1] === WIDE_PAD
// Keep wide-glyph pairs atomic at canvas bounds
if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue
set(canvas, roles, row, col, cell, role)
if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, 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++) {
const c = canvas[col]![row]!
// Skip wide-glyph continuation cells: the glyph itself spans 2 columns
if (c === WIDE_PAD) continue
chars.push(c)
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)
}
+290
View File
@@ -0,0 +1,290 @@
import type { ClassDiagram, ClassNode, ClassRelationship, ClassMember, RelationshipType, ClassNamespace } from './types'
import { normalizeBrTags } from '../multiline-utils'
// ============================================================================
// Class diagram parser
//
// Parses Mermaid classDiagram syntax into a ClassDiagram structure.
//
// Supported syntax:
// class Animal { +String name; +eat() void }
// class Shape { <<abstract>> }
// 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<string, ClassNode>()
// 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 <<interface>>
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 { <<interface>> }` (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<string, ClassNode>, 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
}
}
+121
View File
@@ -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 <<interface>>, <<abstract>>, <<service>>, <<enumeration>> */
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 }
}
+181
View File
@@ -0,0 +1,181 @@
import type { ErDiagram, ErEntity, ErAttribute, ErRelationship, Cardinality } from './types'
import { normalizeBrTags } from '../multiline-utils'
// ============================================================================
// 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<string, ErEntity>()
// 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<string, ErEntity>, 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 <br> 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 <cardinality_and_line> 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
}
+91
View File
@@ -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 }>
}
+14
View File
@@ -0,0 +1,14 @@
// ============================================================================
// Mermaid → ASCII renderer (vendored)
//
// First-party copy of the ASCII rendering pipeline from `beautiful-mermaid`
// (MIT, Copyright (c) 2026 Craft Docs — see ./NOTICE). The SVG pipeline and
// its `elkjs` graph-layout dependency are omitted; the ASCII renderers use
// their own grid layout + A* edge routing and need no external deps. Terminal
// display-width math is delegated to `Bun.stringWidth` (see ./text-metrics).
//
// Public surface: renderMermaidASCII + AsciiRenderOptions (incl. the
// `direction` override) and the theme/color-mode types.
// ============================================================================
export * from './ascii/index'
@@ -0,0 +1,30 @@
// ============================================================================
// Label normalization
//
// Shared by the diagram parsers (flowchart/state, class, ER, sequence) to
// normalize raw Mermaid label text before it reaches the ASCII renderers.
// The SVG-only multi-line tspan renderers from upstream are not vendored.
// ============================================================================
/**
* Normalize label text for terminal ASCII output: strip surrounding quotes,
* convert <br> tags and literal newline escapes to newlines, and reduce
* inline formatting (HTML bold/italic/underline/strike tags and the markdown
* bold, italic, and strikethrough markers) to plain text. The ASCII renderer
* has no styled spans, so preserving the markup would print raw tags and
* markers inside node boxes.
*/
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(/<br\s*\/?>/gi, '\n')
.replace(/\\n/g, '\n')
.replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '')
// Drop inline HTML formatting tags — ASCII output has no styled spans
.replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '')
// Reduce markdown emphasis to its inner text (order matters: ** before *)
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
}
+645
View File
@@ -0,0 +1,645 @@
import type { MermaidGraph, MermaidNode, MermaidEdge, MermaidSubgraph, Direction, NodeShape, EdgeStyle } from './types'
import { normalizeBrTags } from './multiline-utils'
// ============================================================================
// 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<string>()
// 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<string, string> {
// Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`)
const cleaned = propsStr.replace(/;\s*$/, '')
const props: Record<string, string> = {}
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'
}
@@ -0,0 +1,207 @@
import type { SequenceDiagram, Actor, Message, Block, Note } from './types'
import { normalizeBrTags } from '../multiline-utils'
// ============================================================================
// Sequence diagram parser
//
// Parses Mermaid sequenceDiagram syntax into a SequenceDiagram structure.
//
// Supported syntax:
// participant A as Alice
// actor B as Bob
// A->>B: Solid arrow
// A-->>B: Dashed arrow
// A-)B: Open arrow
// A--)B: Dashed open arrow
// A->>+B: Activate target
// A-->>-B: Deactivate source
// loop Label ... end
// alt Label ... else Label ... end
// opt Label ... end
// par Label ... and Label ... end
// Note left of A: Text
// Note right of A: Text
// Note over A,B: Text
// ============================================================================
/**
* Parse a Mermaid sequence diagram.
* Expects the first line to be "sequenceDiagram".
*/
export function parseSequenceDiagram(lines: string[]): SequenceDiagram {
const diagram: SequenceDiagram = {
actors: [],
messages: [],
blocks: [],
notes: [],
}
// Track actor IDs to auto-create actors referenced in messages
const actorIds = new Set<string>()
// Track block nesting with a stack
const blockStack: Array<{ type: Block['type']; label: string; startIndex: number; dividers: Block['dividers'] }> = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i]!
// --- Participant / Actor declaration ---
// "participant A as Alice" or "participant Alice"
// "actor B as Bob" or "actor Bob"
const actorMatch = line.match(/^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$/)
if (actorMatch) {
const type = actorMatch[1] as 'participant' | 'actor'
const id = actorMatch[2]!
const rawLabel = actorMatch[3]?.trim() ?? id
const label = normalizeBrTags(rawLabel)
if (!actorIds.has(id)) {
actorIds.add(id)
diagram.actors.push({ id, label, type })
}
continue
}
// --- Note ---
// "Note left of A: text" / "Note right of A: text" / "Note over A,B: text"
const noteMatch = line.match(/^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$/i)
if (noteMatch) {
const posStr = noteMatch[1]!.toLowerCase()
const actorsStr = noteMatch[2]!.trim()
const text = normalizeBrTags(noteMatch[3]!.trim())
const noteActorIds = actorsStr.split(',').map(s => s.trim())
// Ensure actors exist
for (const aid of noteActorIds) {
ensureActor(diagram, actorIds, aid)
}
let position: 'left' | 'right' | 'over' = 'over'
if (posStr === 'left of') position = 'left'
else if (posStr === 'right of') position = 'right'
diagram.notes.push({
actorIds: noteActorIds,
text,
position,
afterIndex: diagram.messages.length - 1,
})
continue
}
// --- Block start: loop, alt, opt, par, critical, break, rect ---
const blockMatch = line.match(/^(loop|alt|opt|par|critical|break|rect)\s*(.*)$/)
if (blockMatch) {
const blockType = blockMatch[1] as Block['type']
const rawBlockLabel = blockMatch[2]?.trim() ?? ''
const label = normalizeBrTags(rawBlockLabel)
blockStack.push({
type: blockType,
label,
startIndex: diagram.messages.length,
dividers: [],
})
continue
}
// --- Block divider: else, and ---
const dividerMatch = line.match(/^(else|and)\s*(.*)$/)
if (dividerMatch && blockStack.length > 0) {
const rawDividerLabel = dividerMatch[2]?.trim() ?? ''
const label = normalizeBrTags(rawDividerLabel)
blockStack[blockStack.length - 1]!.dividers.push({
index: diagram.messages.length,
label,
})
continue
}
// --- Block end ---
if (line === 'end' && blockStack.length > 0) {
const completed = blockStack.pop()!
diagram.blocks.push({
type: completed.type,
label: completed.label,
startIndex: completed.startIndex,
endIndex: Math.max(diagram.messages.length - 1, completed.startIndex),
dividers: completed.dividers,
})
continue
}
// --- Message ---
// Patterns: A->>B, A-->>B, A-)B, A--)B, with optional +/- activation
// Format: FROM ARROW TO: LABEL
const msgMatch = line.match(
/^(\S+?)\s*(--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(\S+?)\s*:\s*(.+)$/
)
if (msgMatch) {
const from = msgMatch[1]!
const arrow = msgMatch[2]!
const activationMark = msgMatch[3]
const to = msgMatch[4]!
const label = normalizeBrTags(msgMatch[5]!.trim())
// Ensure both actors exist
ensureActor(diagram, actorIds, from)
ensureActor(diagram, actorIds, to)
// Determine line style and arrow head from the arrow operator
const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid'
// ">>" = filled arrow, ")" or ">" alone = open arrow, "x" = cross (treat as filled)
const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open'
const msg: Message = {
from,
to,
label,
lineStyle,
arrowHead,
}
// Activation/deactivation via +/- prefix on target
if (activationMark === '+') msg.activate = true
if (activationMark === '-') msg.deactivate = true
diagram.messages.push(msg)
continue
}
// --- Simplified message format: A->>B: Label (fallback with more relaxed regex) ---
const simpleMsgMatch = line.match(
/^(\S+?)\s*(->>|-->>|-\)|--\)|-x|--x|->|-->)\s*([+-]?)(\S+?)\s*:\s*(.+)$/
)
if (simpleMsgMatch) {
const from = simpleMsgMatch[1]!
const arrow = simpleMsgMatch[2]!
const activationMark = simpleMsgMatch[3]
const to = simpleMsgMatch[4]!
const label = normalizeBrTags(simpleMsgMatch[5]!.trim())
ensureActor(diagram, actorIds, from)
ensureActor(diagram, actorIds, to)
const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid'
const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open'
const msg: Message = { from, to, label, lineStyle, arrowHead }
if (activationMark === '+') msg.activate = true
if (activationMark === '-') msg.deactivate = true
diagram.messages.push(msg)
continue
}
// --- activate / deactivate explicit commands ---
// These are handled implicitly via +/- on messages but can also appear standalone
// For now, we skip explicit activate/deactivate lines (they affect rendering only)
}
return diagram
}
/** Ensure an actor exists, creating a default participant if not */
function ensureActor(diagram: SequenceDiagram, actorIds: Set<string>, id: string): void {
if (!actorIds.has(id)) {
actorIds.add(id)
diagram.actors.push({ id, label: id, type: 'participant' })
}
}
@@ -0,0 +1,146 @@
// ============================================================================
// Sequence diagram types
//
// Models the parsed and positioned representations of a Mermaid sequence diagram.
// Sequence diagrams show actor interactions over time (vertical timeline).
// ============================================================================
/** Parsed sequence diagram — logical structure from mermaid text */
export interface SequenceDiagram {
/** Ordered list of actors/participants */
actors: Actor[]
/** Messages between actors in chronological order */
messages: Message[]
/** Structural blocks (loop, alt, opt, par, critical) */
blocks: Block[]
/** Notes attached to actors */
notes: Note[]
}
export interface Actor {
id: string
label: string
/** 'participant' renders as a box, 'actor' renders as a stick figure */
type: 'participant' | 'actor'
}
export interface Message {
from: string
to: string
label: string
/** Arrow style: solid line or dashed line */
lineStyle: 'solid' | 'dashed'
/** Arrow head: filled (closed) or open */
arrowHead: 'filled' | 'open'
/** Activate the target lifeline (+) */
activate?: boolean
/** Deactivate the source lifeline (-) */
deactivate?: boolean
}
export interface Block {
/** Block type keyword */
type: 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect'
/** Label for the block header */
label: string
/** Index of the first message inside this block */
startIndex: number
/** Index of the last message inside this block (inclusive) */
endIndex: number
/** For alt/par blocks: indices where "else"/"and" dividers appear (message indices) */
dividers: Array<{ index: number; label: string }>
}
export interface Note {
/** Which actor(s) the note is attached to */
actorIds: string[]
/** Note text content */
text: string
/** Position relative to the actor(s) */
position: 'left' | 'right' | 'over'
/** Message index after which this note appears */
afterIndex: number
}
// ============================================================================
// Positioned sequence diagram — ready for SVG rendering
// ============================================================================
export interface PositionedSequenceDiagram {
width: number
height: number
actors: PositionedActor[]
lifelines: Lifeline[]
messages: PositionedMessage[]
activations: Activation[]
blocks: PositionedBlock[]
notes: PositionedNote[]
}
export interface PositionedActor {
id: string
label: string
type: 'participant' | 'actor'
/** Center x of the actor box */
x: number
/** Top y of the actor box */
y: number
width: number
height: number
}
/** Vertical dashed line from actor to bottom of diagram */
export interface Lifeline {
actorId: string
x: number
topY: number
bottomY: number
}
export interface PositionedMessage {
from: string
to: string
label: string
lineStyle: 'solid' | 'dashed'
arrowHead: 'filled' | 'open'
/** Start point (from actor's lifeline) */
x1: number
/** End point (to actor's lifeline) */
x2: number
/** Vertical position */
y: number
/** Whether this is a self-message (same actor) */
isSelf: boolean
}
/** Narrow rectangle on a lifeline showing active processing */
export interface Activation {
actorId: string
x: number
topY: number
bottomY: number
width: number
}
export interface PositionedBlock {
type: Block['type']
label: string
x: number
y: number
width: number
height: number
/** Divider lines within the block (for alt/par) */
dividers: Array<{ y: number; label: string }>
}
export interface PositionedNote {
text: string
x: number
y: number
width: number
height: number
/** Actor IDs this note is attached to (for SVG attribution) */
actors?: string[]
/** Note position relative to actors (for SVG attribution) */
position?: 'left' | 'right' | 'over'
}
+71
View File
@@ -0,0 +1,71 @@
// ============================================================================
// Terminal display width — used by the ASCII renderer
//
// Terminals render most characters in 1 column, but East Asian wide
// characters (CJK ideographs, Hangul, kana, fullwidth forms) and
// emoji-presentation glyphs occupy 2 columns. The ASCII canvas is a
// cell-per-column grid, so its width math counts display columns rather
// than code units.
//
// Width is delegated to Bun.stringWidth (wcwidth-style, locale-insensitive):
// East Asian Wide/Fullwidth and emoji-presentation graphemes (incl. ZWJ
// sequences and regional-indicator flags) measure 2; box-drawing, the
// renderer's narrow arrowheads (◀ ▶ ►), and East Asian Ambiguous glyphs
// measure 1. This matches the renderer's structural glyph set exactly.
//
// Text is measured in grapheme clusters (Intl.Segmenter): a cluster that
// renders 2 columns is stored as its full string in one canvas cell
// followed by WIDE_PAD in the continuation cell it covers. The ASCII
// serializers drop WIDE_PAD cells when joining a row, so the emitted line
// occupies exactly as many terminal columns as the canvas has cells.
// ============================================================================
/**
* Placeholder occupying the second cell of a fullwidth glyph on the ASCII
* canvas. U+0000 cannot appear in parsed Mermaid labels, is treated as
* occupied label content by canvas merging, and is stripped at
* serialization time. Invariant: a WIDE_PAD cell always sits immediately
* right of its lead cell; canvas writes keep the pair atomic.
*/
export const WIDE_PAD = '\u0000'
const graphemeSegmenter = new Intl.Segmenter()
/**
* Display width of a string in terminal columns, summed over grapheme
* clusters so it always equals `toCells(text).length`. ASCII-only strings
* take a fast path.
*/
export function displayWidth(text: string): number {
let ascii = true
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) > 0x7e) {
ascii = false
break
}
}
if (ascii) return text.length
let width = 0
for (const seg of graphemeSegmenter.segment(text)) {
width += Bun.stringWidth(seg.segment) >= 2 ? 2 : 1
}
return width
}
/**
* Expand a string into ASCII-canvas cells: each 2-column grapheme cluster
* is stored whole in one cell and followed by WIDE_PAD, so that
* `cells.length === displayWidth(text)`. Per-character placement loops can
* iterate the result with plain cell offsets.
*/
export function toCells(text: string): string[] {
const cells: string[] = []
for (const seg of graphemeSegmenter.segment(text)) {
cells.push(seg.segment)
if (Bun.stringWidth(seg.segment) >= 2) {
cells.push(WIDE_PAD)
}
}
return cells
}
+164
View File
@@ -0,0 +1,164 @@
// ============================================================================
// Parsed graph — logical structure extracted from Mermaid text
// ============================================================================
export interface MermaidGraph {
direction: Direction
nodes: Map<string, MermaidNode>
edges: MermaidEdge[]
subgraphs: MermaidSubgraph[]
classDefs: Map<string, Record<string, string>>
/** Maps node IDs to their class names (from `class X className` or `:::className` shorthand) */
classAssignments: Map<string, string>
/** Maps node IDs to inline styles (from `style X fill:#f00,stroke:#333`) */
nodeStyles: Map<string, Record<string, string>>
/** Maps edge indices (or 'default') to inline styles from `linkStyle` directives */
linkStyles: Map<number | 'default', Record<string, string>>
}
export type Direction = 'TD' | 'TB' | 'LR' | 'BT' | 'RL'
export interface MermaidNode {
id: string
label: string
shape: NodeShape
}
export type NodeShape =
| 'rectangle'
| 'rounded'
| 'diamond'
| 'stadium'
| 'circle'
// Batch 1 additions
| 'subroutine' // [[text]] — double-bordered rectangle
| 'doublecircle' // (((text))) — concentric circles
| 'hexagon' // {{text}} — six-sided polygon
// Batch 2 additions
| 'cylinder' // [(text)] — database cylinder
| 'asymmetric' // >text] — flag/banner shape
| 'trapezoid' // [/text\] — wider bottom
| 'trapezoid-alt' // [\text/] — wider top
// Batch 3 state diagram pseudostates
| 'state-start' // filled circle (start pseudostate)
| 'state-end' // bullseye circle (end pseudostate)
export interface MermaidEdge {
source: string
target: string
label?: string
style: EdgeStyle
/** 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
}
export type EdgeStyle = 'solid' | 'dotted' | 'thick'
export interface MermaidSubgraph {
id: string
label: string
nodeIds: string[]
children: MermaidSubgraph[]
/** Optional direction override for this subgraph's internal layout */
direction?: Direction
}
// ============================================================================
// Positioned graph — after ELK layout, ready for SVG rendering
// ============================================================================
export interface PositionedGraph {
width: number
height: number
nodes: PositionedNode[]
edges: PositionedEdge[]
groups: PositionedGroup[]
}
export interface PositionedNode {
id: string
label: string
shape: NodeShape
x: number
y: number
width: number
height: number
/** Inline styles resolved from classDef + explicit `style` statements — override theme defaults */
inlineStyle?: Record<string, string>
}
export interface PositionedEdge {
source: string
target: string
label?: string
style: EdgeStyle
hasArrowStart: boolean
hasArrowEnd: boolean
/** Full path including bends — array of {x, y} points */
points: Point[]
/** Layout-computed label center position (avoids label-label collisions) */
labelPosition?: Point
/** Inline styles resolved from `linkStyle` directives — override theme defaults */
inlineStyle?: Record<string, string>
}
export interface Point {
x: number
y: number
}
export interface PositionedGroup {
id: string
label: string
x: number
y: number
width: number
height: number
children: PositionedGroup[]
}
// ============================================================================
// Render options — user-facing configuration
//
// Color theming uses CSS custom properties: --bg and --fg are required,
// optional enrichment variables (--line, --accent, --muted, --surface,
// --border) add richer color from Shiki themes or custom palettes.
// See src/theme.ts for the full variable system.
// ============================================================================
export interface RenderOptions {
/** Background color → CSS variable --bg. Default: '#FFFFFF' */
bg?: string
/** Foreground / primary text color → CSS variable --fg. Default: '#27272A' */
fg?: string
// -- Optional enrichment colors (fall back to color-mix from bg/fg) --
/** Edge/connector color → CSS variable --line */
line?: string
/** Arrow heads, highlights → CSS variable --accent */
accent?: string
/** Secondary text, edge labels → CSS variable --muted */
muted?: string
/** Node/box fill tint → CSS variable --surface */
surface?: string
/** Node/group stroke color → CSS variable --border */
border?: string
/** Font family for all text. Default: 'Inter' */
font?: string
/** Canvas padding in px. Default: 40 */
padding?: number
/** Horizontal spacing between sibling nodes. Default: 24 */
nodeSpacing?: number
/** Vertical spacing between layers. Default: 40 */
layerSpacing?: number
/** Spacing between disconnected components. Default: nodeSpacing (24) */
componentSpacing?: number
/** Render with transparent background (no background style on SVG). Default: false */
transparent?: boolean
/** Enable hover tooltips on chart data points (xychart only). Default: false */
interactive?: boolean
}
@@ -0,0 +1,140 @@
// ============================================================================
// XY Chart — shared color palette
//
// Generates monochromatic shades from the theme accent color.
// Series 0 = accent (or blue fallback). Series 1+ are darker/lighter
// shades of the same hue with subtle hue drift to stay in the same
// color family (like navy ↔ cyan from blue).
//
// Used by both the SVG and ASCII renderers.
// ============================================================================
/** Default accent for charts when the theme doesn't provide one. */
export const CHART_ACCENT_FALLBACK = '#3b82f6' // blue-500
// ---------------------------------------------------------------------------
// HSL ↔ Hex conversion
// ---------------------------------------------------------------------------
function hexToHsl(hex: string): [number, number, number] {
const h = hex.replace('#', '')
const ri = parseInt(h.substring(0, 2), 16) / 255
const gi = parseInt(h.substring(2, 4), 16) / 255
const bi = parseInt(h.substring(4, 6), 16) / 255
const max = Math.max(ri, gi, bi)
const min = Math.min(ri, gi, bi)
const l = (max + min) / 2
if (max === min) return [0, 0, l * 100]
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let hue: number
if (max === ri) hue = ((gi - bi) / d + (gi < bi ? 6 : 0)) / 6
else if (max === gi) hue = ((bi - ri) / d + 2) / 6
else hue = ((ri - gi) / d + 4) / 6
return [hue * 360, s * 100, l * 100]
}
function hslToHex(h: number, s: number, l: number): string {
const si = s / 100
const li = l / 100
const c = (1 - Math.abs(2 * li - 1)) * si
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = li - c / 2
let r: number, g: number, b: number
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// ---------------------------------------------------------------------------
// Hex ↔ RGB conversion
// ---------------------------------------------------------------------------
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
]
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check whether a string is a valid 6-digit hex color (e.g. "#3b82f6"). */
export function isValidHex(color: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(color)
}
/**
* Detect whether a background color is dark (lightness < 50%).
*/
export function isDarkBackground(bgHex: string): boolean {
return hexToHsl(bgHex)[2] < 50
}
/**
* Mix two hex colors in RGB space.
* `ratio` controls how much of `fgHex` shows: 0 = pure bg, 1 = pure fg.
* Equivalent to alpha-compositing fg over bg at the given opacity.
*/
export function mixHexColors(bgHex: string, fgHex: string, ratio: number): string {
const [br, bg, bb] = hexToRgb(bgHex)
const [fr, fg, fb] = hexToRgb(fgHex)
const inv = 1 - ratio
return rgbToHex(br * inv + fr * ratio, bg * inv + fg * ratio, bb * inv + fb * ratio)
}
/**
* Get the hex color for a series index.
* Index 0 returns the accent color as-is.
* Index 1+ alternate between darker and lighter shades of the same hue
* with subtle hue drift (±8-12° per tier) to stay in the same family.
*
* When `bgColor` is provided, shade direction adapts to the background:
* - Light bg: odd = darker, even = lighter (default)
* - Dark bg: odd = lighter, even = darker (so shades stay visible)
*/
export function getSeriesColor(index: number, accentColor: string, bgColor?: string): string {
if (index === 0) return accentColor
// Fall back to defaults when inputs aren't valid hex (e.g. CSS variable refs like "var(--accent)")
const safeAccent = isValidHex(accentColor) ? accentColor : CHART_ACCENT_FALLBACK
const safeBg = bgColor && isValidHex(bgColor) ? bgColor : undefined
const [h, s] = hexToHsl(safeAccent)
const chartS = Math.max(55, Math.min(85, s))
const tier = Math.ceil(index / 2)
const oddIndex = index % 2 === 1
// On dark backgrounds, flip: odd = lighter, even = darker
const dark = safeBg && isDarkBackground(safeBg) ? !oddIndex : oddIndex
const l = dark
? Math.max(25, 48 - tier * 13)
: Math.min(78, 55 + tier * 11)
// Subtle hue drift: darker shades shift slightly negative, lighter shift positive
const hShift = (dark ? -8 : 12) * tier
const newH = ((h + hShift) % 360 + 360) % 360
return hslToHex(newH, chartS, l)
}
@@ -0,0 +1,115 @@
import type { XYChart, XYAxis, XYChartSeries } from './types'
// ============================================================================
// XY Chart parser
//
// Parses Mermaid xychart-beta syntax into a typed XYChart structure.
//
// Supported directives:
// xychart-beta [horizontal]
// title "Chart Title"
// x-axis [label1, label2, ...] — categorical
// x-axis min --> max — numeric range
// x-axis "Axis Title" [label1, ...] — with title
// x-axis "Axis Title" min --> max — with title
// y-axis (same patterns)
// bar [val1, val2, ...]
// line [val1, val2, ...]
// ============================================================================
/**
* Parse a Mermaid xychart-beta diagram from preprocessed lines.
* Lines should already be trimmed and comment-stripped.
*/
export function parseXYChart(lines: string[]): XYChart {
const xAxis: XYAxis = {}
const yAxis: XYAxis = {}
const series: XYChartSeries[] = []
let title: string | undefined
let horizontal = false
for (const line of lines) {
// Header line — detect horizontal
if (/^xychart(-beta)?\b/i.test(line)) {
if (/\bhorizontal\b/i.test(line)) horizontal = true
continue
}
// Title
const titleMatch = line.match(/^title\s+"([^"]+)"/)
if (titleMatch) {
title = titleMatch[1]
continue
}
// x-axis with categories: x-axis "Title" [a, b, c] or x-axis [a, b, c]
const xCatMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/)
if (xCatMatch) {
if (xCatMatch[1]) xAxis.title = xCatMatch[1]
xAxis.categories = xCatMatch[2]!.split(',').map(s => s.trim())
continue
}
// x-axis with range: x-axis "Title" min --> max or x-axis min --> max
const xRangeMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/)
if (xRangeMatch) {
if (xRangeMatch[1]) xAxis.title = xRangeMatch[1]
xAxis.range = { min: parseFloat(xRangeMatch[2]!), max: parseFloat(xRangeMatch[3]!) }
continue
}
// y-axis with range: y-axis "Title" min --> max or y-axis min --> max
const yRangeMatch = line.match(/^y-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/)
if (yRangeMatch) {
if (yRangeMatch[1]) yAxis.title = yRangeMatch[1]
yAxis.range = { min: parseFloat(yRangeMatch[2]!), max: parseFloat(yRangeMatch[3]!) }
continue
}
// y-axis with just title (no range)
const yTitleOnly = line.match(/^y-axis\s+"([^"]+)"\s*$/)
if (yTitleOnly) {
yAxis.title = yTitleOnly[1]
continue
}
// bar [...]
const barMatch = line.match(/^bar\s+\[([^\]]+)\]/)
if (barMatch) {
series.push({ type: 'bar', data: parseNumericArray(barMatch[1]!) })
continue
}
// line [...]
const lineMatch = line.match(/^line\s+\[([^\]]+)\]/)
if (lineMatch) {
series.push({ type: 'line', data: parseNumericArray(lineMatch[1]!) })
continue
}
}
// Auto-derive y-axis range from data if not specified
if (!yAxis.range && series.length > 0) {
const allValues = series.flatMap(s => s.data)
let min = Math.min(...allValues)
let max = Math.max(...allValues)
const span = max - min || 1
// Add 10% padding
min = min - span * 0.1
max = max + span * 0.1
// Floor to 0 if all values are positive and min is close to 0
if (min > 0 && min < span * 0.5) min = 0
yAxis.range = { min, max }
}
// Fallback y-axis range
if (!yAxis.range) {
yAxis.range = { min: 0, max: 100 }
}
return { title, horizontal, xAxis, yAxis, series }
}
function parseNumericArray(str: string): number[] {
return str.split(',').map(s => parseFloat(s.trim()))
}
+150
View File
@@ -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
}
@@ -0,0 +1,413 @@
/**
* Comprehensive tests for class diagram arrow directions.
*
* Ensures all relationship types have correctly oriented arrows:
* - Inheritance/Realization: hollow triangles point toward parent/interface
* - Association/Dependency: filled arrows point from source to target
* - Composition/Aggregation: diamonds are omnidirectional
*/
import { describe, expect, test } from "bun:test";
import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index";
describe("Class Diagram Arrow Directions", () => {
// ============================================================================
// INHERITANCE (<|--)
// ============================================================================
describe("Inheritance (<|--)", () => {
test("parent above child - triangle points UP toward parent", () => {
const diagram = `classDiagram
Animal <|-- Dog`;
const result = renderMermaidAscii(diagram);
// Should contain upward triangle
expect(result).toContain("△");
expect(result).not.toContain("▽");
// Parent should be above child
const lines = result.split("\n");
const animalLine = lines.findIndex(l => l.includes("Animal"));
const dogLine = lines.findIndex(l => l.includes("Dog"));
expect(animalLine).toBeLessThan(dogLine);
});
test("multiple inheritance creates separate arrows", () => {
const diagram = `classDiagram
Animal <|-- Dog
Animal <|-- Cat
Dog <|-- Puppy`;
const result = renderMermaidAscii(diagram);
// Animal should be at top, then Dog/Cat, then Puppy
const lines = result.split("\n");
const animalLine = lines.findIndex(l => l.includes("Animal"));
const dogLine = lines.findIndex(l => l.includes("Dog"));
const catLine = lines.findIndex(l => l.includes("Cat"));
const puppyLine = lines.findIndex(l => l.includes("Puppy"));
expect(animalLine).toBeLessThan(dogLine);
expect(animalLine).toBeLessThan(catLine);
expect(dogLine).toBeLessThan(puppyLine);
});
test("multi-level inheritance - all triangles point UP", () => {
const diagram = `classDiagram
Animal <|-- Mammal
Mammal <|-- Dog`;
const result = renderMermaidAscii(diagram);
// Verify ordering: Animal > Mammal > Dog (top to bottom)
const lines = result.split("\n");
const animalLine = lines.findIndex(l => l.includes("Animal"));
const mammalLine = lines.findIndex(l => l.includes("Mammal"));
const dogLine = lines.findIndex(l => l.includes("Dog"));
expect(animalLine).toBeLessThan(mammalLine);
expect(mammalLine).toBeLessThan(dogLine);
// All triangles should point up
expect(result.match(/△/g)?.length).toBe(2);
});
test("multiple inheritance from same parent", () => {
const diagram = `classDiagram
Animal <|-- Dog
Animal <|-- Cat`;
const result = renderMermaidAscii(diagram);
// Animal should be above both children
const lines = result.split("\n");
const animalLine = lines.findIndex(l => l.includes("Animal"));
const dogLine = lines.findIndex(l => l.includes("Dog"));
const catLine = lines.findIndex(l => l.includes("Cat"));
expect(animalLine).toBeLessThan(dogLine);
expect(animalLine).toBeLessThan(catLine);
// Should have at least one triangle pointing up (may merge visually)
expect(result).toContain("△");
});
test("ASCII mode uses ^ for upward triangle", () => {
const diagram = `classDiagram
Animal <|-- Dog`;
const result = renderMermaidAscii(diagram, { useAscii: true });
expect(result).toContain("^");
expect(result).not.toContain("v");
});
});
// ============================================================================
// ASSOCIATION (-->)
// ============================================================================
describe("Association (-->)", () => {
test("source above target - arrow points DOWN", () => {
const diagram = `classDiagram
Person --> Address`;
const result = renderMermaidAscii(diagram);
// Should contain downward arrow
expect(result).toContain("▼");
expect(result).not.toContain("▲");
// Person should be above Address
const lines = result.split("\n");
const personLine = lines.findIndex(l => l.includes("Person"));
const addressLine = lines.findIndex(l => l.includes("Address"));
expect(personLine).toBeLessThan(addressLine);
});
test("multiple associations from same source", () => {
const diagram = `classDiagram
Person --> Address
Person --> Phone`;
const result = renderMermaidAscii(diagram);
// Person should be above both targets
const lines = result.split("\n");
const personLine = lines.findIndex(l => l.includes("Person"));
const addressLine = lines.findIndex(l => l.includes("Address"));
const phoneLine = lines.findIndex(l => l.includes("Phone"));
expect(personLine).toBeLessThan(addressLine);
expect(personLine).toBeLessThan(phoneLine);
});
test("chain of associations", () => {
const diagram = `classDiagram
A --> B
B --> C`;
const result = renderMermaidAscii(diagram);
// A > B > C ordering
const lines = result.split("\n");
const aLine = lines.findIndex(l => l.includes("│ A │"));
const bLine = lines.findIndex(l => l.includes("│ B │"));
const cLine = lines.findIndex(l => l.includes("│ C │"));
expect(aLine).toBeLessThan(bLine);
expect(bLine).toBeLessThan(cLine);
// Both arrows point down
expect(result.match(/▼/g)?.length).toBe(2);
});
test("ASCII mode uses v for downward arrow", () => {
const diagram = `classDiagram
Person --> Address`;
const result = renderMermaidAscii(diagram, { useAscii: true });
expect(result).toContain("v");
expect(result).not.toContain("^");
});
});
// ============================================================================
// DEPENDENCY (..>)
// ============================================================================
describe("Dependency (..>)", () => {
test("source above target - arrow points DOWN", () => {
const diagram = `classDiagram
Client ..> Server`;
const result = renderMermaidAscii(diagram);
expect(result).toContain("▼");
expect(result).not.toContain("▲");
const lines = result.split("\n");
const clientLine = lines.findIndex(l => l.includes("Client"));
const serverLine = lines.findIndex(l => l.includes("Server"));
expect(clientLine).toBeLessThan(serverLine);
});
test("multiple dependencies", () => {
const diagram = `classDiagram
Client ..> Server
Client ..> Database`;
const result = renderMermaidAscii(diagram);
const lines = result.split("\n");
const clientLine = lines.findIndex(l => l.includes("Client"));
const serverLine = lines.findIndex(l => l.includes("Server"));
const dbLine = lines.findIndex(l => l.includes("Database"));
expect(clientLine).toBeLessThan(serverLine);
expect(clientLine).toBeLessThan(dbLine);
});
test("ASCII mode uses v for downward arrow", () => {
const diagram = `classDiagram
Client ..> Server`;
const result = renderMermaidAscii(diagram, { useAscii: true });
expect(result).toContain("v");
});
});
// ============================================================================
// REALIZATION (..|>)
// ============================================================================
describe("Realization (..|>)", () => {
test("interface above implementation - triangle points UP", () => {
// Circle ..|> Shape means "Circle implements Shape"
// Shape (interface/parent) should be placed ABOVE Circle (implementation/child)
const diagram = `classDiagram
Circle ..|> Shape`;
const result = renderMermaidAscii(diagram);
// Shape (interface) should be above Circle (implementation)
const lines = result.split("\n");
const shapeLine = lines.findIndex(l => l.includes("Shape"));
const circleLine = lines.findIndex(l => l.includes("Circle"));
expect(shapeLine).toBeLessThan(circleLine);
expect(result).toContain("△");
});
test("realization with <|.. syntax (marker at from end)", () => {
// Shape <|.. Circle means "Circle implements Shape" (same as Circle ..|> Shape)
const diagram = `classDiagram
Shape <|.. Circle`;
const result = renderMermaidAscii(diagram);
// Shape (interface) should be above Circle (implementation)
const lines = result.split("\n");
const shapeLine = lines.findIndex(l => l.includes("Shape"));
const circleLine = lines.findIndex(l => l.includes("Circle"));
expect(shapeLine).toBeLessThan(circleLine);
expect(result).toContain("△");
});
test("multiple implementations", () => {
// Circle and Square both implement Shape
const diagram = `classDiagram
Circle ..|> Shape
Square ..|> Shape`;
const result = renderMermaidAscii(diagram);
// Shape (interface) above both implementations
const lines = result.split("\n");
const shapeLine = lines.findIndex(l => l.includes("Shape"));
const circleLine = lines.findIndex(l => l.includes("Circle"));
const squareLine = lines.findIndex(l => l.includes("Square"));
expect(shapeLine).toBeLessThan(circleLine);
expect(shapeLine).toBeLessThan(squareLine);
// At least one triangle (may merge visually if same connection point)
expect(result).toContain("△");
});
});
// ============================================================================
// COMPOSITION & AGGREGATION (omnidirectional diamonds)
// ============================================================================
describe("Composition (*--) and Aggregation (o--)", () => {
test("composition - diamond is omnidirectional", () => {
const diagram = `classDiagram
Car *-- Engine`;
const result = renderMermaidAscii(diagram);
// Should contain filled diamond
expect(result).toContain("◆");
});
test("aggregation - hollow diamond is omnidirectional", () => {
const diagram = `classDiagram
Team o-- Player`;
const result = renderMermaidAscii(diagram);
// Should contain hollow diamond
expect(result).toContain("◇");
});
});
// ============================================================================
// MIXED SCENARIOS
// ============================================================================
describe("Mixed Relationship Scenarios", () => {
test("all 6 relationship types together", () => {
const diagram = `classDiagram
A <|-- B : inheritance
C *-- D : composition
E o-- F : aggregation
G --> H : association
I ..> J : dependency
K ..|> L : realization`;
const result = renderMermaidAscii(diagram);
// Upward triangles for inheritance and realization
expect(result.match(/△/g)?.length).toBe(2);
// Downward arrows for association and dependency
expect(result.match(/▼/g)?.length).toBe(2);
// Diamonds for composition and aggregation
expect(result).toContain("◆");
expect(result).toContain("◇");
});
test("inheritance with association - different arrow directions", () => {
const diagram = `classDiagram
Animal <|-- Dog
Dog --> Food`;
const result = renderMermaidAscii(diagram);
// Should have both up triangle (inheritance) and down arrow (association)
expect(result).toContain("△");
expect(result).toContain("▼");
});
test("circular reference creates valid layout", () => {
const diagram = `classDiagram
A --> B
B --> C
C ..> A`;
const result = renderMermaidAscii(diagram);
// Cycles may create mixed arrow directions (up and down) to avoid overlaps
// Just verify arrows are present and classes are rendered
const hasUpArrow = result.includes("▲");
const hasDownArrow = result.includes("▼");
expect(hasUpArrow || hasDownArrow).toBe(true);
expect(result).toContain("│ A │");
expect(result).toContain("│ B │");
expect(result).toContain("│ C │");
});
});
// ============================================================================
// ASCII vs UNICODE CONSISTENCY
// ============================================================================
describe("ASCII and Unicode Mode Consistency", () => {
test("same diagram produces consistent layouts in both modes", () => {
const diagram = `classDiagram
Animal <|-- Dog
Person --> Address`;
const unicode = renderMermaidAscii(diagram);
const ascii = renderMermaidAscii(diagram, { useAscii: true });
// Both should have same node ordering
const unicodeLines = unicode.split("\n");
const asciiLines = ascii.split("\n");
const uAnimal = unicodeLines.findIndex(l => l.includes("Animal"));
const uDog = unicodeLines.findIndex(l => l.includes("Dog"));
const aPerson = asciiLines.findIndex(l => l.includes("Person"));
const aAddress = asciiLines.findIndex(l => l.includes("Address"));
expect(uAnimal).toBeLessThan(uDog);
expect(aPerson).toBeLessThan(aAddress);
// Unicode has △ and ▼, ASCII has ^ and v
expect(unicode).toContain("△");
expect(unicode).toContain("▼");
expect(ascii).toContain("^");
expect(ascii).toContain("v");
});
});
// ============================================================================
// EDGE CASES
// ============================================================================
describe("Edge Cases", () => {
test("single inheritance relationship", () => {
const diagram = `classDiagram
A <|-- B`;
const result = renderMermaidAscii(diagram);
expect(result).toContain("△");
const lines = result.split("\n");
const aLine = lines.findIndex(l => l.includes("│ A │"));
const bLine = lines.findIndex(l => l.includes("│ B │"));
expect(aLine).toBeLessThan(bLine);
});
test("classes with members maintain arrow directions", () => {
const diagram = `classDiagram
class Animal {
+String name
+eat() void
}
class Dog {
+bark() void
}
Animal <|-- Dog`;
const result = renderMermaidAscii(diagram);
expect(result).toContain("△");
const lines = result.split("\n");
const animalLine = lines.findIndex(l => l.includes("Animal"));
const dogLine = lines.findIndex(l => l.includes("Dog"));
expect(animalLine).toBeLessThan(dogLine);
});
});
});
@@ -0,0 +1,149 @@
// ============================================================================
// ASCII edge style tests — dotted and thick line rendering
// ============================================================================
import { describe, expect, it } from "bun:test";
import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index";
describe("ASCII edge styles", () => {
describe("solid edges (default)", () => {
it("renders solid edges with ─ in unicode mode", () => {
const result = renderMermaidAscii(`
graph LR
A --> B
`);
expect(result).toContain("─");
expect(result).not.toContain("┄");
expect(result).not.toContain("━");
});
it("renders solid edges with - in ascii mode", () => {
const result = renderMermaidAscii(
`
graph LR
A --> B
`,
{ useAscii: true },
);
expect(result).toContain("-");
});
});
describe("dotted edges (-.->)", () => {
it("renders dotted edges with ┄ in unicode mode", () => {
const result = renderMermaidAscii(`
graph LR
A -.-> B
`);
// Should contain dotted horizontal line character
expect(result).toContain("┄");
});
it("renders dotted edges with . in ascii mode", () => {
const result = renderMermaidAscii(
`
graph LR
A -.-> B
`,
{ useAscii: true },
);
// Should contain dots for dotted lines
expect(result).toContain(".");
});
it("renders dotted vertical edges with ┆ in unicode mode", () => {
const result = renderMermaidAscii(`
graph TD
A -.-> B
`);
// Should contain dotted vertical line character
expect(result).toContain("┆");
});
it("renders dotted vertical edges with : in ascii mode", () => {
const result = renderMermaidAscii(
`
graph TD
A -.-> B
`,
{ useAscii: true },
);
// Should contain colons for dotted vertical lines
expect(result).toContain(":");
});
it("renders dotted edges with labels", () => {
const result = renderMermaidAscii(`
graph LR
A -.->|optional| B
`);
expect(result).toContain("┄");
expect(result).toContain("optional");
});
});
describe("thick edges (==>)", () => {
it("renders thick edges with ━ in unicode mode", () => {
const result = renderMermaidAscii(`
graph LR
A ==> B
`);
// Should contain thick horizontal line character
expect(result).toContain("━");
});
it("renders thick edges with = in ascii mode", () => {
const result = renderMermaidAscii(
`
graph LR
A ==> B
`,
{ useAscii: true },
);
// Should contain equals for thick lines
expect(result).toContain("=");
});
it("renders thick vertical edges with ┃ in unicode mode", () => {
const result = renderMermaidAscii(`
graph TD
A ==> B
`);
// Should contain thick vertical line character
expect(result).toContain("┃");
});
});
describe("mixed edge styles", () => {
it("renders different styles in the same diagram", () => {
const result = renderMermaidAscii(`
graph LR
A --> B
B -.-> C
C ==> D
`);
// Should have all three line types
expect(result).toContain("─"); // solid
expect(result).toContain("┄"); // dotted
expect(result).toContain("━"); // thick
});
it("renders mixed styles in ascii mode", () => {
const result = renderMermaidAscii(
`
graph LR
A --> B
B -.-> C
C ==> D
`,
{ useAscii: true },
);
// Note: ASCII mode uses - for solid, . for dotted, = for thick
// We just check that the diagram renders without error
expect(result).toContain("A");
expect(result).toContain("B");
expect(result).toContain("C");
expect(result).toContain("D");
});
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";
import { renderMermaidAscii } from "../../src/mermaid-ascii";
// The vendored renderer is ASCII-only, so inline formatting (HTML tags and
// markdown emphasis) is reduced to plain text rather than preserved — otherwise
// the raw tags/markers would print inside the node box. Exercised through the
// public `@oh-my-pi/pi-utils` wrapper so the dependency-removal path stays covered.
describe("mermaid ASCII inline-formatting stripping", () => {
const render = (label: string): string => renderMermaidAscii(`flowchart TD\n A[${label}]`, { colorMode: "none" });
it("strips markdown bold/italic/strikethrough markers, keeping the text", () => {
const out = render("**bold** *em* ~~gone~~");
expect(out).toContain("bold");
expect(out).toContain("em");
expect(out).toContain("gone");
expect(out).not.toContain("**");
expect(out).not.toContain("~~");
expect(out).not.toContain("*em*");
});
it("strips inline HTML formatting tags, keeping the text", () => {
const out = render("<b>strong</b> and <i>slanted</i>");
expect(out).toContain("strong");
expect(out).toContain("slanted");
expect(out).not.toContain("<b>");
expect(out).not.toContain("</b>");
expect(out).not.toContain("<i>");
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* Golden-file tests for the ASCII/Unicode renderer.
*
* Ported from AlexanderGrooff/mermaid-ascii cmd/graph_test.go.
* Each .txt file contains mermaid input above a `---` separator
* and the expected ASCII/Unicode output below it.
*
* Test data: 44 ASCII files + 22 Unicode files = 66 total.
*/
import { describe, expect, it } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index";
import { DIAGONAL_CHARS, hasDiagonalLines } from "../../src/vendor/mermaid-ascii/ascii/validate";
// ============================================================================
// Test case parser — matches Go's testutil.ReadTestCase format
// ============================================================================
interface TestCase {
mermaid: string;
expected: string;
paddingX: number;
paddingY: number;
}
/**
* Parse a golden test file into its components.
* Format:
* [paddingX=N] (optional)
* [paddingY=N] (optional)
* <mermaid code>
* ---
* <expected output>
*/
function parseTestCase(content: string): TestCase {
const tc: TestCase = { mermaid: "", expected: "", paddingX: 5, paddingY: 5 };
const lines = content.split("\n");
const paddingRegex = /^(?:padding([xy]))\s*=\s*(\d+)\s*$/i;
let inMermaid = true;
let mermaidStarted = false;
const mermaidLines: string[] = [];
const expectedLines: string[] = [];
for (const line of lines) {
if (line === "---") {
inMermaid = false;
continue;
}
if (inMermaid) {
const trimmed = line.trim();
// Before mermaid code starts, parse padding directives and skip blanks
if (!mermaidStarted) {
if (trimmed === "") continue;
const match = trimmed.match(paddingRegex);
if (match) {
const value = parseInt(match[2]!, 10);
if (match[1]!.toLowerCase() === "x") {
tc.paddingX = value;
} else {
tc.paddingY = value;
}
continue;
}
}
mermaidStarted = true;
mermaidLines.push(line);
} else {
expectedLines.push(line);
}
}
tc.mermaid = `${mermaidLines.join("\n")}\n`;
// Strip final trailing newline (matches Go's strings.TrimSuffix(expected, "\n"))
let expected = expectedLines.join("\n");
if (expected.endsWith("\n")) {
expected = expected.slice(0, -1);
}
tc.expected = expected;
return tc;
}
// ============================================================================
// Whitespace normalization — matches Go's testutil.NormalizeWhitespace
// ============================================================================
/**
* Normalize whitespace for comparison:
* - Trim trailing spaces from each line
* - Remove leading/trailing blank lines
*/
function normalizeWhitespace(s: string): string {
const lines = s.split("\n");
const normalized = lines.map(l => l.trimEnd());
// Remove leading blank lines
while (normalized.length > 0 && normalized[0] === "") {
normalized.shift();
}
// Remove trailing blank lines
while (normalized.length > 0 && normalized[normalized.length - 1] === "") {
normalized.pop();
}
return normalized.join("\n");
}
/** Replace spaces with middle dots for clearer diff output. */
function visualizeWhitespace(s: string): string {
return s.replaceAll(" ", "·");
}
// ============================================================================
// Test runner — dynamically loads all golden files from testdata directories
// ============================================================================
function runGoldenTests(dir: string, useAscii: boolean): void {
const files = readdirSync(dir)
.filter(f => f.endsWith(".txt"))
.sort();
for (const file of files) {
const testName = file.replace(".txt", "");
it(testName, () => {
const content = readFileSync(join(dir, file), "utf-8");
const tc = parseTestCase(content);
const actual = renderMermaidAscii(tc.mermaid, {
useAscii,
paddingX: tc.paddingX,
paddingY: tc.paddingY,
});
const normalizedExpected = normalizeWhitespace(tc.expected);
const normalizedActual = normalizeWhitespace(actual);
if (normalizedExpected !== normalizedActual) {
const expectedVis = visualizeWhitespace(normalizedExpected);
const actualVis = visualizeWhitespace(normalizedActual);
expect(actualVis).toBe(expectedVis);
}
});
}
}
// ============================================================================
// Test suites
// ============================================================================
const testdataDir = join(import.meta.dir, "testdata");
describe("ASCII rendering", () => {
runGoldenTests(join(testdataDir, "ascii"), true);
});
describe("Unicode rendering", () => {
runGoldenTests(join(testdataDir, "unicode"), false);
});
// ============================================================================
// Config behavior tests — ported from Go's TestGraphUseAsciiConfig
// ============================================================================
describe("Config behavior", () => {
const mermaidInput = "graph LR\nA --> B";
it("ASCII and Unicode outputs should differ", () => {
const asciiOutput = renderMermaidAscii(mermaidInput, { useAscii: true });
const unicodeOutput = renderMermaidAscii(mermaidInput, { useAscii: false });
expect(asciiOutput).not.toBe(unicodeOutput);
});
it("ASCII output should not contain Unicode box-drawing characters", () => {
const output = renderMermaidAscii(mermaidInput, { useAscii: true });
expect(output).not.toContain("┌");
expect(output).not.toContain("─");
expect(output).not.toContain("│");
});
it("Unicode output should contain Unicode box-drawing characters", () => {
const output = renderMermaidAscii(mermaidInput, { useAscii: false });
const hasUnicode = output.includes("┌") || output.includes("─") || output.includes("│");
expect(hasUnicode).toBe(true);
});
});
// ============================================================================
// Diagonal validation — ensures all edges use orthogonal Manhattan routing
// ============================================================================
describe("Diagonal validation", () => {
const asciiDir = join(testdataDir, "ascii");
const unicodeDir = join(testdataDir, "unicode");
it("ASCII output should never contain diagonal characters", () => {
// Test all ASCII golden files
const files = readdirSync(asciiDir).filter(f => f.endsWith(".txt"));
for (const file of files) {
const content = readFileSync(join(asciiDir, file), "utf-8");
const { mermaid, paddingX, paddingY } = parseTestCase(content);
const output = renderMermaidAscii(mermaid, {
useAscii: true,
boxBorderPadding: paddingX,
paddingY: paddingY,
});
// Check for diagonal characters
for (const char of DIAGONAL_CHARS.ascii) {
expect(output).not.toContain(char);
}
}
});
it("Unicode output should never contain diagonal characters", () => {
// Test all Unicode golden files
const files = readdirSync(unicodeDir).filter(f => f.endsWith(".txt"));
for (const file of files) {
const content = readFileSync(join(unicodeDir, file), "utf-8");
const { mermaid, paddingX, paddingY } = parseTestCase(content);
const output = renderMermaidAscii(mermaid, {
useAscii: false,
boxBorderPadding: paddingX,
paddingY: paddingY,
});
// Check for diagonal characters
for (const char of DIAGONAL_CHARS.unicode) {
expect(output).not.toContain(char);
}
}
});
it("hasDiagonalLines utility correctly detects diagonal characters", () => {
// Should detect ASCII diagonals
expect(hasDiagonalLines("A / B")).toBe(true);
expect(hasDiagonalLines("A \\ B")).toBe(true);
// Should detect Unicode diagonals
expect(hasDiagonalLines("A ╱ B")).toBe(true);
expect(hasDiagonalLines("A ╲ B")).toBe(true);
// Should not flag clean output
expect(hasDiagonalLines("┌───┐\n│ A │\n└───┘")).toBe(false);
expect(hasDiagonalLines("+---+\n| A |\n+---+")).toBe(false);
});
});
@@ -0,0 +1,199 @@
import { describe, expect, it } from "bun:test";
import { renderMermaidAscii } from "../../src/vendor/mermaid-ascii/ascii/index";
describe("ASCII multi-line labels", () => {
describe("flowchart nodes", () => {
it("renders multi-line node labels", () => {
const ascii = renderMermaidAscii("graph TD\n A[Line1<br>Line2]", { useAscii: false });
expect(ascii).toContain("Line1");
expect(ascii).toContain("Line2");
// Lines should be on different rows
const lines = ascii.split("\n");
const line1Row = lines.findIndex(l => l.includes("Line1"));
const line2Row = lines.findIndex(l => l.includes("Line2"));
expect(line2Row).toBeGreaterThan(line1Row);
});
it("handles 3+ line labels", () => {
const ascii = renderMermaidAscii("graph TD\n A[A<br>B<br>C]", { useAscii: false });
expect(ascii).toContain("A");
expect(ascii).toContain("B");
expect(ascii).toContain("C");
// Verify vertical ordering
const lines = ascii.split("\n");
const aRow = lines.findIndex(l => l.includes("A") && !l.includes("─") && !l.includes("-"));
const bRow = lines.findIndex(l => l.includes("B"));
const cRow = lines.findIndex(l => l.includes("C"));
expect(bRow).toBeGreaterThan(aRow);
expect(cRow).toBeGreaterThan(bRow);
});
it("renders in ASCII mode (not Unicode)", () => {
const ascii = renderMermaidAscii("graph TD\n A[Line1<br>Line2]", { useAscii: true });
expect(ascii).toContain("Line1");
expect(ascii).toContain("Line2");
// Should use ASCII box characters
expect(ascii).toContain("+");
expect(ascii).toContain("-");
});
});
describe("flowchart edge labels", () => {
it("renders multi-line edge labels", () => {
const ascii = renderMermaidAscii("graph TD\n A --> B\n A -->|Line1<br>Line2| C", { useAscii: false });
expect(ascii).toContain("Line1");
expect(ascii).toContain("Line2");
});
});
describe("flowchart subgraph labels", () => {
it("renders multi-line subgraph labels", () => {
const ascii = renderMermaidAscii(
`graph TD
subgraph sg [Group<br>Header]
A[Node]
end
`,
{ useAscii: false },
);
expect(ascii).toContain("Group");
expect(ascii).toContain("Header");
});
});
describe("sequence diagram", () => {
it("renders multi-line actor labels", () => {
const ascii = renderMermaidAscii(
`sequenceDiagram
participant A as Actor<br>One
A->>A: msg
`,
{ useAscii: false },
);
expect(ascii).toContain("Actor");
expect(ascii).toContain("One");
});
it("renders multi-line message labels", () => {
const ascii = renderMermaidAscii(
`sequenceDiagram
participant A
participant B
A->>B: Line1<br>Line2
`,
{ useAscii: false },
);
expect(ascii).toContain("Line1");
expect(ascii).toContain("Line2");
});
it("preserves existing note multi-line support", () => {
const ascii = renderMermaidAscii(
`sequenceDiagram
participant A
A->>A: self
Note over A: Note line 1<br>Note line 2
`,
{ useAscii: false },
);
expect(ascii).toContain("Note line 1");
expect(ascii).toContain("Note line 2");
});
});
describe("class diagram", () => {
it("renders multi-line class names", () => {
const ascii = renderMermaidAscii(
`classDiagram
class MyClass["Long<br>Name"]
`,
{ useAscii: false },
);
expect(ascii).toContain("Long");
expect(ascii).toContain("Name");
});
it("renders multi-line relationship labels", () => {
const ascii = renderMermaidAscii(
`classDiagram
A --> B : uses<br>implements
`,
{ useAscii: false },
);
expect(ascii).toContain("uses");
expect(ascii).toContain("implements");
});
});
describe("ER diagram", () => {
it("renders multi-line entity names", () => {
const ascii = renderMermaidAscii(
`erDiagram
"Entity<br>Name" {
string id
}
`,
{ useAscii: false },
);
expect(ascii).toContain("Entity");
expect(ascii).toContain("Name");
});
it("renders multi-line relationship labels", () => {
const ascii = renderMermaidAscii(
`erDiagram
A ||--o{ B : "has<br>many"
`,
{ useAscii: false },
);
expect(ascii).toContain("has");
expect(ascii).toContain("many");
});
});
describe("edge cases", () => {
it("handles empty lines from consecutive <br>", () => {
const ascii = renderMermaidAscii("graph TD\n A[Line1<br><br>Line3]", { useAscii: false });
expect(ascii).toContain("Line1");
expect(ascii).toContain("Line3");
});
it("handles single-line labels (no <br>)", () => {
const ascii = renderMermaidAscii("graph TD\n A[SingleLine]", { useAscii: false });
expect(ascii).toContain("SingleLine");
});
it("handles very long lines", () => {
const long = "A".repeat(30);
const ascii = renderMermaidAscii(`graph TD\n A[${long}<br>Short]`, { useAscii: false });
expect(ascii).toContain(long);
expect(ascii).toContain("Short");
});
it("handles mixed short and long lines", () => {
const ascii = renderMermaidAscii("graph TD\n A[Short<br>VeryLongSecondLine<br>Med]", { useAscii: false });
expect(ascii).toContain("Short");
expect(ascii).toContain("VeryLongSecondLine");
expect(ascii).toContain("Med");
});
});
describe("multiline-utils functions", () => {
it("splitLines splits on newlines", () => {
// Test through the rendering pipeline
const ascii = renderMermaidAscii("graph TD\n A[One<br>Two<br>Three]", { useAscii: false });
const lines = ascii.split("\n");
// All three words should appear on separate lines
expect(lines.some(l => l.includes("One"))).toBe(true);
expect(lines.some(l => l.includes("Two"))).toBe(true);
expect(lines.some(l => l.includes("Three"))).toBe(true);
});
it("maxLineWidth uses longest line for box sizing", () => {
// Box should be wide enough for the longest line
const ascii = renderMermaidAscii("graph TD\n A[X<br>LongLine<br>Y]", { useAscii: false });
// The box should contain LongLine without truncation
expect(ascii).toContain("LongLine");
});
});
});
@@ -0,0 +1,18 @@
graph LR
A --> B & C
---
+---+ +---+
| | | |
| A |---->| B |
| | | |
+---+ +---+
|
|
|
|
|
| +---+
| | |
+------>| C |
| |
+---+
@@ -0,0 +1,18 @@
graph LR
A & B --> C & D
---
+---+ +---+
| | | |
| A |--+->| C |
| | | | |
+---+ | +---+
| |
| |
+----+
| |
| |
+---+ | +---+
| | | | |
| B |--+->| D |
| | | |
+---+ +---+
@@ -0,0 +1,18 @@
graph LR
A & B --> C
---
+---+ +---+
| | | |
| A |---->| C |
| | | |
+---+ +---+
^
|
|
|
|
+---+ |
| | |
| B |-------+
| |
+---+
@@ -0,0 +1,18 @@
graph TD
A & B --> C
---
+---+ +---+
| | | |
| A | | B |
| | | |
+---+ +---+
| |
| |
+---------+
|
v
+---+
| |
| C |
| |
+---+
@@ -0,0 +1,18 @@
graph TD
A --> B & C
---
+---+
| |
| A |
| |
+---+
|
|
+---------+
| |
v v
+---+ +---+
| | | |
| B | | C |
| | | |
+---+ +---+
@@ -0,0 +1,18 @@
graph LR
A & B
---
+---+
| |
| A |
| |
+---+
+---+
| |
| B |
| |
+---+
@@ -0,0 +1,10 @@
graph LR
A --> B --> C --> A
---
+---+ +---+ +---+
| | | | | |
| A |---->| B |---->| C |
| | | | | |
+---+ +---+ +---+
^ |
+-------------------+
@@ -0,0 +1,22 @@
graph LR
A --> B
B --> C
A --> C
B --> D
C --> D
---
+---+ +---+ +---+
| | | | | |
| A |---->| B |---->| D |
| | | | | |
+---+ +---+ +---+
| | ^
| | |
| | |
| | |
| v |
| +---+ |
| | | |
+------>| C |-------+
| |
+---+
@@ -0,0 +1,22 @@
graph LR
A --> B
B --> C
A --> C
B --> D
D --> C
---
+---+ +---+ +---+
| | | | | |
| A |---->| B |--+->| D |
| | | | | | |
+---+ +---+ | +---+
| | |
| | |
| +----+
| |
| v
| +---+
| | |
+------>| C |
| |
+---+
@@ -0,0 +1,20 @@
paddingX=5
paddingY=3
graph LR
A --> B & C
B --> C & D
D --> C
---
+---+ +---+ +---+
| | | | | |
| A |---->| B |--+->| D |
| | | | | | |
+---+ +---+ | +---+
| | |
| +----+
| v
| +---+
| | |
+------>| C |
| |
+---+
@@ -0,0 +1,17 @@
classDiagram
A <|-- B : inherits
C *-- D : owns
E o-- F : has
G --> H : uses
I ..> J : depends
K ..|> L : implements
---
+---+ +---+ +---+ +---+ +---+ +---+
| A | | C | | E | | G | | I | | L |
+---+ +---+ +---+ +---+ +---+ +---+
^ * o | : ^
inherit owns has uses depend implements
| | | v v :
+---+ +---+ +---+ +---+ +---+ +---+
| B | | D | | F | | H | | J | | K |
+---+ +---+ +---+ +---+ +---+ +---+
@@ -0,0 +1,31 @@
classDiagram
class Shape {
<<abstract>>
+draw() void
}
class Circle {
+radius int
+draw() void
}
Shape <|-- Circle
---
+--------------+
| <<abstract>> |
| Shape |
+--------------+
| |
+--------------+
| +draw: void |
+--------------+
^
|
|
+--------------+
| Circle |
+--------------+
| +int: radius |
+--------------+
| +draw: void |
+--------------+
@@ -0,0 +1,14 @@
classDiagram
Student --> Course : enrolls
---
+---------+
| Student |
+---------+
|
enrolls
v
+--------+
| Course |
+--------+
@@ -0,0 +1,15 @@
classDiagram
class Animal {
+String name
+eat() void
}
---
+---------------+
| Animal |
+---------------+
| +name: String |
+---------------+
| +eat: void |
+---------------+
@@ -0,0 +1,14 @@
classDiagram
Client ..> Server : uses
---
+--------+
| Client |
+--------+
:
uses
v
+--------+
| Server |
+--------+
@@ -0,0 +1,22 @@
classDiagram
Animal <|-- Dog
Animal : +String name
Dog : +bark() void
---
+---------------+
| Animal |
+---------------+
| +name: String |
+---------------+
^
--
|
+-------------+
| Dog |
+-------------+
| |
+-------------+
| +bark: void |
+-------------+
@@ -0,0 +1,21 @@
classDiagram
class BankAccount {
+String owner
-int balance
+deposit(int amount) void
+withdraw(int amount) bool
-validate() bool
}
---
+-----------------+
| BankAccount |
+-----------------+
| +owner: String |
| -balance: int |
+-----------------+
| +deposit: void |
| +withdraw: bool |
| -validate: bool |
+-----------------+
+23
View File
@@ -0,0 +1,23 @@
graph LR
%% This is a comment
A --> B
%% Another comment
B --> C
A --> C
%% Final comment
---
+---+ +---+
| | | |
| A |---->| B |
| | | |
+---+ +---+
| |
| |
| |
| |
| v
| +---+
| | |
+------>| C |
| |
+---+
@@ -0,0 +1,10 @@
paddingX=2
paddingY=1
graph LR
A --> B
---
+---+ +---+
| | | |
| A |->| B |
| | | |
+---+ +---+
@@ -0,0 +1,19 @@
graph TD
A[Server] --> B[Client]
C[Server] --> D[Client]
---
+--------+ +--------+
| | | |
| Server | | Server |
| | | |
+--------+ +--------+
| |
| |
| |
| |
v v
+--------+ +--------+
| | | |
| Client | | Client |
| | | |
+--------+ +--------+
@@ -0,0 +1,19 @@
erDiagram
CUSTOMER {
string name PK
string email UK
int age
}
ORDER {
int id PK
string status
}
CUSTOMER ||--o{ ORDER : places
---
+-----------------+ +------------------+
| CUSTOMER | | ORDER |
+-----------------+ +------------------+
| PK string name ||---o<| PK int id |
| UK string email |places| string status |
| int age | +------------------+
+-----------------+
@@ -0,0 +1,8 @@
erDiagram
CUSTOMER ||--o{ ORDER : places
---
+----------+ +-------+
| CUSTOMER ||---o<| ORDER |
+----------+places+-------+
@@ -0,0 +1,16 @@
erDiagram
PERSON ||--o{ ADDRESS : lives_at
PERSON {
string name PK
}
ADDRESS {
string street
string city
}
---
+----------------+ +------------------+
| PERSON | | ADDRESS |
+----------------+|---o<+------------------+
| PK string name |lives_| string street |
+----------------+ | string city |
+------------------+
@@ -0,0 +1,29 @@
flowchart TB
A --> B
B --> C
---
+---+
| |
| A |
| |
+---+
|
|
|
|
v
+---+
| |
| B |
| |
+---+
|
|
|
|
v
+---+
| |
| C |
| |
+---+
@@ -0,0 +1,28 @@
graph BT
A --> B --> C
---
+---+
| |
| C |
| |
+---+
^
|
|
|
|
+---+
| |
| B |
| |
+---+
^
|
|
|
|
+---+
| |
| A |
| |
+---+
@@ -0,0 +1,26 @@
graph TB
subgraph one
A --> B
end
---
+-------+
| one |
| |
| |
| +---+ |
| | | |
| | A | |
| | | |
| +---+ |
| | |
| | |
| | |
| | |
| v |
| +---+ |
| | | |
| | B | |
| | | |
| +---+ |
| |
+-------+
@@ -0,0 +1,36 @@
graph TD
A[Web Server] --> B[API Gateway]
B --> C[Web Server]
subgraph Frontend
A
end
subgraph Backend
B
C
end
---
+-------------+
| |
| Web Server |
| |
+-------------+
|
|
|
|
v
+-------------+
| |
| API Gateway |
| |
+-------------+
|
|
|
|
v
+-------------+
| |
| Web Server |
| |
+-------------+
@@ -0,0 +1,23 @@
graph LR
A
B
B --> A
A --> A
B --> C
C --> A
---
+---+ +---+
| | | |
| A |<-+--| C |
| | | | |
+---+ | +---+
^ | ^
| | |
+----+ |
| |
| |
+---+ |
| | |
| B |-------+
| |
+---+
@@ -0,0 +1,10 @@
graph LR
A --> A
---
+---+
| |
| A |-+
| | |
+---+ |
^ |
+---+
@@ -0,0 +1,10 @@
graph LR
A --> A & B
---
+---+ +---+
| | | |
| A |--+->| B |
| | | | |
+---+ | +---+
^ |
+----+
@@ -0,0 +1,17 @@
sequenceDiagram
Alice->>Bob: Hello Bob
Bob-->>Alice: Hi Alice
---
+-------+ +-----+
| Alice | | Bob |
+-------+ +-----+
| |
| Hello Bob |
|-------------->
| |
| Hi Alice |
<..............|
| |
+-------+ +-----+
| Alice | | Bob |
+-------+ +-----+
@@ -0,0 +1,25 @@
sequenceDiagram
Alice->>Bob: Hello
Bob->>Charlie: Forward
Charlie-->>Bob: Reply
Bob-->>Alice: Done
---
+-------+ +-----+ +---------+
| Alice | | Bob | | Charlie |
+-------+ +-----+ +---------+
| | |
| Hello | |
|----------> |
| | |
| | Forward |
| |------------>
| | |
| | Reply |
| <............|
| | |
| Done | |
<..........| |
| | |
+-------+ +-----+ +---------+
| Alice | | Bob | | Charlie |
+-------+ +-----+ +---------+
@@ -0,0 +1,18 @@
sequenceDiagram
Alice->>Alice: Think
Alice->>Bob: Result
---
+-------+ +-----+
| Alice | | Bob |
+-------+ +-----+
| |
+---+ |
| | Think |
<---+ |
| |
| Result |
|----------->
| |
+-------+ +-----+
| Alice | | Bob |
+-------+ +-----+
@@ -0,0 +1,8 @@
graph LR
A
---
+---+
| |
| A |
| |
+---+
@@ -0,0 +1,8 @@
graph LR
LongerName
---
+------------+
| |
| LongerName |
| |
+------------+
@@ -0,0 +1,38 @@
graph LR
Start
subgraph Processing
A --> B
B --> C
end
subgraph Storage
D
E
end
Start --> A
C --> D
C --> E
D --> End
E --> End
End
---
+---------------------------+ +-------+
| Processing | |Storage|
| | | |
| | | |
+-------+ | +---+ +---+ +---+ | | +---+ | +-----+
| | | | | | | | | | | | | | | |
| Start |---->| A |---->| B |---->| C |---->| D |---->| End |
| | | | | | | | | | | | | | | |
+-------+ | +---+ +---+ +---+ | | +---+ | +-----+
| | | | | ^
+-----------------------|---+ | | |
| | | |
| | | |
| | | |
| | +---+ | |
| | | | | |
+------>| E |--------+
| | | |
| +---+ |
| |
+-------+
@@ -0,0 +1,49 @@
graph LR
subgraph outer
A
subgraph inner
B
end
C
end
D
---
+-----------+
| outer |
| |
| |
| +---+ |
| | | |
| | A | |
| | | |
| +---+ |
| |
| +-------+ |
| | inner | |
| | | |
| | | |
| | +---+ | |
| | | | | |
| | | B | | |
| | | | | |
| | +---+ | |
| | | |
| +-------+ |
| |
| |
| |
| +---+ |
| | | |
| | C | |
| | | |
| +---+ |
| |
+-----------+
+---+
| |
| D |
| |
+---+
@@ -0,0 +1,37 @@
graph TD
subgraph one [LR Group]
direction LR
A --> B
end
X --> A
B --> Y
---
+---+
| |
| X |
| |
+---+
|
|
|
|
|
+---|-------------+
| |LR Group |
| | |
| v |
| +---+ +---+ |
| | | | | |
| | A |---->| B | |
| | | | | |
| +---+ +---+ |
| | |
+-------------|---+
|
|
|
+---+ |
| | |
| Y |<------+
| |
+---+

Some files were not shown because too many files have changed in this diff Show More