feat(utils): vendor mermaid rendering
This commit is contained in:
@@ -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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -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.
|
|
||||||
```
|
|
||||||
@@ -6,7 +6,7 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
# Portions of the minimizer adapt MIT-licensed algorithms from rtk-ai/rtk.
|
# 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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -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
|
//! Ported from rtk-ai/rtk@878af7de99e0ba71da2e8fd996f6b52a1836e06c
|
||||||
//! Path: `src/cmds/python/pytest_cmd.rs`
|
//! 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 `pi-shell` crate root.
|
||||||
//!
|
//!
|
||||||
//! The pytest state machine (`filter_pytest`, `pytest_success`,
|
//! The pytest state machine (`filter_pytest`, `pytest_success`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"patchedDependencies": {
|
"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"
|
"@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
@@ -55,7 +54,6 @@
|
|||||||
"@typescript/native-preview": "7.0.0-dev.20260609.1",
|
"@typescript/native-preview": "7.0.0-dev.20260609.1",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"arktype": "^2.2.0",
|
"arktype": "^2.2.0",
|
||||||
"beautiful-mermaid": "^1.1.3",
|
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.4.0",
|
"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 legacyDbPath = path.join(tempDir, "legacy-v1-anthropic-agent.db");
|
||||||
const legacyDb = new Database(legacyDbPath);
|
const legacyDb = new Database(legacyDbPath);
|
||||||
legacyDb.exec(`
|
legacyDb.run(`
|
||||||
CREATE TABLE auth_schema_version (
|
CREATE TABLE auth_schema_version (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
@@ -442,7 +442,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
|||||||
|
|
||||||
const futureDbPath = path.join(tempDir, "future-schema-agent.db");
|
const futureDbPath = path.join(tempDir, "future-schema-agent.db");
|
||||||
const futureDb = new Database(futureDbPath);
|
const futureDb = new Database(futureDbPath);
|
||||||
futureDb.exec(`
|
futureDb.run(`
|
||||||
CREATE TABLE auth_schema_version (
|
CREATE TABLE auth_schema_version (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
@@ -505,7 +505,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
|||||||
|
|
||||||
const legacyDbPath = path.join(tempDir, "legacy-v3-agent.db");
|
const legacyDbPath = path.join(tempDir, "legacy-v3-agent.db");
|
||||||
const legacyDb = new Database(legacyDbPath);
|
const legacyDb = new Database(legacyDbPath);
|
||||||
legacyDb.exec(`
|
legacyDb.run(`
|
||||||
CREATE TABLE auth_schema_version (
|
CREATE TABLE auth_schema_version (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
@@ -561,7 +561,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
|||||||
|
|
||||||
const legacyDbPath = path.join(tempDir, "legacy-v1-agent.db");
|
const legacyDbPath = path.join(tempDir, "legacy-v1-agent.db");
|
||||||
const legacyDb = new Database(legacyDbPath);
|
const legacyDb = new Database(legacyDbPath);
|
||||||
legacyDb.exec(`
|
legacyDb.run(`
|
||||||
CREATE TABLE auth_schema_version (
|
CREATE TABLE auth_schema_version (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
@@ -612,7 +612,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
|||||||
|
|
||||||
const legacyDbPath = path.join(tempDir, "legacy-agent.db");
|
const legacyDbPath = path.join(tempDir, "legacy-agent.db");
|
||||||
const legacyDb = new Database(legacyDbPath);
|
const legacyDb = new Database(legacyDbPath);
|
||||||
legacyDb.exec(`
|
legacyDb.run(`
|
||||||
CREATE TABLE auth_credentials (
|
CREATE TABLE auth_credentials (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ describe("AssistantMessageComponent mermaid markdown", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("aligns box borders for CJK labels in display columns", () => {
|
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
|
// terminal columns wide, so every row of a single-node diagram must
|
||||||
// measure the same display width or the right border drifts.
|
// measure the same display width or the right border drifts.
|
||||||
const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```");
|
const rendered = renderAssistantMessage("```mermaid\nflowchart TD\n A[수집 스케줄러]\n```");
|
||||||
|
|||||||
+11
-11
@@ -56,11 +56,11 @@ export async function initDb(): Promise<Database> {
|
|||||||
db = new Database(getStatsDbPath());
|
db = new Database(getStatsDbPath());
|
||||||
// Install the busy handler BEFORE any lock-taking statement. See
|
// Install the busy handler BEFORE any lock-taking statement. See
|
||||||
// https://github.com/can1357/oh-my-pi/issues/2421.
|
// https://github.com/can1357/oh-my-pi/issues/2421.
|
||||||
db.exec("PRAGMA busy_timeout = 5000");
|
db.run("PRAGMA busy_timeout = 5000");
|
||||||
db.exec("PRAGMA journal_mode = WAL");
|
db.run("PRAGMA journal_mode = WAL");
|
||||||
|
|
||||||
// Create tables
|
// Create tables
|
||||||
db.exec(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_file TEXT NOT NULL,
|
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 }[];
|
const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
|
||||||
if (!messageColumns.some(column => column.name === "premium_requests")) {
|
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
|
// Each behavior-metric bump invalidates previously-ingested rows. We detect
|
||||||
// the stale schema by column name and drop the table; `IF NOT EXISTS` above
|
// 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.
|
// 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 hasV4Columns = userMessageColumns.some(column => column.name === "negation");
|
||||||
const hasOldUserMessages = userMessageColumns.length > 0;
|
const hasOldUserMessages = userMessageColumns.length > 0;
|
||||||
if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
|
if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
|
||||||
db.exec("DROP TABLE user_messages");
|
db.run("DROP TABLE user_messages");
|
||||||
db.exec(`
|
db.run(`
|
||||||
CREATE TABLE user_messages (
|
CREATE TABLE user_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_file TEXT NOT NULL,
|
session_file TEXT NOT NULL,
|
||||||
@@ -787,8 +787,8 @@ function backfillUserMessages(database: Database): void {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!shouldResetBackfill(row?.value)) return;
|
if (!shouldResetBackfill(row?.value)) return;
|
||||||
|
|
||||||
database.exec("DELETE FROM user_messages");
|
database.run("DELETE FROM user_messages");
|
||||||
database.exec("DELETE FROM file_offsets");
|
database.run("DELETE FROM file_offsets");
|
||||||
database
|
database
|
||||||
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
||||||
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
||||||
@@ -808,7 +808,7 @@ function repairUserMessageLinks(database: Database): void {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!shouldResetBackfill(row?.value)) return;
|
if (!shouldResetBackfill(row?.value)) return;
|
||||||
|
|
||||||
database.exec("DELETE FROM file_offsets");
|
database.run("DELETE FROM file_offsets");
|
||||||
database
|
database
|
||||||
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
||||||
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
|
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
|
||||||
@@ -830,7 +830,7 @@ function backfillPriorityPremiumRequests(database: Database): void {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!shouldResetBackfill(row?.value)) return;
|
if (!shouldResetBackfill(row?.value)) return;
|
||||||
|
|
||||||
database.exec("DELETE FROM file_offsets");
|
database.run("DELETE FROM file_offsets");
|
||||||
database
|
database
|
||||||
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
||||||
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
|
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [16.0.3] - 2026-06-16
|
||||||
|
|
||||||
@@ -150,4 +157,4 @@
|
|||||||
|
|
||||||
### Added
|
### 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.
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oh-my-pi/pi-natives": "catalog:",
|
"@oh-my-pi/pi-natives": "catalog:",
|
||||||
"beautiful-mermaid": "catalog:",
|
|
||||||
"handlebars": "catalog:",
|
"handlebars": "catalog:",
|
||||||
"winston": "catalog:",
|
"winston": "catalog:",
|
||||||
"winston-daily-rotate-file": "catalog:"
|
"winston-daily-rotate-file": "catalog:"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid";
|
import { type AsciiRenderOptions, renderMermaidASCII } from "./vendor/mermaid-ascii";
|
||||||
|
|
||||||
export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
|
export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
|
||||||
|
|
||||||
|
|||||||
+33
@@ -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.
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}`
|
||||||
|
}
|
||||||
@@ -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]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1382
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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }>
|
||||||
|
}
|
||||||
@@ -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
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 |
|
||||||
|
+-----------------+
|
||||||
|
|
||||||
|
|
||||||
@@ -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
Reference in New Issue
Block a user