mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e41d6deb9f | |||
| 22c15eb4d6 | |||
| a07b523dae |
@@ -571,6 +571,20 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.8",
|
||||
@@ -881,6 +895,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
@@ -2064,6 +2079,8 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
TextBufferRenderable,
|
||||
type ColorInput,
|
||||
type RenderContext,
|
||||
type RGBA,
|
||||
type StyledText,
|
||||
type TextBufferOptions,
|
||||
} from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasTextOptions } from "../canvas.js"
|
||||
import { setDiagramRenderableColor } from "./renderable-color.js"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
interface DiagramRenderableOptions<Diagram, Grid extends DiagramCanvas<any, any>> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => StyledText
|
||||
measure?: DiagramCanvasTextOptions
|
||||
}
|
||||
|
||||
export abstract class DiagramRenderable<Diagram, Grid extends DiagramCanvas<any, any>> extends TextBufferRenderable {
|
||||
private _content: string
|
||||
private _renderedWidth = 0
|
||||
private _renderedHeight = 0
|
||||
private _pipeline?: DiagramRenderablePipeline<Diagram, Grid>
|
||||
|
||||
protected constructor(ctx: RenderContext, options: TextBufferOptions & { content?: string }) {
|
||||
super(ctx, { ...options, wrapMode: options.wrapMode ?? "none" })
|
||||
this._content = options.content ?? ""
|
||||
}
|
||||
|
||||
protected initializeDiagram(options: DiagramRenderableOptions<Diagram, Grid>): void {
|
||||
this._pipeline = new DiagramRenderablePipeline({
|
||||
parse: options.parse,
|
||||
draw: options.draw,
|
||||
didDraw: (grid) => {
|
||||
const size = grid.getTextSize(options.measure)
|
||||
this._renderedWidth = size.width
|
||||
this._renderedHeight = size.height
|
||||
},
|
||||
publish: (grid) => {
|
||||
this.textBuffer.setStyledText(options.publish(grid))
|
||||
this.updateTextInfo()
|
||||
},
|
||||
})
|
||||
this._pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get content(): string {
|
||||
return this._content
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
if (this._content === value) return
|
||||
this._content = value
|
||||
this.contentChanged()
|
||||
this.pipeline.invalidateParsedDiagram()
|
||||
}
|
||||
|
||||
get renderedWidth(): number {
|
||||
return this._renderedWidth
|
||||
}
|
||||
|
||||
get renderedHeight(): number {
|
||||
return this._renderedHeight
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this.pipeline.batchUpdate(update)
|
||||
}
|
||||
|
||||
protected contentChanged(): void {}
|
||||
|
||||
protected parsedDiagram(): Diagram {
|
||||
return this.pipeline.diagram()
|
||||
}
|
||||
|
||||
protected invalidateGrid(): void {
|
||||
this.pipeline.invalidateGrid()
|
||||
}
|
||||
|
||||
protected invalidateStyle(): void {
|
||||
this.pipeline.invalidateStyle()
|
||||
}
|
||||
|
||||
protected setColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
): void {
|
||||
setDiagramRenderableColor(current, value, assign, () => this.invalidateStyle())
|
||||
}
|
||||
|
||||
private get pipeline(): DiagramRenderablePipeline<Diagram, Grid> {
|
||||
if (!this._pipeline) throw new Error("Diagram renderable was not initialized")
|
||||
return this._pipeline
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
|
||||
import { colorsEqual } from "../color/style.js"
|
||||
|
||||
export function parseDiagramRenderableColor(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value ? parseColor(value) : undefined
|
||||
}
|
||||
|
||||
export function setDiagramRenderableColor(
|
||||
current: RGBA | undefined,
|
||||
value: ColorInput | undefined,
|
||||
assign: (color: RGBA | undefined) => void,
|
||||
invalidate: () => void,
|
||||
): void {
|
||||
const next = parseDiagramRenderableColor(value)
|
||||
if (colorsEqual(current, next)) return
|
||||
assign(next)
|
||||
invalidate()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
|
||||
|
||||
describe("DiagramRenderablePipeline", () => {
|
||||
test("caches the parsed diagram and grid across style repaints", () => {
|
||||
let parseCount = 0
|
||||
let drawCount = 0
|
||||
let publishCount = 0
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => ({ version: ++parseCount }),
|
||||
draw: (diagram) => ({ version: diagram.version, draw: ++drawCount }),
|
||||
publish: () => {
|
||||
publishCount += 1
|
||||
},
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.invalidateGrid()
|
||||
|
||||
expect({ parseCount, drawCount, publishCount }).toEqual({ parseCount: 1, drawCount: 2, publishCount: 3 })
|
||||
})
|
||||
|
||||
test("reparses source changes and coalesces nested invalidations", () => {
|
||||
const events: string[] = []
|
||||
const pipeline = new DiagramRenderablePipeline({
|
||||
parse: () => {
|
||||
events.push("parse")
|
||||
return {}
|
||||
},
|
||||
draw: (diagram) => {
|
||||
events.push("draw")
|
||||
return diagram
|
||||
},
|
||||
publish: () => events.push("publish"),
|
||||
})
|
||||
|
||||
pipeline.invalidateParsedDiagram()
|
||||
events.length = 0
|
||||
pipeline.batchUpdate(() => {
|
||||
pipeline.invalidateStyle()
|
||||
pipeline.batchUpdate(() => pipeline.invalidateGrid())
|
||||
pipeline.invalidateParsedDiagram()
|
||||
})
|
||||
|
||||
expect(events).toEqual(["parse", "draw", "publish"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface DiagramRenderablePipelineOptions<Diagram, Grid> {
|
||||
parse: () => Diagram
|
||||
draw: (diagram: Diagram) => Grid
|
||||
publish: (grid: Grid) => void
|
||||
didDraw?: (grid: Grid) => void
|
||||
}
|
||||
|
||||
type DiagramRenderableInvalidation = "grid" | "style"
|
||||
|
||||
export class DiagramRenderablePipeline<Diagram, Grid> {
|
||||
private _diagram?: Diagram
|
||||
private _grid?: Grid
|
||||
private _batchDepth = 0
|
||||
private _pending?: DiagramRenderableInvalidation
|
||||
|
||||
constructor(private readonly options: DiagramRenderablePipelineOptions<Diagram, Grid>) {}
|
||||
|
||||
diagram(): Diagram {
|
||||
this._diagram ??= this.options.parse()
|
||||
return this._diagram
|
||||
}
|
||||
|
||||
batchUpdate(update: () => void): void {
|
||||
this._batchDepth += 1
|
||||
try {
|
||||
update()
|
||||
} finally {
|
||||
this._batchDepth -= 1
|
||||
if (this._batchDepth === 0 && this._pending) {
|
||||
const pending = this._pending
|
||||
this._pending = undefined
|
||||
this.render(pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateParsedDiagram(): void {
|
||||
this._diagram = undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
invalidateGrid(): void {
|
||||
this._grid = undefined
|
||||
this.invalidate("grid")
|
||||
}
|
||||
|
||||
invalidateStyle(): void {
|
||||
this.invalidate("style")
|
||||
}
|
||||
|
||||
private invalidate(level: DiagramRenderableInvalidation): void {
|
||||
if (this._batchDepth > 0) {
|
||||
if (level === "grid" || !this._pending) this._pending = level
|
||||
return
|
||||
}
|
||||
this.render(level)
|
||||
}
|
||||
|
||||
private render(level: DiagramRenderableInvalidation): void {
|
||||
let grid = this._grid
|
||||
if (level === "grid" || !grid) {
|
||||
grid = this.options.draw(this.diagram())
|
||||
this._grid = grid
|
||||
this.options.didDraw?.(grid)
|
||||
}
|
||||
this.options.publish(grid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "./canvas.js"
|
||||
|
||||
describe("DiagramCanvas", () => {
|
||||
test("writes cells and text while clipping out-of-bounds positions", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 2)
|
||||
|
||||
canvas.setCell(0, 0, "A", "label")
|
||||
canvas.setCell(9, 0, "X", "label")
|
||||
canvas.setText(2, 1, "hey", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("A\n hey")
|
||||
})
|
||||
|
||||
test("uses measured character widths for text placement", () => {
|
||||
const canvas = new DiagramCanvas<"label">(5, 1)
|
||||
|
||||
canvas.setText(0, 0, "a界b", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("a界b")
|
||||
expect(stringWidth(canvas.toString())).toBe(4)
|
||||
})
|
||||
|
||||
test("preserves combined graphemes while placing later text", () => {
|
||||
const canvas = new DiagramCanvas<"label">(4, 1)
|
||||
|
||||
canvas.setText(0, 0, "e\u0301x", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("e\u0301x")
|
||||
expect(stringWidth(canvas.toString())).toBe(2)
|
||||
})
|
||||
|
||||
test("merges cells through the adapter-provided merge function", () => {
|
||||
type Style = "line"
|
||||
const canvas = new DiagramCanvas<Style>(3, 1, {
|
||||
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
|
||||
...incoming,
|
||||
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
|
||||
}),
|
||||
})
|
||||
|
||||
canvas.setCell(1, 0, "─", "line")
|
||||
canvas.setCell(1, 0, "│", "line")
|
||||
|
||||
expect(canvas.toString()).toBe(" ┼")
|
||||
})
|
||||
|
||||
test("iterates style and metadata runs", () => {
|
||||
interface Metadata {
|
||||
stateId?: string
|
||||
}
|
||||
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
|
||||
const runs: string[] = []
|
||||
|
||||
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
|
||||
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
|
||||
() => runs.push("newline"),
|
||||
{ key: (cell) => [cell.style, cell.stateId] },
|
||||
)
|
||||
|
||||
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
|
||||
})
|
||||
|
||||
test("can trim bottom whitespace for renderers with dynamic height", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
const runs: string[] = []
|
||||
canvas.setText(0, 0, "top", "label")
|
||||
|
||||
expect(canvas.toString()).toBe("top\n\n")
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("top")
|
||||
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
|
||||
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
|
||||
|
||||
canvas.forEachRun(
|
||||
(run) => runs.push(run.text),
|
||||
() => runs.push("newline"),
|
||||
{ trimBottom: true },
|
||||
)
|
||||
expect(runs).toEqual(["top"])
|
||||
})
|
||||
|
||||
test("can trim unused leading whitespace reserved by layout", () => {
|
||||
const canvas = new DiagramCanvas<"label">(3, 3)
|
||||
canvas.setText(0, 2, "end", "label")
|
||||
|
||||
expect(canvas.toString({ trimTop: true })).toBe("end")
|
||||
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import stringWidth from "string-width"
|
||||
import { diagramTextGraphemes } from "./text.js"
|
||||
|
||||
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
|
||||
char: string
|
||||
style?: Style
|
||||
} & Partial<Metadata>
|
||||
|
||||
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
|
||||
text: string
|
||||
style: Style | undefined
|
||||
cell: DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
|
||||
measure?: (text: string) => number
|
||||
mergeCell?: (
|
||||
existing: DiagramCanvasCell<Style, Metadata>,
|
||||
incoming: DiagramCanvasCell<Style, Metadata>,
|
||||
) => DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextOptions {
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
export interface DiagramCanvasTextSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type DiagramCanvasTextMetadata<Metadata extends object> =
|
||||
| Partial<Metadata>
|
||||
| ((x: number, y: number) => Partial<Metadata>)
|
||||
|
||||
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
|
||||
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
|
||||
trimTop?: boolean
|
||||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
|
||||
return { char: " " } as DiagramCanvasCell<Style, Metadata>
|
||||
}
|
||||
|
||||
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
|
||||
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
|
||||
}
|
||||
|
||||
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
|
||||
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
|
||||
|
||||
private readonly measure: (text: string) => number
|
||||
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
options: DiagramCanvasOptions<Style, Metadata> = {},
|
||||
) {
|
||||
this.measure = options.measure ?? stringWidth
|
||||
this.mergeCell = options.mergeCell
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
|
||||
}
|
||||
|
||||
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
|
||||
let rowEnd = row.length
|
||||
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
|
||||
return rowEnd
|
||||
}
|
||||
|
||||
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
|
||||
return row
|
||||
.slice(0, rowEnd)
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
}
|
||||
|
||||
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
|
||||
let start = 0
|
||||
let end = this.rows.length
|
||||
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
|
||||
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
|
||||
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
|
||||
|
||||
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
|
||||
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
|
||||
}
|
||||
|
||||
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
|
||||
return this.rows[y]?.[x]
|
||||
}
|
||||
|
||||
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
|
||||
let offset = 0
|
||||
for (const grapheme of diagramTextGraphemes(text)) {
|
||||
const width = Math.max(1, this.measure(grapheme))
|
||||
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
|
||||
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
|
||||
for (let continuation = 1; continuation < width; continuation++) {
|
||||
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
}
|
||||
|
||||
toString(options: DiagramCanvasTextOptions = {}): string {
|
||||
const lines: string[] = []
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
lines.push(this.rowText(this.rows[rowIndex]!))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
let width = 0
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
|
||||
}
|
||||
return { width, height: rows.end - rows.start }
|
||||
}
|
||||
|
||||
forEachRun(
|
||||
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
|
||||
onLineEnd: () => void,
|
||||
options: DiagramCanvasRunOptions<Style, Metadata> = {},
|
||||
): void {
|
||||
const key = options.key
|
||||
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
|
||||
|
||||
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
|
||||
const row = this.rows[rowIndex]!
|
||||
const rowEnd = this.rowTextEnd(row)
|
||||
|
||||
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
|
||||
let currentKey: readonly unknown[] | undefined
|
||||
let currentText = ""
|
||||
const flush = () => {
|
||||
if (!currentText || !currentCell) return
|
||||
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
for (let x = 0; x < rowEnd; x++) {
|
||||
const cell = row[x]!
|
||||
const nextKey = key?.(cell)
|
||||
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
|
||||
if (!sameRun) {
|
||||
flush()
|
||||
currentCell = cell
|
||||
currentKey = nextKey
|
||||
}
|
||||
currentText += cell.char
|
||||
}
|
||||
|
||||
flush()
|
||||
if (rowIndex < rows.end - 1) onLineEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
|
||||
import { colorsEqual } from "./style.js"
|
||||
|
||||
export type DiagramColorMapInput = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR = "::cell:"
|
||||
export const DIAGRAM_CELL_COLOR_LEVEL_COUNT = 6
|
||||
|
||||
export function diagramCellColorKey(id: string, level: number): string {
|
||||
const normalizedLevel = Math.max(0, Math.min(DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1, Math.round(level)))
|
||||
return `${id}${DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR}${normalizedLevel}`
|
||||
}
|
||||
|
||||
export function diagramRadialCellColorLevel(
|
||||
bounds: { width: number; height: number; centerX: number; centerY: number },
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): number {
|
||||
const halfWidth = Math.max(1, (bounds.width - 1) / 2)
|
||||
const halfHeight = Math.max(1, (bounds.height - 1) / 2)
|
||||
const dx = (x - bounds.centerX) / halfWidth
|
||||
const dy = (y - bounds.centerY) / halfHeight
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const level = Math.round((1 - Math.min(1, distance)) * (DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1))
|
||||
return border ? Math.min(1, level) : level
|
||||
}
|
||||
|
||||
export function baseDiagramCellColorKey(id: string): string {
|
||||
const index = id.lastIndexOf(DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR)
|
||||
return index === -1 ? id : id.slice(0, index)
|
||||
}
|
||||
|
||||
export function mappedDiagramColor(
|
||||
colors: ReadonlyMap<string, RGBA> | undefined,
|
||||
id: string | undefined,
|
||||
): RGBA | undefined {
|
||||
return id ? (colors?.get(id) ?? colors?.get(baseDiagramCellColorKey(id))) : undefined
|
||||
}
|
||||
|
||||
export function normalizeDiagramColorMap(value: DiagramColorMapInput | undefined): Map<string, RGBA> {
|
||||
const colors = new Map<string, RGBA>()
|
||||
if (!value) return colors
|
||||
|
||||
const entries = value instanceof Map ? value.entries() : Object.entries(value)
|
||||
for (const [id, color] of entries) {
|
||||
if (color !== undefined) colors.set(id, parseColor(color))
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
export function diagramColorMapsEqual(left: ReadonlyMap<string, RGBA>, right: ReadonlyMap<string, RGBA>): boolean {
|
||||
if (left.size !== right.size) return false
|
||||
for (const [id, color] of left) {
|
||||
if (!colorsEqual(color, right.get(id))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
baseDiagramCellColorKey,
|
||||
diagramCellColorKey,
|
||||
diagramColorMapsEqual,
|
||||
diagramRadialCellColorLevel,
|
||||
mappedDiagramColor,
|
||||
normalizeDiagramColorMap,
|
||||
} from "./map.js"
|
||||
import {
|
||||
ansiBg,
|
||||
ansiFg,
|
||||
blendColor,
|
||||
brightenColor,
|
||||
createAnsiRampTheme,
|
||||
mixRgb,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
} from "./style.js"
|
||||
|
||||
describe("diagram style helpers", () => {
|
||||
test("mixes rgb values and emits truecolor ANSI", () => {
|
||||
expect(mixRgb([0, 10, 20], [10, 30, 60], 0.5)).toEqual([5, 20, 40])
|
||||
expect(ansiFg([1, 2, 3])).toBe("\x1b[38;2;1;2;3m")
|
||||
expect(ansiBg([4, 5, 6])).toBe("\x1b[48;2;4;5;6m")
|
||||
})
|
||||
|
||||
test("converts rgb tuples and blends optional RGBA values", () => {
|
||||
const black = RGBA.fromInts(0, 0, 0, 255)
|
||||
const white = RGBA.fromInts(10, 20, 30, 255)
|
||||
|
||||
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
|
||||
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
|
||||
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
|
||||
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("brightens colors toward white", () => {
|
||||
const color = brightenColor(RGBA.fromInts(100, 150, 200, 255), 0.5)
|
||||
|
||||
expect(color?.equals(RGBA.fromInts(178, 203, 228, 255))).toBe(true)
|
||||
expect(brightenColor(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("creates numbered style keys and ANSI ramps", () => {
|
||||
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
|
||||
const theme = createAnsiRampTheme(styles, [0, 0, 0], [12, 24, 36])
|
||||
|
||||
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
|
||||
expect(theme.requestFade1).toBe("\x1b[38;2;3;6;9m")
|
||||
expect(theme.requestFade2).toBe("\x1b[38;2;6;12;18m")
|
||||
expect(theme.requestFade3).toBe("\x1b[38;2;9;18;27m")
|
||||
})
|
||||
|
||||
test("normalizes and resolves leveled diagram color maps", () => {
|
||||
const red = RGBA.fromInts(255, 0, 0, 255)
|
||||
const blue = RGBA.fromInts(0, 0, 255, 255)
|
||||
const colors = normalizeDiagramColorMap({
|
||||
A: red,
|
||||
B: undefined,
|
||||
[diagramCellColorKey("C", 2)]: blue,
|
||||
})
|
||||
|
||||
expect(colors.size).toBe(2)
|
||||
expect(diagramCellColorKey("A", -1)).toBe("A::cell:0")
|
||||
expect(diagramCellColorKey("A", 10)).toBe("A::cell:5")
|
||||
expect(baseDiagramCellColorKey("A::cell:3")).toBe("A")
|
||||
expect(mappedDiagramColor(colors, "A")?.equals(red)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:2")?.equals(blue)).toBe(true)
|
||||
expect(mappedDiagramColor(colors, "C::cell:4")).toBeUndefined()
|
||||
expect(diagramColorMapsEqual(colors, normalizeDiagramColorMap(new Map(colors)))).toBe(true)
|
||||
})
|
||||
|
||||
test("computes radial diagram cell color levels", () => {
|
||||
const bounds = { width: 9, height: 5, centerX: 4, centerY: 2 }
|
||||
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2)).toBe(5)
|
||||
expect(diagramRadialCellColorLevel(bounds, 0, 0)).toBe(0)
|
||||
expect(diagramRadialCellColorLevel(bounds, 4, 2, true)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export type DiagramRgb = readonly [number, number, number]
|
||||
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
|
||||
|
||||
export function numberedStyleKeys<Prefix extends string, Step extends number>(
|
||||
prefix: Prefix,
|
||||
steps: readonly Step[],
|
||||
): Array<`${Prefix}${Step}`> {
|
||||
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
|
||||
}
|
||||
|
||||
export function mixChannel(left: number, right: number, amount: number): number {
|
||||
return Math.round(left + (right - left) * amount)
|
||||
}
|
||||
|
||||
export function mixRgb(left: DiagramRgb, right: DiagramRgb, amount: number): DiagramRgb {
|
||||
return [
|
||||
mixChannel(left[0], right[0], amount),
|
||||
mixChannel(left[1], right[1], amount),
|
||||
mixChannel(left[2], right[2], amount),
|
||||
]
|
||||
}
|
||||
|
||||
export function ansiFg(rgb: DiagramRgb): string {
|
||||
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function ansiBg(rgb: DiagramRgb): string {
|
||||
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
|
||||
}
|
||||
|
||||
export function rgba(rgb: DiagramRgb): RGBA {
|
||||
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
|
||||
}
|
||||
|
||||
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
|
||||
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
|
||||
if (!from && !to) return undefined
|
||||
if (!from) return to
|
||||
if (!to) return from
|
||||
|
||||
const [fromR, fromG, fromB, fromA] = from.toInts()
|
||||
const [toR, toG, toB, toA] = to.toInts()
|
||||
const mix = (left: number, right: number) => left + (right - left) * amount
|
||||
|
||||
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
|
||||
}
|
||||
|
||||
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
|
||||
if (!left || !right) return left === right
|
||||
return left.equals(right)
|
||||
}
|
||||
|
||||
export function brightenColor(color: RGBA | undefined, amount: number = 0.35): RGBA | undefined {
|
||||
if (!color) return undefined
|
||||
|
||||
const [r, g, b, a] = color.toInts()
|
||||
return RGBA.fromInts(mixChannel(r, 255, amount), mixChannel(g, 255, amount), mixChannel(b, 255, amount), a)
|
||||
}
|
||||
|
||||
export function createAnsiRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: DiagramRgb,
|
||||
to: DiagramRgb,
|
||||
): Record<Style, string> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, ansiFg(mixRgb(from, to, (index + 1) / (styles.length + 1)))]),
|
||||
) as Record<Style, string>
|
||||
}
|
||||
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA,
|
||||
to: RGBA,
|
||||
): Record<Style, RGBA>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined>
|
||||
export function createColorRampTheme<Style extends string>(
|
||||
styles: readonly Style[],
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<Style, RGBA | undefined> {
|
||||
return Object.fromEntries(
|
||||
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
|
||||
) as Record<Style, RGBA | undefined>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { DiagramCanvas } from "./canvas.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "./drawing.js"
|
||||
|
||||
describe("diagram drawing", () => {
|
||||
test("merges line glyphs with square and rounded corners", () => {
|
||||
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
|
||||
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
|
||||
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("draws frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(8, 4)
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("draws diamond frames through caller-provided cell writers", () => {
|
||||
const canvas = new DiagramCanvas<"frame">(9, 5)
|
||||
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
|
||||
canvas.setCell(x, y, char, "frame"),
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
|
||||
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with selected corner style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ cornerStyle: "rounded" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with heavy line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(7, 4)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 3, y: 2 },
|
||||
{ x: 6, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "heavy" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
|
||||
})
|
||||
|
||||
test("draws orthogonal paths with dashed line style", () => {
|
||||
const canvas = new DiagramCanvas<"edge">(9, 1)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 8, y: 0 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
{ lineStyle: "dashed" },
|
||||
)
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
|
||||
})
|
||||
|
||||
test("keeps container frame policy separate from edge drawing policy", () => {
|
||||
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
|
||||
mergeCell: (existing, incoming) => {
|
||||
if (incoming.style === "edge") return incoming
|
||||
return existing.char === " " ? incoming : existing
|
||||
},
|
||||
})
|
||||
|
||||
drawDiagramFrame(
|
||||
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
|
||||
BorderChars.rounded,
|
||||
(x, y, char) => canvas.setCell(x, y, char, "group"),
|
||||
)
|
||||
drawOrthogonalPath(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
(x, y, char) => canvas.setCell(x, y, char, "edge"),
|
||||
)
|
||||
canvas.setCell(7, 2, "▶", "edge")
|
||||
|
||||
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
|
||||
})
|
||||
|
||||
test("selects filled and line arrow heads", () => {
|
||||
expect(diagramArrowHead("right")).toBe("▶")
|
||||
expect(diagramArrowHead("left", "line")).toBe("←")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { BorderChars, type BorderCharacters } from "@opentui/core"
|
||||
import {
|
||||
directionBetween,
|
||||
walkOrthogonalSegment,
|
||||
type DiagramBounds,
|
||||
type DiagramDirection,
|
||||
type DiagramPoint,
|
||||
} from "./geometry.js"
|
||||
|
||||
export type DiagramLineCornerStyle = "square" | "rounded"
|
||||
export type DiagramLineStyle = "single" | "heavy" | "dashed"
|
||||
export type DiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface DiagramDiamondCharacters {
|
||||
topLeft: string
|
||||
topRight: string
|
||||
upperLeft: string
|
||||
upperLeftJoin: string
|
||||
upperRightJoin: string
|
||||
upperRight: string
|
||||
vertical: string
|
||||
lowerLeft: string
|
||||
lowerLeftJoin: string
|
||||
lowerRightJoin: string
|
||||
lowerRight: string
|
||||
bottomLeft: string
|
||||
bottomRight: string
|
||||
horizontal: string
|
||||
}
|
||||
|
||||
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
|
||||
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
|
||||
|
||||
export const DIAGRAM_DIAMOND_CHARS = {
|
||||
topLeft: "╭",
|
||||
topRight: "╮",
|
||||
upperLeft: "╭",
|
||||
upperLeftJoin: "╯",
|
||||
upperRightJoin: "╰",
|
||||
upperRight: "╮",
|
||||
vertical: "│",
|
||||
lowerLeft: "╰",
|
||||
lowerLeftJoin: "╮",
|
||||
lowerRightJoin: "╭",
|
||||
lowerRight: "╯",
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
} as const satisfies DiagramDiamondCharacters
|
||||
|
||||
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
|
||||
return {
|
||||
topLeft: chars.topLeft,
|
||||
topRight: chars.topRight,
|
||||
upperLeft: chars.topLeft,
|
||||
upperLeftJoin: chars.bottomRight,
|
||||
upperRightJoin: chars.bottomLeft,
|
||||
upperRight: chars.topRight,
|
||||
vertical: chars.vertical,
|
||||
lowerLeft: chars.bottomLeft,
|
||||
lowerLeftJoin: chars.topRight,
|
||||
lowerRightJoin: chars.topLeft,
|
||||
lowerRight: chars.bottomRight,
|
||||
bottomLeft: chars.bottomLeft,
|
||||
bottomRight: chars.bottomRight,
|
||||
horizontal: chars.horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
|
||||
switch (char) {
|
||||
case "─":
|
||||
return ["left", "right"]
|
||||
case "│":
|
||||
return ["up", "down"]
|
||||
case "━":
|
||||
return ["left", "right"]
|
||||
case "┃":
|
||||
return ["up", "down"]
|
||||
case "┌":
|
||||
case "╭":
|
||||
case "┏":
|
||||
return ["right", "down"]
|
||||
case "┐":
|
||||
case "╮":
|
||||
case "┓":
|
||||
return ["left", "down"]
|
||||
case "└":
|
||||
case "╰":
|
||||
case "┗":
|
||||
return ["up", "right"]
|
||||
case "┘":
|
||||
case "╯":
|
||||
case "┛":
|
||||
return ["up", "left"]
|
||||
case "├":
|
||||
case "┣":
|
||||
return ["up", "down", "right"]
|
||||
case "┤":
|
||||
case "┫":
|
||||
return ["up", "down", "left"]
|
||||
case "┬":
|
||||
case "┳":
|
||||
return ["left", "right", "down"]
|
||||
case "┴":
|
||||
case "┻":
|
||||
return ["left", "right", "up"]
|
||||
case "┼":
|
||||
case "╋":
|
||||
return ["up", "down", "left", "right"]
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function diagramLineGlyph(
|
||||
directions: ReadonlySet<DiagramDirection>,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
lineStyle: DiagramLineStyle = "single",
|
||||
): string {
|
||||
const up = directions.has("up")
|
||||
const down = directions.has("down")
|
||||
const left = directions.has("left")
|
||||
const right = directions.has("right")
|
||||
if (lineStyle === "heavy") {
|
||||
const chars = BorderChars.heavy
|
||||
if (up && down && left && right) return chars.cross
|
||||
if (up && down && right) return chars.leftT
|
||||
if (up && down && left) return chars.rightT
|
||||
if (left && right && down) return chars.topT
|
||||
if (left && right && up) return chars.bottomT
|
||||
if (up && right) return chars.bottomLeft
|
||||
if (up && left) return chars.bottomRight
|
||||
if (down && right) return chars.topLeft
|
||||
if (down && left) return chars.topRight
|
||||
if (up || down) return chars.vertical
|
||||
return chars.horizontal
|
||||
}
|
||||
if (up && down && left && right) return "┼"
|
||||
if (up && down && right) return "├"
|
||||
if (up && down && left) return "┤"
|
||||
if (left && right && down) return "┬"
|
||||
if (left && right && up) return "┴"
|
||||
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
|
||||
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
|
||||
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
|
||||
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
|
||||
if (up || down) return "│"
|
||||
return "─"
|
||||
}
|
||||
|
||||
function isHeavyLineGlyph(char: string): boolean {
|
||||
return HEAVY_LINE_GLYPHS.has(char)
|
||||
}
|
||||
|
||||
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
|
||||
const directions = new Set<DiagramDirection>(
|
||||
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
|
||||
)
|
||||
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
|
||||
}
|
||||
|
||||
export function mergeDiagramLineGlyph(
|
||||
existing: string,
|
||||
incoming: string,
|
||||
cornerStyle: DiagramLineCornerStyle = "square",
|
||||
): string | undefined {
|
||||
const existingDirections = lineDirections(existing)
|
||||
const incomingDirections = lineDirections(incoming)
|
||||
if (!existingDirections || !incomingDirections) return undefined
|
||||
|
||||
return diagramLineGlyph(
|
||||
new Set([...existingDirections, ...incomingDirections]),
|
||||
cornerStyle,
|
||||
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
|
||||
)
|
||||
}
|
||||
|
||||
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
|
||||
if (style === "line") {
|
||||
if (direction === "right") return "→"
|
||||
if (direction === "left") return "←"
|
||||
if (direction === "up") return "↑"
|
||||
return "↓"
|
||||
}
|
||||
|
||||
if (direction === "right") return "▶"
|
||||
if (direction === "left") return "◀"
|
||||
if (direction === "up") return "▲"
|
||||
return "▼"
|
||||
}
|
||||
|
||||
export function diagramArrowHeadBetween(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
style: DiagramArrowHeadStyle = "filled",
|
||||
): string {
|
||||
const direction = directionBetween(from, to)
|
||||
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
|
||||
}
|
||||
|
||||
export function drawDiagramFrame(
|
||||
bounds: DiagramBounds,
|
||||
chars: BorderCharacters,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
): void {
|
||||
setCell(bounds.left, bounds.top, chars.topLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
|
||||
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
|
||||
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
setCell(x, bounds.top, chars.horizontal)
|
||||
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
|
||||
}
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
setCell(bounds.left, y, chars.vertical)
|
||||
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawDiagramDiamond(
|
||||
bounds: DiagramBounds,
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
|
||||
): void {
|
||||
const left = bounds.left
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const top = bounds.top
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
|
||||
const capLeft = left + capInset
|
||||
const capRight = right - capInset
|
||||
|
||||
setCell(capLeft, top, chars.topLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
|
||||
setCell(capRight, top, chars.topRight)
|
||||
|
||||
setCell(left, top + 1, chars.upperLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(capLeft, top + 1, chars.upperLeftJoin)
|
||||
setCell(capRight, top + 1, chars.upperRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
|
||||
setCell(right, top + 1, chars.upperRight)
|
||||
|
||||
for (let y = top + 2; y < bottom - 1; y++) {
|
||||
setCell(left, y, chars.vertical)
|
||||
setCell(right, y, chars.vertical)
|
||||
}
|
||||
|
||||
setCell(left, bottom - 1, chars.lowerLeft)
|
||||
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
|
||||
setCell(capRight, bottom - 1, chars.lowerRightJoin)
|
||||
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
|
||||
setCell(right, bottom - 1, chars.lowerRight)
|
||||
|
||||
setCell(capLeft, bottom, chars.bottomLeft)
|
||||
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
|
||||
setCell(capRight, bottom, chars.bottomRight)
|
||||
}
|
||||
|
||||
export function drawOrthogonalPath(
|
||||
points: readonly DiagramPoint[],
|
||||
setCell: (x: number, y: number, char: string) => void,
|
||||
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
|
||||
): void {
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
const glyph = segmentGlyph(direction, options.lineStyle)
|
||||
let step = index === 1 ? 0 : 1
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
|
||||
step += 1
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 1; index < points.length - 1; index++) {
|
||||
const previous = points[index - 1]!
|
||||
const current = points[index]!
|
||||
const next = points[index + 1]!
|
||||
const fromDirection = directionBetween(current, previous)
|
||||
const toDirection = directionBetween(current, next)
|
||||
const directions = new Set<DiagramDirection>()
|
||||
if (fromDirection) directions.add(fromDirection)
|
||||
if (toDirection) directions.add(toDirection)
|
||||
setCell(
|
||||
current.x,
|
||||
current.y,
|
||||
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
diagramBoundsFromRect,
|
||||
boundsSidePoint,
|
||||
directionBetween,
|
||||
lane,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
point,
|
||||
translateDiagramBounds,
|
||||
walkOrthogonalSegment,
|
||||
} from "./geometry.js"
|
||||
|
||||
describe("diagram geometry", () => {
|
||||
test("directions are only defined for orthogonal point pairs", () => {
|
||||
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
|
||||
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
|
||||
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
|
||||
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds side points describe border and outside ports", () => {
|
||||
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
|
||||
|
||||
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
|
||||
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
|
||||
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
|
||||
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
|
||||
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
|
||||
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
|
||||
})
|
||||
|
||||
test("bounds helpers create, translate, and union bounds", () => {
|
||||
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
|
||||
|
||||
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
|
||||
translateDiagramBounds(bounds, 3, -1)
|
||||
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
|
||||
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 6,
|
||||
centerX: 5,
|
||||
centerY: 3,
|
||||
})
|
||||
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
|
||||
left: 2,
|
||||
top: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
centerX: 3,
|
||||
centerY: 4,
|
||||
})
|
||||
})
|
||||
|
||||
test("paths compose through lanes while removing duplicate joints", () => {
|
||||
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
|
||||
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
|
||||
point(0, 0),
|
||||
point(4, 0),
|
||||
point(4, 3),
|
||||
point(8, 3),
|
||||
])
|
||||
})
|
||||
|
||||
test("composed paths do not alias caller-owned points", () => {
|
||||
const start = point(0, 0)
|
||||
const end = point(8, 3)
|
||||
const firstPath = pathViaLane(start, lane("x", 4), end)
|
||||
const secondPath = pathViaLane(start, lane("x", 5), end)
|
||||
|
||||
expect(firstPath[0]).not.toBe(start)
|
||||
expect(firstPath[firstPath.length - 1]).not.toBe(end)
|
||||
expect(firstPath[0]).not.toBe(secondPath[0])
|
||||
|
||||
firstPath[0]!.x += 10
|
||||
firstPath[firstPath.length - 1]!.y += 10
|
||||
|
||||
expect(start).toEqual(point(0, 0))
|
||||
expect(end).toEqual(point(8, 3))
|
||||
expect(secondPath[0]).toEqual(point(0, 0))
|
||||
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
|
||||
})
|
||||
|
||||
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
|
||||
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
|
||||
})
|
||||
|
||||
test("orthogonal segment walkers exclude endpoints", () => {
|
||||
const visited: Array<{ x: number; y: number }> = []
|
||||
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
|
||||
visited.push(next)
|
||||
})
|
||||
expect(visited).toEqual([point(1, 0), point(2, 0)])
|
||||
})
|
||||
|
||||
test("orthogonal path points include endpoints without duplicating joints", () => {
|
||||
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
|
||||
point(0, 0),
|
||||
point(1, 0),
|
||||
point(2, 0),
|
||||
point(3, 0),
|
||||
point(3, 1),
|
||||
point(3, 2),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
export type DiagramAxis = "x" | "y"
|
||||
export type DiagramDirection = "up" | "down" | "left" | "right"
|
||||
export type DiagramSide = "left" | "right" | "top" | "bottom"
|
||||
|
||||
export interface DiagramPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DiagramBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface DiagramSegment {
|
||||
from: DiagramPoint
|
||||
to: DiagramPoint
|
||||
axis: DiagramAxis
|
||||
direction: DiagramDirection
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface DiagramLane {
|
||||
axis: DiagramAxis
|
||||
coordinate: number
|
||||
}
|
||||
|
||||
export interface DiagramSpan {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const DIRECTION_AXIS = {
|
||||
left: "x",
|
||||
right: "x",
|
||||
up: "y",
|
||||
down: "y",
|
||||
} as const satisfies Record<DiagramDirection, DiagramAxis>
|
||||
|
||||
const DIRECTION_SIGN = {
|
||||
left: -1,
|
||||
right: 1,
|
||||
up: -1,
|
||||
down: 1,
|
||||
} as const satisfies Record<DiagramDirection, -1 | 1>
|
||||
|
||||
const DIRECTION_SIDE = {
|
||||
left: "left",
|
||||
right: "right",
|
||||
up: "top",
|
||||
down: "bottom",
|
||||
} as const satisfies Record<DiagramDirection, DiagramSide>
|
||||
|
||||
const OPPOSITE_SIDE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
top: "bottom",
|
||||
bottom: "top",
|
||||
} as const satisfies Record<DiagramSide, DiagramSide>
|
||||
|
||||
export function point(x: number, y: number): DiagramPoint {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
|
||||
bounds.left += dx
|
||||
bounds.top += dy
|
||||
bounds.centerX += dx
|
||||
bounds.centerY += dy
|
||||
}
|
||||
|
||||
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
|
||||
if (bounds.length === 0) return undefined
|
||||
const left = Math.min(...bounds.map((bound) => bound.left))
|
||||
const top = Math.min(...bounds.map((bound) => bound.top))
|
||||
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
|
||||
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
|
||||
return diagramBoundsFromRect(left, top, right - left, bottom - top)
|
||||
}
|
||||
|
||||
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
|
||||
if (points.length === 0) return undefined
|
||||
const left = Math.min(...points.map((point) => point.x))
|
||||
const top = Math.min(...points.map((point) => point.y))
|
||||
const right = Math.max(...points.map((point) => point.x))
|
||||
const bottom = Math.max(...points.map((point) => point.y))
|
||||
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
|
||||
return point[axis]
|
||||
}
|
||||
|
||||
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
|
||||
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
|
||||
}
|
||||
|
||||
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return { x: point.x - distance, y: point.y }
|
||||
case "right":
|
||||
return { x: point.x + distance, y: point.y }
|
||||
case "up":
|
||||
return { x: point.x, y: point.y - distance }
|
||||
case "down":
|
||||
return { x: point.x, y: point.y + distance }
|
||||
}
|
||||
}
|
||||
|
||||
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
|
||||
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
|
||||
}
|
||||
|
||||
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
|
||||
return left.x === right.x && left.y === right.y
|
||||
}
|
||||
|
||||
export function directionAxis(direction: DiagramDirection): DiagramAxis {
|
||||
return DIRECTION_AXIS[direction]
|
||||
}
|
||||
|
||||
export function directionSign(direction: DiagramDirection): -1 | 1 {
|
||||
return DIRECTION_SIGN[direction]
|
||||
}
|
||||
|
||||
export function sideForDirection(direction: DiagramDirection): DiagramSide {
|
||||
return DIRECTION_SIDE[direction]
|
||||
}
|
||||
|
||||
export function oppositeSide(side: DiagramSide): DiagramSide {
|
||||
return OPPOSITE_SIDE[side]
|
||||
}
|
||||
|
||||
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
|
||||
if (from.y === to.y) {
|
||||
if (to.x > from.x) return "right"
|
||||
if (to.x < from.x) return "left"
|
||||
}
|
||||
if (from.x === to.x) {
|
||||
if (to.y > from.y) return "down"
|
||||
if (to.y < from.y) return "up"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
|
||||
return point(bounds.centerX, bounds.centerY)
|
||||
}
|
||||
|
||||
export function boundsSidePoint(
|
||||
bounds: DiagramBounds,
|
||||
side: DiagramSide,
|
||||
surface: "border" | "outside" = "outside",
|
||||
): DiagramPoint {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
|
||||
case "right":
|
||||
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
|
||||
case "top":
|
||||
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
|
||||
case "bottom":
|
||||
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
|
||||
return axis === "x" ? bounds.centerX : bounds.centerY
|
||||
}
|
||||
|
||||
export function snapCoordinate(source: number, target: number, tolerance: number): number {
|
||||
return Math.abs(source - target) <= tolerance ? target : source
|
||||
}
|
||||
|
||||
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (const next of points) {
|
||||
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
|
||||
return { axis, coordinate }
|
||||
}
|
||||
|
||||
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
|
||||
return pathThrough([
|
||||
start,
|
||||
withCoordinate(start, routeLane.axis, routeLane.coordinate),
|
||||
withCoordinate(end, routeLane.axis, routeLane.coordinate),
|
||||
end,
|
||||
])
|
||||
}
|
||||
|
||||
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
|
||||
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
|
||||
}
|
||||
|
||||
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
|
||||
const startCoordinate = coordinate(start, axis)
|
||||
const endCoordinate = coordinate(end, axis)
|
||||
const delta = endCoordinate - startCoordinate
|
||||
const sign = Math.sign(delta)
|
||||
if (sign === 0) return endCoordinate
|
||||
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
|
||||
}
|
||||
|
||||
export function orthogonalPath(
|
||||
start: DiagramPoint,
|
||||
end: DiagramPoint,
|
||||
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
|
||||
): DiagramPoint[] {
|
||||
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
|
||||
|
||||
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
|
||||
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
|
||||
}
|
||||
|
||||
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return undefined
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
axis: directionAxis(direction),
|
||||
direction,
|
||||
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
|
||||
}
|
||||
}
|
||||
|
||||
export function walkOrthogonalSegment(
|
||||
from: DiagramPoint,
|
||||
to: DiagramPoint,
|
||||
includeStart: boolean,
|
||||
visit: (point: DiagramPoint) => boolean | void,
|
||||
): void {
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) return
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
|
||||
|
||||
while (!samePoint(cursor, to)) {
|
||||
if (visit(cursor) === false) return
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
|
||||
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
const path: DiagramPoint[] = []
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = directionBetween(from, to)
|
||||
if (!direction) continue
|
||||
|
||||
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
|
||||
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
|
||||
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
|
||||
while (true) {
|
||||
path.push(cursor)
|
||||
if (samePoint(cursor, to)) break
|
||||
cursor = point(cursor.x + dx, cursor.y + dy)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function orderedSpan(left: number, right: number): DiagramSpan {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
|
||||
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
|
||||
}
|
||||
|
||||
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
|
||||
return withCoordinate(segment.from, segment.axis, coordinateValue)
|
||||
}
|
||||
|
||||
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
|
||||
return { start: span.start + amount, end: span.end - amount }
|
||||
}
|
||||
|
||||
export function spanCapacity(span: DiagramSpan): number {
|
||||
return Math.max(0, span.end - span.start + 1)
|
||||
}
|
||||
|
||||
export function centeredSpanStart(span: DiagramSpan, width: number): number {
|
||||
return span.start + Math.floor((spanCapacity(span) - width) / 2)
|
||||
}
|
||||
|
||||
export function midpoint(span: DiagramSpan): number {
|
||||
return Math.round((span.start + span.end) / 2)
|
||||
}
|
||||
|
||||
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
|
||||
return origin + directionSign(direction) * distance
|
||||
}
|
||||
|
||||
export function beforeNearestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
|
||||
return advanceCoordinate(nearest, direction, -clearance)
|
||||
}
|
||||
|
||||
export function afterFarthestCoordinate(
|
||||
points: readonly DiagramPoint[],
|
||||
axis: DiagramAxis,
|
||||
direction: DiagramDirection,
|
||||
clearance: number,
|
||||
): number {
|
||||
const coordinates = points.map((point) => coordinate(point, axis))
|
||||
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
|
||||
return advanceCoordinate(farthest, direction, clearance)
|
||||
}
|
||||
|
||||
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
|
||||
}
|
||||
|
||||
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
|
||||
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface NumberedMermaidLine {
|
||||
readonly lineNumber: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
yield { lineNumber: index + 1, text: lines[index]!.trim() }
|
||||
}
|
||||
}
|
||||
|
||||
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
|
||||
for (const line of numberedMermaidLines(content)) {
|
||||
if (line.text && !line.text.startsWith("%%")) yield line
|
||||
}
|
||||
}
|
||||
|
||||
export function firstMeaningfulMermaidLine(content: string): string | undefined {
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (line && !line.startsWith("%%")) return line
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function stripMermaidQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { StyledText, type TextChunk } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
|
||||
import { ANSI } from "./terminal/ansi.js"
|
||||
|
||||
export interface RenderDiagramGridAnsiOptions<
|
||||
Style extends string,
|
||||
Metadata extends object = object,
|
||||
> extends DiagramCanvasRunOptions<Style, Metadata> {
|
||||
trimOutputEnd?: boolean
|
||||
}
|
||||
|
||||
export function renderDiagramGridAnsi<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
styleAnsi: (run: DiagramCanvasRun<Style, Metadata>) => string | undefined,
|
||||
options: RenderDiagramGridAnsiOptions<Style, Metadata> = {},
|
||||
): string {
|
||||
const { trimOutputEnd, ...runOptions } = options
|
||||
const output: string[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
const ansi = styleAnsi(run)
|
||||
output.push(ansi ? `${ansi}${run.text}${ANSI.reset}` : run.text)
|
||||
},
|
||||
() => {
|
||||
output.push("\n")
|
||||
},
|
||||
runOptions,
|
||||
)
|
||||
|
||||
const text = output.join("")
|
||||
return trimOutputEnd ? text.trimEnd() : text
|
||||
}
|
||||
|
||||
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
|
||||
grid: DiagramCanvas<Style, Metadata>,
|
||||
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
|
||||
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
|
||||
options?: DiagramCanvasRunOptions<Style, Metadata>,
|
||||
): StyledText {
|
||||
const chunks: TextChunk[] = []
|
||||
|
||||
grid.forEachRun(
|
||||
(run) => {
|
||||
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
|
||||
},
|
||||
() => {
|
||||
chunks.push({ __isChunk: true, text: "\n" })
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function splitDiagramLines(value: string): string[] {
|
||||
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
|
||||
|
||||
describe("diagram text helpers", () => {
|
||||
test("splits Mermaid-style line breaks", () => {
|
||||
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
test("measures padded text boxes", () => {
|
||||
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
|
||||
width: 8,
|
||||
height: 4,
|
||||
lines: ["wide", "x"],
|
||||
})
|
||||
})
|
||||
|
||||
test("measures terminal cell width", () => {
|
||||
expect(diagramTextWidth("abc")).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import stringWidth from "string-width"
|
||||
import { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export { splitDiagramLines } from "./text-lines.js"
|
||||
|
||||
export interface DiagramTextBoxSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export function diagramTextWidth(value: string): number {
|
||||
return stringWidth(value)
|
||||
}
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function* diagramTextGraphemes(value: string): Generator<string> {
|
||||
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
|
||||
}
|
||||
|
||||
export function measureDiagramTextBox(
|
||||
value: string,
|
||||
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
|
||||
): DiagramTextBoxSize {
|
||||
const lines = splitDiagramLines(value)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
|
||||
return {
|
||||
width: innerWidth + (options.paddingX ?? 0) * 2,
|
||||
height: lines.length + (options.paddingY ?? 0) * 2,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that merman does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
readonly _tag = "MermaidSyntaxError"
|
||||
|
||||
constructor(
|
||||
readonly kind: MermaidDiagramKind,
|
||||
readonly lineNumber: number,
|
||||
readonly sourceLine: string,
|
||||
reason = "Unsupported syntax",
|
||||
) {
|
||||
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
|
||||
this.name = "MermaidSyntaxError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { orthogonalPathPoints, walkOrthogonalSegment } from "../core/geometry.js"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { diagramRadialCellColorLevel } from "../core/color/map.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
DIAGRAM_ARROW_HEADS,
|
||||
diagramArrowHeadBetween,
|
||||
diagramDiamondCharactersFromBorder,
|
||||
diagramLineGlyph,
|
||||
drawDiagramDiamond,
|
||||
drawDiagramFrame,
|
||||
drawOrthogonalPath,
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
|
||||
import {
|
||||
DATABASE_EDGE_FADE_STYLES,
|
||||
flowchartNodeColorKey,
|
||||
NODE_EDGE_FADE_STYLES,
|
||||
type FlowchartCellMetadata,
|
||||
type FlowchartCellStyle,
|
||||
type FlowchartEdgeFadeStyle,
|
||||
type FlowchartGrid,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartActiveEdgeSelection,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
function mergeFlowchartCell(
|
||||
existing: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
incoming: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
|
||||
): DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata> {
|
||||
if (incoming.style !== "edge" && incoming.style !== "activeEdge") return incoming
|
||||
if (existing.style === "label") return incoming.style === "activeEdge" ? incoming : existing
|
||||
if (incoming.char === " ") return existing
|
||||
if ((existing.style !== "edge" && existing.style !== "activeEdge") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
|
||||
} as DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
}
|
||||
|
||||
function nodeMetadataForCell(
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
border = false,
|
||||
): FlowchartCellMetadata {
|
||||
const key = flowchartNodeColorKey(nodeId, diagramRadialCellColorLevel(bounds, x, y, border))
|
||||
return { nodeId: key, bgNodeId: key }
|
||||
}
|
||||
|
||||
function setNodeText(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, (cellX, cellY) => nodeMetadataForCell(bounds, nodeId, cellX, cellY))
|
||||
}
|
||||
|
||||
function fillNodeInterior(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
nodeId: string,
|
||||
style: FlowchartCellStyle,
|
||||
): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, " ", style, nodeMetadataForCell(bounds, nodeId, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawNode(
|
||||
grid: FlowchartGrid,
|
||||
node: FlowchartNode,
|
||||
bounds: FlowchartNodeBounds,
|
||||
borderStyle: BorderStyle,
|
||||
active: boolean,
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = active ? "activeNode" : node.shape === "database" ? "database" : "node"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawSubroutineNode(grid, bounds, chars, style, node.id)
|
||||
} else if (node.shape === "database") {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDatabaseNode(grid, bounds, chars, style, node.id)
|
||||
} else {
|
||||
fillNodeInterior(grid, bounds, node.id, style)
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
|
||||
)
|
||||
}
|
||||
|
||||
const textTop =
|
||||
node.shape === "decision"
|
||||
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
|
||||
: node.shape === "database"
|
||||
? bounds.top + 2
|
||||
: bounds.top + 1
|
||||
for (const [index, line] of bounds.lines.entries()) {
|
||||
const lineX =
|
||||
node.shape === "subroutine"
|
||||
? bounds.left + 3
|
||||
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
|
||||
setNodeText(grid, bounds, node.id, lineX, textTop + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubroutineNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const leftRailX = bounds.left + 2
|
||||
const rightRailX = bounds.left + bounds.width - 3
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top,
|
||||
chars.topT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top, true),
|
||||
)
|
||||
grid.setCell(
|
||||
leftRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
grid.setCell(
|
||||
rightRailX,
|
||||
bounds.top + bounds.height - 1,
|
||||
chars.bottomT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top + bounds.height - 1, true),
|
||||
)
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
grid.setCell(leftRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, leftRailX, y, true))
|
||||
grid.setCell(rightRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, rightRailX, y, true))
|
||||
}
|
||||
}
|
||||
|
||||
function drawDatabaseNode(
|
||||
grid: FlowchartGrid,
|
||||
bounds: FlowchartNodeBounds,
|
||||
chars: BorderCharacters,
|
||||
style: FlowchartCellStyle,
|
||||
nodeId: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) =>
|
||||
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
|
||||
)
|
||||
const topRailY = bounds.top + 1
|
||||
const bottomRailY = bounds.top + bounds.height - 2
|
||||
for (const y of [topRailY, bottomRailY]) {
|
||||
grid.setCell(bounds.left, y, chars.leftT, style, nodeMetadataForCell(bounds, nodeId, bounds.left, y, true))
|
||||
grid.setCell(
|
||||
bounds.left + bounds.width - 1,
|
||||
y,
|
||||
chars.rightT,
|
||||
style,
|
||||
nodeMetadataForCell(bounds, nodeId, bounds.left + bounds.width - 1, y, true),
|
||||
)
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
grid.setCell(x, y, chars.horizontal, style, nodeMetadataForCell(bounds, nodeId, x, y, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
|
||||
}
|
||||
|
||||
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
|
||||
if (bounds.label) {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
|
||||
for (const [index, line] of lines.entries()) {
|
||||
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
for (const [index, line] of label.lines.entries()) {
|
||||
grid.setText(label.point.x, label.point.y + index, line, style)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute, active = false): void {
|
||||
const { edge, points } = route
|
||||
if (points.length < 2) return
|
||||
const style: FlowchartCellStyle = active ? "activeEdge" : "edge"
|
||||
|
||||
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
|
||||
cornerStyle: "rounded",
|
||||
lineStyle: edge.style === "thick" ? "heavy" : edge.style === "dashed" ? "dashed" : "single",
|
||||
})
|
||||
const end = points[points.length - 1]!
|
||||
const arrowFrom = points[points.length - 2]!
|
||||
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
|
||||
if (edge.label) {
|
||||
drawEdgeLabel(grid, route, active ? "activeEdge" : "label")
|
||||
}
|
||||
}
|
||||
|
||||
function activeEdgeMatches(
|
||||
route: FlowchartEdgeRoute,
|
||||
edgeIndex: number,
|
||||
activeEdge: FlowchartActiveEdgeSelection,
|
||||
): boolean {
|
||||
return (
|
||||
route.edge.from === activeEdge.from &&
|
||||
route.edge.to === activeEdge.to &&
|
||||
(activeEdge.index ?? edgeIndex) === edgeIndex
|
||||
)
|
||||
}
|
||||
|
||||
function activeRoute(
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
diagram: FlowchartDiagram,
|
||||
activeEdge: FlowchartActiveEdgeSelection | undefined,
|
||||
): FlowchartEdgeRoute | undefined {
|
||||
if (!activeEdge) return undefined
|
||||
const edgeIndexes = new Map(diagram.edges.map((edge, index) => [edge, index]))
|
||||
for (let index = 0; index < routes.length; index++) {
|
||||
const route = routes[index]!
|
||||
if (activeEdgeMatches(route, edgeIndexes.get(route.edge) ?? index, activeEdge)) return route
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function activeRoutePoints(
|
||||
route: FlowchartEdgeRoute,
|
||||
from: FlowchartNodeBounds | undefined,
|
||||
): readonly FlowchartPoint[] {
|
||||
const sourcePoint = route.points[0]
|
||||
return from && sourcePoint ? [{ ...flowchartSourceConnector(from, sourcePoint) }, ...route.points] : route.points
|
||||
}
|
||||
|
||||
function styleActivePathCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartCellStyle): void {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (cell && cell.char !== " ") cell.style = style
|
||||
}
|
||||
|
||||
function drawActiveRoute(grid: FlowchartGrid, route: FlowchartEdgeRoute, from: FlowchartNodeBounds | undefined): void {
|
||||
for (const point of orthogonalPathPoints(activeRoutePoints(route, from))) {
|
||||
styleActivePathCell(grid, point.x, point.y, "activeEdge")
|
||||
}
|
||||
if (route.edge.label) {
|
||||
drawEdgeLabel(grid, route, "activeEdge")
|
||||
}
|
||||
}
|
||||
|
||||
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
|
||||
return node?.shape === "database" ? "database" : "node"
|
||||
}
|
||||
|
||||
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
|
||||
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
|
||||
}
|
||||
|
||||
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): boolean {
|
||||
const cell = grid.getCell(x, y)
|
||||
if (!cell || cell.char === " " || cell.style === "label" || DIAGRAM_ARROW_HEADS.has(cell.char)) return false
|
||||
grid.setCell(x, y, cell.char, style)
|
||||
return true
|
||||
}
|
||||
|
||||
function fadeSourcePath(
|
||||
grid: FlowchartGrid,
|
||||
points: FlowchartPoint[],
|
||||
styles: readonly FlowchartEdgeFadeStyle[],
|
||||
): void {
|
||||
let styleIndex = 1
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (let index = 1; index < points.length && styleIndex < styles.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
const direction = flowchartDirectionBetween(from, to)
|
||||
if (!direction) continue
|
||||
walkOrthogonalSegment(from, to, index === 1, (point) => {
|
||||
if (styleIndex >= styles.length) return false
|
||||
const key = `${point.x}:${point.y}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
if (styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)) styleIndex += 1
|
||||
}
|
||||
return styleIndex < styles.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function drawSourceConnectors(
|
||||
grid: FlowchartGrid,
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
|
||||
|
||||
for (const route of routes) {
|
||||
const from = bounds.get(route.edge.from)
|
||||
const sourcePoint = route.points[0]
|
||||
if (!from || !sourcePoint) continue
|
||||
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
|
||||
const connector = flowchartSourceConnector(from, sourcePoint)
|
||||
grid.setCell(connector.x, connector.y, connector.char, styles[0])
|
||||
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell) {
|
||||
cell.char = diagramLineGlyph(
|
||||
new Set([routeDirection, connectorDirection]),
|
||||
"rounded",
|
||||
route.edge.style === "thick" ? "heavy" : "single",
|
||||
)
|
||||
cell.style = "edge"
|
||||
}
|
||||
}
|
||||
fadeSourcePath(grid, route.points, styles)
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFlowchartDiagramGrid(
|
||||
diagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartGrid {
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
const layout = layoutFlowchartDiagram(diagram, options)
|
||||
const { bounds, routes, subgraphBounds, width, height } = layout
|
||||
diagram = layout.diagram
|
||||
const grid = new DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>(width, height, {
|
||||
mergeCell: mergeFlowchartCell,
|
||||
})
|
||||
const selectedRoute = activeRoute(routes, diagram, options.activeEdge)
|
||||
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
|
||||
}
|
||||
for (const route of routes) drawRoutedEdge(grid, route)
|
||||
for (const node of diagram.nodes) {
|
||||
const bound = bounds.get(node.id)
|
||||
if (bound) drawNode(grid, node, bound, borderStyle, node.id === options.activeNode)
|
||||
}
|
||||
drawSourceConnectors(grid, diagram, bounds, routes)
|
||||
if (selectedRoute) {
|
||||
const from = bounds.get(selectedRoute.edge.from)
|
||||
drawActiveRoute(grid, selectedRoute, from)
|
||||
}
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const bound = subgraphBounds.get(subgraph.id)
|
||||
if (bound) drawSubgraphLabel(grid, bound)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { renderFlowchartDiagram, renderFlowchartDiagramAnsi } from "./render.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
|
||||
export type {
|
||||
FlowchartDiagram as Diagram,
|
||||
FlowchartActiveEdgeSelection as ActiveEdgeSelection,
|
||||
FlowchartDirection as Direction,
|
||||
FlowchartEdge as Edge,
|
||||
FlowchartEdgeDirection as EdgeDirection,
|
||||
FlowchartEdgeRoute as EdgeRoute,
|
||||
FlowchartNode as Node,
|
||||
FlowchartNodeBounds as NodeBounds,
|
||||
FlowchartNodeShape as NodeShape,
|
||||
FlowchartPoint as Point,
|
||||
FlowchartSubgraph as Subgraph,
|
||||
FlowchartSubgraphBounds as SubgraphBounds,
|
||||
} from "./types.js"
|
||||
export type {
|
||||
FlowchartDiagramAnsiOptions as AnsiRenderOptions,
|
||||
FlowchartDiagramOptions as RenderableOptions,
|
||||
FlowchartDiagramRenderOptions as PlainRenderOptions,
|
||||
} from "./options.js"
|
||||
export type { FlowchartDiagramAnsiTheme as Theme, FlowchartNodeColors as NodeColors } from "./style.js"
|
||||
export { flowchartNodeColorKey as nodeColorKey } from "./style.js"
|
||||
export { isMermaidFlowchartDiagram as is, parseMermaidFlowchartDiagram as parse } from "./parser.js"
|
||||
export { FlowchartDiagramRenderable as Renderable } from "./renderable.js"
|
||||
|
||||
export interface RenderOptions extends FlowchartDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid flowchart string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color
|
||||
? renderFlowchartDiagramAnsi(content, rest)
|
||||
: renderFlowchartDiagram(content, rest as FlowchartDiagramRenderOptions)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
|
||||
const measure = (text: string): number => text.length
|
||||
|
||||
describe("flowchart edge labels", () => {
|
||||
test("places vertical-route labels beside the bus", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 23, y: 5 })
|
||||
})
|
||||
|
||||
test("places labels inline only when padded text fits with clearance", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 13, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 2 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 9, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 7, y: 2 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 2, y: 1 })
|
||||
})
|
||||
|
||||
test("uses vertical bus labels before short terminal branches", () => {
|
||||
expect(
|
||||
flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 155, y: 5 },
|
||||
{ x: 150, y: 5 },
|
||||
{ x: 150, y: 9 },
|
||||
{ x: 146, y: 9 },
|
||||
],
|
||||
"rollback",
|
||||
measure,
|
||||
).point,
|
||||
).toEqual({ x: 151, y: 7 })
|
||||
})
|
||||
|
||||
test("measures br-delimited edge label lines as a block", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 20, y: 2 },
|
||||
],
|
||||
"first<br/>second line",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.lines).toEqual([" first ", " second line "])
|
||||
expect(layout.width).toBe(13)
|
||||
expect(layout.height).toBe(2)
|
||||
})
|
||||
|
||||
test("places multiline horizontal edge labels outside the route row", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 20, y: 5 },
|
||||
],
|
||||
"first<br/>second",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
test("centers multiline vertical edge labels beside their route", () => {
|
||||
const layout = flowchartEdgeLabelLayout(
|
||||
[
|
||||
{ x: 22, y: 2 },
|
||||
{ x: 22, y: 10 },
|
||||
],
|
||||
"one<br/>two<br/>three",
|
||||
measure,
|
||||
)
|
||||
|
||||
expect(layout.point).toEqual({ x: 23, y: 5 })
|
||||
expect(layout.height).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
clampPoint,
|
||||
centeredSpanStart,
|
||||
insetSpan,
|
||||
midpoint,
|
||||
point,
|
||||
pointOnSegment,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
shiftPoint,
|
||||
spanCapacity,
|
||||
type DiagramSegment,
|
||||
} from "../core/geometry.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import type { FlowchartPoint } from "./types.js"
|
||||
|
||||
const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
point: FlowchartPoint
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function flowchartLabelText(label: string): string {
|
||||
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
|
||||
}
|
||||
|
||||
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
|
||||
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
|
||||
}
|
||||
|
||||
function minimumInlineLabelLength(labelWidth: number): number {
|
||||
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
|
||||
}
|
||||
|
||||
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
|
||||
}
|
||||
|
||||
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
|
||||
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
|
||||
}
|
||||
|
||||
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
|
||||
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
|
||||
}
|
||||
|
||||
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
|
||||
if (segment.axis === "x") {
|
||||
const slot = inlineLabelSlot(segment, labelWidth)
|
||||
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
|
||||
|
||||
if (labelHeight > 1) {
|
||||
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
|
||||
}
|
||||
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
|
||||
let roomyHorizontal: DiagramSegment | undefined
|
||||
let verticalBus: DiagramSegment | undefined
|
||||
let longest: DiagramSegment | undefined
|
||||
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
|
||||
if (!verticalBus && segment.axis === "y") verticalBus = segment
|
||||
if (!longest || segment.length > longest.length) longest = segment
|
||||
}
|
||||
|
||||
return roomyHorizontal ?? verticalBus ?? longest
|
||||
}
|
||||
|
||||
function flowchartLabelPoint(
|
||||
points: readonly FlowchartPoint[],
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
): FlowchartPoint {
|
||||
const segment = bestLabelSegment(points, labelWidth)
|
||||
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
|
||||
}
|
||||
|
||||
export function flowchartEdgeLabelLayout(
|
||||
points: readonly FlowchartPoint[],
|
||||
label: string,
|
||||
measure: (text: string) => number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(label, measure)
|
||||
const height = lines.length
|
||||
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import {
|
||||
diagramBoundsFromBounds,
|
||||
diagramBoundsFromPoints,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
translateDiagramBounds,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
flowchartEdgeLabelLayout,
|
||||
flowchartHorizontalLabelRankGap,
|
||||
flowchartLabelWidth,
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export const DEFAULT_MIN_NODE_GAP = 5
|
||||
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
|
||||
export const DEFAULT_MIN_RANK_GAP = 10
|
||||
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
|
||||
export const COMPACT_MIN_RANK_GAP = 4
|
||||
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
|
||||
const SUBGRAPH_PADDING_X = 2
|
||||
const SUBGRAPH_PADDING_TOP = 1
|
||||
const SUBGRAPH_PADDING_BOTTOM = 1
|
||||
|
||||
export interface FlowchartLayout {
|
||||
diagram: FlowchartDiagram
|
||||
bounds: Map<string, FlowchartNodeBounds>
|
||||
routes: FlowchartEdgeRoute[]
|
||||
subgraphBounds: Map<string, FlowchartSubgraphBounds>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
|
||||
|
||||
function horizontalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelGap)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function verticalRankGaps(
|
||||
diagram: FlowchartDiagram,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
rankKeys: readonly number[],
|
||||
fallback: number,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
||||
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (!edge.label) continue
|
||||
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
||||
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
||||
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
||||
|
||||
const labelHeight = splitDiagramLines(edge.label).length
|
||||
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
||||
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
function isHorizontalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "LR" || direction === "RL"
|
||||
}
|
||||
|
||||
export function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
|
||||
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
|
||||
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
|
||||
const innerWidth = width - 4
|
||||
if (node.shape === "decision") {
|
||||
const width = innerWidth + 6
|
||||
return {
|
||||
width: width % 2 === 0 ? width + 1 : width,
|
||||
height: Math.max(5, lines.length + 4),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
|
||||
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
const incoming = new Set<string>()
|
||||
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
const list = outgoing.get(edge.from) ?? []
|
||||
list.push(edge.to)
|
||||
outgoing.set(edge.from, list)
|
||||
incoming.add(edge.to)
|
||||
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
|
||||
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
|
||||
|
||||
const queue = starts.map((node) => node.id)
|
||||
for (const node of starts) ranks.set(node.id, 0)
|
||||
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index]!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
|
||||
}
|
||||
|
||||
const acyclicRanks = new Map<string, number>()
|
||||
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
|
||||
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
|
||||
for (let index = 0; index < acyclicQueue.length; index++) {
|
||||
const id = acyclicQueue[index]!
|
||||
const rank = acyclicRanks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
|
||||
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
|
||||
incomingCounts.set(to, remainingIncoming)
|
||||
if (remainingIncoming === 0) acyclicQueue.push(to)
|
||||
}
|
||||
}
|
||||
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
|
||||
|
||||
return ranks
|
||||
}
|
||||
|
||||
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
||||
translateDiagramBounds(bounds, dx, dy)
|
||||
}
|
||||
|
||||
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
|
||||
for (const route of routes) {
|
||||
for (const point of route.points) {
|
||||
point.x += dx
|
||||
point.y += dy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromBounds(children)
|
||||
}
|
||||
|
||||
function subgraphBoundFromChildren(
|
||||
id: string,
|
||||
label: string,
|
||||
children: readonly FlowchartBounds[],
|
||||
): FlowchartSubgraphBounds {
|
||||
const labelLines = splitDiagramLines(label)
|
||||
const labelHeight = labelLines.length
|
||||
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
||||
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
||||
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
||||
const bottom =
|
||||
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
||||
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
||||
|
||||
if (right - left < minWidth) {
|
||||
const extra = minWidth - (right - left)
|
||||
left -= Math.floor(extra / 2)
|
||||
right += Math.ceil(extra / 2)
|
||||
}
|
||||
|
||||
const width = right - left
|
||||
const height = Math.max(3, bottom - top)
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
labelSide: "top",
|
||||
}
|
||||
}
|
||||
|
||||
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
|
||||
return leftStart <= rightEnd && rightStart <= leftEnd
|
||||
}
|
||||
|
||||
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
const left = bounds.left + 2
|
||||
const height = lines.length
|
||||
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
|
||||
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
|
||||
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
|
||||
}
|
||||
|
||||
function segmentOverlapsSlot(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
slot: FlowchartBounds,
|
||||
): boolean {
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) return false
|
||||
|
||||
const slotRight = slot.left + slot.width - 1
|
||||
const slotBottom = slot.top + slot.height - 1
|
||||
const span = segmentSpan(segment)
|
||||
if (segment.axis === "x") {
|
||||
return (
|
||||
segment.from.y >= slot.top &&
|
||||
segment.from.y <= slotBottom &&
|
||||
spansOverlap(span.start, span.end, slot.left, slotRight)
|
||||
)
|
||||
}
|
||||
return (
|
||||
segment.from.x >= slot.left &&
|
||||
segment.from.x <= slotRight &&
|
||||
spansOverlap(span.start, span.end, slot.top, slotBottom)
|
||||
)
|
||||
}
|
||||
|
||||
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
|
||||
}
|
||||
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (
|
||||
!routeLabelBounds ||
|
||||
!spansOverlap(
|
||||
routeLabelBounds.top,
|
||||
routeLabelBounds.top + routeLabelBounds.height - 1,
|
||||
slot.top,
|
||||
slot.top + slot.height - 1,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return spansOverlap(
|
||||
routeLabelBounds.left,
|
||||
routeLabelBounds.left + routeLabelBounds.width - 1,
|
||||
slot.left,
|
||||
slot.left + slot.width - 1,
|
||||
)
|
||||
}
|
||||
|
||||
function chooseSubgraphLabelSide(
|
||||
bounds: FlowchartSubgraphBounds,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): FlowchartSubgraphBounds["labelSide"] {
|
||||
const topSlot = labelSlot(bounds, "top")
|
||||
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
||||
|
||||
const bottomSlot = labelSlot(bounds, "bottom")
|
||||
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
||||
}
|
||||
|
||||
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
||||
return diagramBoundsFromPoints(points)
|
||||
}
|
||||
|
||||
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
||||
if (!route.edge.label) return undefined
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
|
||||
const { point, width, height } = label
|
||||
return {
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
width,
|
||||
height,
|
||||
centerX: point.x + Math.floor(width / 2),
|
||||
centerY: point.y + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
return routeRenderBounds(
|
||||
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
|
||||
)
|
||||
}
|
||||
|
||||
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
||||
const bounds: FlowchartBounds[] = []
|
||||
for (const route of routes) {
|
||||
const routeBounds = pathBounds(route.points)
|
||||
if (routeBounds) bounds.push(routeBounds)
|
||||
const routeLabelBounds = labelBounds(route)
|
||||
if (routeLabelBounds) bounds.push(routeLabelBounds)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutRankedNodes(
|
||||
diagram: FlowchartDiagram,
|
||||
direction: FlowchartDirection,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
let widestPaddedEdgeLabel = 0
|
||||
for (const edge of diagram.edges) {
|
||||
if (edge.label)
|
||||
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
|
||||
}
|
||||
const rankNodeGap = horizontal
|
||||
? minNodeGap
|
||||
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
const ranksByIndex = new Map<number, FlowchartNode[]>()
|
||||
const normalizedRanks = new Map<string, number>()
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const rank = ranks.get(node.id) ?? 0
|
||||
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
|
||||
normalizedRanks.set(node.id, normalizedRank)
|
||||
const nodes = ranksByIndex.get(normalizedRank) ?? []
|
||||
nodes.push(node)
|
||||
ranksByIndex.set(normalizedRank, nodes)
|
||||
}
|
||||
|
||||
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
|
||||
)
|
||||
const columnHeights = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasHeight = Math.max(1, ...columnHeights)
|
||||
let x = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const columnWidth = columnWidths[rankIndex]!
|
||||
let y = Math.floor((canvasHeight - columnHeights[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const left = x + Math.floor((columnWidth - size.width) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left,
|
||||
top: y,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: y + Math.floor(size.height / 2),
|
||||
})
|
||||
y += size.height + rankNodeGap
|
||||
}
|
||||
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
} else {
|
||||
const rowHeights = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
|
||||
)
|
||||
const rowWidths = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * rankNodeGap
|
||||
)
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rowWidths)
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const rowHeight = rowHeights[rankIndex]!
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + rankNodeGap
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
|
||||
const nodeIds = new Set(subgraph.nodeIds)
|
||||
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
|
||||
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction) continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
|
||||
}
|
||||
return diagram.direction
|
||||
}
|
||||
|
||||
function hasLocalSubgraphDirection(diagram: FlowchartDiagram): boolean {
|
||||
return (diagram.subgraphs ?? []).some((subgraph) => subgraph.direction && subgraph.direction !== diagram.direction)
|
||||
}
|
||||
|
||||
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
|
||||
const nodeIds = new Set<string>()
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
|
||||
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
|
||||
if (subgraph.parentId === subgraphId) {
|
||||
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
return nodeIds
|
||||
}
|
||||
|
||||
function separateLocalSubgraphItems(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
): void {
|
||||
if (!hasLocalSubgraphDirection(diagram)) return
|
||||
|
||||
const coveredNodeIds = new Set<string>()
|
||||
const items: { bounds: FlowchartBounds; nodeIds: Set<string> }[] = []
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
if (subgraph.parentId) continue
|
||||
const bounds = subgraphBounds.get(subgraph.id)
|
||||
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
||||
if (!bounds || nodeIds.size === 0) continue
|
||||
items.push({ bounds, nodeIds })
|
||||
for (const nodeId of nodeIds) coveredNodeIds.add(nodeId)
|
||||
}
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
if (coveredNodeIds.has(node.id)) continue
|
||||
const bounds = nodeBounds.get(node.id)
|
||||
if (bounds) items.push({ bounds, nodeIds: new Set([node.id]) })
|
||||
}
|
||||
|
||||
const horizontal = isHorizontalDirection(diagram.direction)
|
||||
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
|
||||
|
||||
let cursor: number | undefined
|
||||
for (const item of items) {
|
||||
const start = horizontal ? item.bounds.left : item.bounds.top
|
||||
const size = horizontal ? item.bounds.width : item.bounds.height
|
||||
if (cursor === undefined) {
|
||||
cursor = start + size + gap
|
||||
continue
|
||||
}
|
||||
const shift = cursor - start
|
||||
if (shift !== 0) {
|
||||
for (const nodeId of item.nodeIds) {
|
||||
const bounds = nodeBounds.get(nodeId)
|
||||
if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
|
||||
}
|
||||
}
|
||||
cursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
|
||||
function layoutSubgraphs(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): Map<string, FlowchartSubgraphBounds> {
|
||||
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
|
||||
for (const subgraph of [...subgraphs].reverse()) {
|
||||
const children: FlowchartBounds[] = []
|
||||
for (const nodeId of subgraph.nodeIds) {
|
||||
const bound = nodeBounds.get(nodeId)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
|
||||
for (const childSubgraph of subgraphs) {
|
||||
if (childSubgraph.parentId !== subgraph.id) continue
|
||||
const bound = subgraphBounds.get(childSubgraph.id)
|
||||
if (bound) children.push(bound)
|
||||
}
|
||||
if (children.length > 0) {
|
||||
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
||||
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
||||
subgraphBounds.set(subgraph.id, bound)
|
||||
}
|
||||
}
|
||||
|
||||
return subgraphBounds
|
||||
}
|
||||
|
||||
function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
|
||||
const requestedMinRankGap = normalizePositiveInt(
|
||||
options.minRankGap,
|
||||
options.compact
|
||||
? horizontal
|
||||
? COMPACT_MIN_RANK_GAP
|
||||
: COMPACT_MIN_VERTICAL_RANK_GAP
|
||||
: horizontal
|
||||
? DEFAULT_MIN_RANK_GAP
|
||||
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
)
|
||||
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
||||
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
|
||||
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
|
||||
|
||||
let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
separateLocalSubgraphItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
||||
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
||||
if (dx > 0 || dy > 0) {
|
||||
for (const bound of allBounds) translateBounds(bound, dx, dy)
|
||||
translateRoutes(routes, dx, dy)
|
||||
}
|
||||
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
|
||||
}
|
||||
|
||||
export function layoutFlowchartDiagram(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions = {},
|
||||
): FlowchartLayout {
|
||||
const direction = options.direction ?? sourceDiagram.direction
|
||||
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
|
||||
const maxWidth = options.layoutMaxWidth
|
||||
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
||||
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
||||
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
import type { FlowchartDiagramAnsiTheme, FlowchartNodeColors } from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDirection } from "./types.js"
|
||||
|
||||
export interface FlowchartDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
direction?: FlowchartDirection
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
layoutMaxWidth?: number
|
||||
activeNode?: string
|
||||
activeEdge?: FlowchartActiveEdgeSelection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramAnsiOptions extends FlowchartDiagramRenderOptions {
|
||||
theme?: FlowchartDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface FlowchartDiagramOptions extends TextBufferOptions, FlowchartDiagramRenderOptions {
|
||||
content?: string
|
||||
nodeColor?: ColorInput
|
||||
nodeColors?: FlowchartNodeColors
|
||||
nodeBgColors?: FlowchartNodeColors
|
||||
databaseColor?: ColorInput
|
||||
edgeColor?: ColorInput
|
||||
activeNodeColor?: ColorInput
|
||||
activeEdgeColor?: ColorInput
|
||||
labelColor?: ColorInput
|
||||
groupColor?: ColorInput
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeStyle,
|
||||
FlowchartNode,
|
||||
FlowchartSubgraph,
|
||||
} from "./types.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
|
||||
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
|
||||
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
|
||||
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
|
||||
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
|
||||
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
|
||||
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
|
||||
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
|
||||
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
|
||||
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
|
||||
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
|
||||
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
|
||||
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
|
||||
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
|
||||
function normalizeDirection(value?: string): FlowchartDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function normalizeSubgraphId(value: string, index: number): string {
|
||||
const stripped = stripQuotes(value)
|
||||
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
|
||||
}
|
||||
|
||||
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
|
||||
const trimmed = token
|
||||
.trim()
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
|
||||
if (withLabel) {
|
||||
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
|
||||
}
|
||||
|
||||
const label = stripQuotes(trimmed)
|
||||
return { id: normalizeSubgraphId(trimmed, index), label }
|
||||
}
|
||||
|
||||
function parseNodeToken(token: string): FlowchartNode {
|
||||
const trimmed = token.trim().replace(/;$/, "")
|
||||
const database = trimmed.match(DATABASE_NODE_RE)
|
||||
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
|
||||
|
||||
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
|
||||
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
|
||||
|
||||
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
|
||||
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
|
||||
|
||||
const rounded = trimmed.match(ROUNDED_NODE_RE)
|
||||
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
|
||||
|
||||
const decision = trimmed.match(DECISION_NODE_RE)
|
||||
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
|
||||
|
||||
const box = trimmed.match(BOX_NODE_RE)
|
||||
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
|
||||
|
||||
return { id: trimmed, label: trimmed, shape: "box" }
|
||||
}
|
||||
|
||||
function hasExplicitNodeShape(token: string): boolean {
|
||||
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
|
||||
}
|
||||
|
||||
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
|
||||
const node = parseNodeToken(token)
|
||||
const existing = nodes.get(node.id)
|
||||
if (!existing) {
|
||||
nodes.set(node.id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(token)) {
|
||||
existing.label = node.label
|
||||
existing.shape = node.shape
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
|
||||
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
|
||||
subgraph.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
function stripNodeToken(token: string): string {
|
||||
return token
|
||||
.replace(/\s*:::.*$/, "")
|
||||
.replace(/;$/, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createEdge(from: string, to: string, label: string, style: FlowchartEdgeStyle | undefined): FlowchartEdge {
|
||||
return style ? { from, to, label, style } : { from, to, label }
|
||||
}
|
||||
|
||||
export function isMermaidFlowchartDiagram(content: string): boolean {
|
||||
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
|
||||
const nodes = new Map<string, FlowchartNode>()
|
||||
const edges: FlowchartEdge[] = []
|
||||
const subgraphs: FlowchartSubgraph[] = []
|
||||
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
|
||||
let direction: FlowchartDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
const header = line.match(FLOWCHART_HEADER_RE)
|
||||
if (header) {
|
||||
direction = normalizeDirection(header[2])
|
||||
continue
|
||||
}
|
||||
|
||||
// Mermaid CSS styling does not apply to terminal theme rendering.
|
||||
if (IGNORED_PRESENTATION_RE.test(line)) continue
|
||||
|
||||
const subgraphMatch = line.match(SUBGRAPH_RE)
|
||||
if (subgraphMatch) {
|
||||
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
|
||||
const subgraph: FlowchartSubgraph = {
|
||||
...parsed,
|
||||
nodeIds: [],
|
||||
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
|
||||
}
|
||||
subgraphs.push(subgraph)
|
||||
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^end$/i.test(line)) {
|
||||
if (subgraphStack.length === 0) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
|
||||
}
|
||||
subgraphStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
|
||||
|
||||
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
|
||||
if (subgraphDirection) {
|
||||
if (!currentSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'A "direction" statement requires an open subgraph',
|
||||
)
|
||||
}
|
||||
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeEdge = line.match(/^(.+?)\s*(-->|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(.+)$/)
|
||||
const textEdge = line.match(/^(.+?)\s*(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)\s*(.+)$/)
|
||||
const edgeMatch = textEdge ?? pipeEdge
|
||||
if (edgeMatch) {
|
||||
const from = ensureNode(nodes, stripNodeToken(edgeMatch[1]!))
|
||||
const toToken = textEdge ? edgeMatch[5]! : edgeMatch[4]!
|
||||
const to = ensureNode(nodes, stripNodeToken(toToken))
|
||||
addNodeToSubgraph(currentSubgraph, from.id)
|
||||
addNodeToSubgraph(currentSubgraph, to.id)
|
||||
const arrow = textEdge ? edgeMatch[4]! : edgeMatch[2]!
|
||||
const label = textEdge ? edgeMatch[3]! : (edgeMatch[3] ?? "")
|
||||
edges.push(createEdge(from.id, to.id, label.trim(), edgeStyleFromArrow(textEdge ? edgeMatch[2]! : arrow, arrow)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasExplicitNodeShape(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
|
||||
if (unclosedSubgraph) {
|
||||
throw new MermaidSyntaxError(
|
||||
"flowchart",
|
||||
unclosedSubgraph.lineNumber,
|
||||
unclosedSubgraph.sourceLine,
|
||||
'Unclosed subgraph; expected "end"',
|
||||
)
|
||||
}
|
||||
|
||||
return { direction, nodes: [...nodes.values()], edges, subgraphs }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import { renderGridAnsi } from "./style.js"
|
||||
|
||||
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
|
||||
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function renderFlowchartDiagramAnsi(content: string, options: FlowchartDiagramAnsiOptions = {}): string {
|
||||
return renderGridAnsi(drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { RGBA, type BorderStyle, type ColorInput, type RenderContext } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { diagramColorMapsEqual, normalizeDiagramColorMap } from "../core/color/map.js"
|
||||
import { DEFAULT_BORDER_STYLE, drawFlowchartDiagramGrid } from "./drawing.js"
|
||||
import type { FlowchartDiagramRenderOptions, FlowchartDiagramOptions } from "./options.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import {
|
||||
renderGridStyledText,
|
||||
resolveFlowchartStyleColors,
|
||||
type FlowchartGrid,
|
||||
type FlowchartNodeColors,
|
||||
} from "./style.js"
|
||||
import type { FlowchartActiveEdgeSelection, FlowchartDiagram, FlowchartDirection, FlowchartEdge } from "./types.js"
|
||||
|
||||
interface IndexedFlowchartEdge {
|
||||
edge: FlowchartEdge
|
||||
index: number
|
||||
}
|
||||
|
||||
function flowchartActiveEdgesEqual(
|
||||
left: FlowchartActiveEdgeSelection | undefined,
|
||||
right: FlowchartActiveEdgeSelection | undefined,
|
||||
): boolean {
|
||||
return left?.from === right?.from && left?.to === right?.to && left?.index === right?.index
|
||||
}
|
||||
|
||||
export class FlowchartDiagramRenderable extends DiagramRenderable<FlowchartDiagram, FlowchartGrid> {
|
||||
private _compact: boolean
|
||||
private _direction?: FlowchartDirection
|
||||
private _borderStyle: BorderStyle
|
||||
private _minNodeGap?: number
|
||||
private _minRankGap?: number
|
||||
private _layoutMaxWidth?: number
|
||||
private _nodeColor?: RGBA
|
||||
private _databaseColor?: RGBA
|
||||
private _edgeColor?: RGBA
|
||||
private _activeNodeColor?: RGBA
|
||||
private _activeEdgeColor?: RGBA
|
||||
private _nodeColors: Map<string, RGBA>
|
||||
private _nodeBgColors: Map<string, RGBA>
|
||||
private _labelColor?: RGBA
|
||||
private _groupColor?: RGBA
|
||||
private _activeNode?: string
|
||||
private _activeEdge?: FlowchartActiveEdgeSelection
|
||||
private _selectedConnectionIndex = 0
|
||||
constructor(ctx: RenderContext, options: FlowchartDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._compact = options.compact ?? false
|
||||
this._direction = options.direction
|
||||
this._borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
|
||||
this._minNodeGap = options.minNodeGap
|
||||
this._minRankGap = options.minRankGap
|
||||
this._layoutMaxWidth = options.layoutMaxWidth
|
||||
this._nodeColor = parseDiagramRenderableColor(options.nodeColor)
|
||||
this._databaseColor = parseDiagramRenderableColor(options.databaseColor)
|
||||
this._edgeColor = parseDiagramRenderableColor(options.edgeColor)
|
||||
this._activeNodeColor = parseDiagramRenderableColor(options.activeNodeColor)
|
||||
this._activeEdgeColor = parseDiagramRenderableColor(options.activeEdgeColor)
|
||||
this._nodeColors = normalizeDiagramColorMap(options.nodeColors)
|
||||
this._nodeBgColors = normalizeDiagramColorMap(options.nodeBgColors)
|
||||
this._labelColor = parseDiagramRenderableColor(options.labelColor)
|
||||
this._groupColor = parseDiagramRenderableColor(options.groupColor)
|
||||
this._activeNode = options.activeNode
|
||||
this._activeEdge = options.activeEdge
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidFlowchartDiagram(this.content),
|
||||
draw: (diagram) => drawFlowchartDiagramGrid(diagram, this.renderOptions()),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
measure: { trimTop: true, trimBottom: true },
|
||||
})
|
||||
}
|
||||
|
||||
protected override contentChanged(): void {
|
||||
this._activeNode = undefined
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
}
|
||||
|
||||
get compact(): boolean {
|
||||
return this._compact
|
||||
}
|
||||
|
||||
set compact(value: boolean) {
|
||||
if (this._compact === value) return
|
||||
this._compact = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set direction(value: FlowchartDirection | undefined) {
|
||||
if (this._direction === value) return
|
||||
this._direction = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set borderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_BORDER_STYLE
|
||||
if (this._borderStyle === next) return
|
||||
this._borderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get layoutMaxWidth(): number | undefined {
|
||||
return this._layoutMaxWidth
|
||||
}
|
||||
|
||||
set layoutMaxWidth(value: number | undefined) {
|
||||
if (this._layoutMaxWidth === value) return
|
||||
this._layoutMaxWidth = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set nodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._nodeColor, value, (color) => (this._nodeColor = color))
|
||||
}
|
||||
|
||||
set databaseColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._databaseColor, value, (color) => (this._databaseColor = color))
|
||||
}
|
||||
|
||||
set edgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._edgeColor, value, (color) => (this._edgeColor = color))
|
||||
}
|
||||
|
||||
set activeNodeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeNodeColor, value, (color) => (this._activeNodeColor = color))
|
||||
}
|
||||
|
||||
set activeEdgeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeEdgeColor, value, (color) => (this._activeEdgeColor = color))
|
||||
}
|
||||
|
||||
set nodeColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeColors, next)) return
|
||||
this._nodeColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
set nodeBgColors(value: FlowchartNodeColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._nodeBgColors, next)) return
|
||||
this._nodeBgColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
get activeNode(): string | undefined {
|
||||
return this._activeNode
|
||||
}
|
||||
|
||||
set activeNode(value: string | undefined) {
|
||||
if (this._activeNode === value) return
|
||||
this._activeNode = value
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get selectedConnectionIndex(): number {
|
||||
return this._selectedConnectionIndex
|
||||
}
|
||||
|
||||
get selectedConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
return selected ? { from: selected.edge.from, to: selected.edge.to, index: selected.index } : undefined
|
||||
}
|
||||
|
||||
get activeEdge(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this._activeEdge
|
||||
}
|
||||
|
||||
set activeEdge(value: FlowchartActiveEdgeSelection | undefined) {
|
||||
if (flowchartActiveEdgesEqual(this._activeEdge, value)) return
|
||||
this._activeEdge = value ? { ...value } : undefined
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set labelColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._labelColor, value, (color) => (this._labelColor = color))
|
||||
}
|
||||
|
||||
set groupColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._groupColor, value, (color) => (this._groupColor = color))
|
||||
}
|
||||
|
||||
activateFirstNode(): string | undefined {
|
||||
if (this._activeNode) return this._activeNode
|
||||
const node = this.parsedDiagram().nodes[0]
|
||||
if (!node) return undefined
|
||||
this.activeNode = node.id
|
||||
return node.id
|
||||
}
|
||||
|
||||
selectNextConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(1)
|
||||
}
|
||||
|
||||
selectPreviousConnection(): FlowchartActiveEdgeSelection | undefined {
|
||||
return this.selectConnection(-1)
|
||||
}
|
||||
|
||||
private selectConnection(delta: 1 | -1): FlowchartActiveEdgeSelection | undefined {
|
||||
this.activateFirstNode()
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
this._activeEdge = undefined
|
||||
if (outgoing.length === 1) {
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
this._selectedConnectionIndex = (this._selectedConnectionIndex + delta + outgoing.length) % outgoing.length
|
||||
this.invalidateGrid()
|
||||
return this.selectedConnection
|
||||
}
|
||||
|
||||
followSelectedConnection(): string | undefined {
|
||||
const selected = this.selectedOutgoingEdge()
|
||||
if (!selected) return undefined
|
||||
this._activeNode = selected.edge.to
|
||||
this._activeEdge = undefined
|
||||
this._selectedConnectionIndex = 0
|
||||
this.invalidateGrid()
|
||||
return selected.edge.to
|
||||
}
|
||||
|
||||
private activeOutgoingEdges(): IndexedFlowchartEdge[] {
|
||||
if (!this._activeNode) return []
|
||||
return this.parsedDiagram().edges.flatMap((edge, index) =>
|
||||
edge.from === this._activeNode ? [{ edge, index }] : [],
|
||||
)
|
||||
}
|
||||
|
||||
private selectedOutgoingEdge(): IndexedFlowchartEdge | undefined {
|
||||
const outgoing = this.activeOutgoingEdges()
|
||||
if (outgoing.length === 0) return undefined
|
||||
const index = ((this._selectedConnectionIndex % outgoing.length) + outgoing.length) % outgoing.length
|
||||
return outgoing[index]
|
||||
}
|
||||
|
||||
private renderOptions(): FlowchartDiagramRenderOptions {
|
||||
return {
|
||||
compact: this._compact,
|
||||
direction: this._direction,
|
||||
borderStyle: this._borderStyle,
|
||||
minNodeGap: this._minNodeGap,
|
||||
minRankGap: this._minRankGap,
|
||||
layoutMaxWidth: this._layoutMaxWidth,
|
||||
activeNode: this._activeNode,
|
||||
activeEdge: this._activeEdge ?? this.selectedConnection,
|
||||
}
|
||||
}
|
||||
|
||||
private styledText(grid: FlowchartGrid) {
|
||||
return renderGridStyledText(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: this._nodeColor,
|
||||
database: this._databaseColor,
|
||||
edge: this._edgeColor,
|
||||
activeNode: this._activeNodeColor,
|
||||
activeEdge: this._activeEdgeColor,
|
||||
label: this._labelColor,
|
||||
group: this._groupColor,
|
||||
}),
|
||||
this._nodeColors,
|
||||
this._nodeBgColors,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
|
||||
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
|
||||
const width = 5
|
||||
const height = 3
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
lines: [id],
|
||||
left,
|
||||
top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor(height / 2),
|
||||
}
|
||||
}
|
||||
|
||||
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
|
||||
return { direction, nodes: [], edges, subgraphs: [] }
|
||||
}
|
||||
|
||||
describe("flowchart routing", () => {
|
||||
test("routes a simple horizontal edge from source port to target port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes a simple reverse horizontal edge into the target right port", () => {
|
||||
const edge = { from: "A", to: "B", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 19, y: 1 },
|
||||
{ x: 5, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes horizontal back-edges above forward lanes", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: -1 },
|
||||
{ x: 22, y: -4 },
|
||||
{ x: 2, y: -4 },
|
||||
{ x: 2, y: -1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes parallel horizontal edges on independent lanes", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 3 },
|
||||
{ x: 2, y: 6 },
|
||||
{ x: 22, y: 6 },
|
||||
{ x: 22, y: 3 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("spaces parallel horizontal lanes for multiline labels", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
|
||||
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
|
||||
})
|
||||
|
||||
test("routes horizontal fan-out through a shared bus lane", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "B", label: "" },
|
||||
{ from: "A", to: "C", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 6)],
|
||||
["B", bounds("B", 20, 0)],
|
||||
["C", bounds("C", 20, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 5, y: 7 },
|
||||
{ x: 8, y: 7 },
|
||||
{ x: 8, y: 13 },
|
||||
{ x: 19, y: 13 },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
|
||||
const edges = [
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
]
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", edges),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
["C", bounds("C", 24, 0)],
|
||||
["D", bounds("D", 24, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toHaveLength(edges.length)
|
||||
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
|
||||
"A->C",
|
||||
"A->D",
|
||||
"B->C",
|
||||
"B->D",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes vertical back-edges around the left side", () => {
|
||||
const edge = { from: "B", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("TD", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 0, 0)],
|
||||
["B", bounds("B", 0, 12)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: -1, y: 13 },
|
||||
{ x: -4, y: 13 },
|
||||
{ x: -4, y: 1 },
|
||||
{ x: -1, y: 1 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes self edges below the source node", () => {
|
||||
const edge = { from: "A", to: "A", label: "" }
|
||||
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 8, y: 1 },
|
||||
{ x: 8, y: 4 },
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes same-column horizontal-flow edges through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "rollback" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("LR", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 20, 0)],
|
||||
["B", bounds("B", 20, 8)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 22, y: 3 },
|
||||
{ x: 22, y: 7 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes overlapping horizontal-flow columns through vertical ports", () => {
|
||||
const edge = { from: "A", to: "B", label: "merge" }
|
||||
const routes = routeFlowchartEdges(
|
||||
diagram("RL", [edge]),
|
||||
new Map([
|
||||
["A", bounds("A", 1, 8)],
|
||||
["B", bounds("B", 0, 0)],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
edge,
|
||||
points: [
|
||||
{ x: 2, y: 7 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,703 @@
|
||||
import {
|
||||
advanceCoordinate,
|
||||
afterFarthestCoordinate,
|
||||
beforeNearestCoordinate,
|
||||
boundsCenter,
|
||||
boundsSidePoint,
|
||||
centerCoordinate,
|
||||
coordinate,
|
||||
keepAfter,
|
||||
keepBefore,
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
withCoordinate,
|
||||
type DiagramAxis,
|
||||
type DiagramDirection,
|
||||
type DiagramLane,
|
||||
type DiagramSide,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
FlowchartEdge,
|
||||
FlowchartEdgeRoute,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraph,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
|
||||
|
||||
const BUS_CLEARANCE = 3
|
||||
const NODE_CLEARANCE = 2
|
||||
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
|
||||
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
|
||||
type PortRole = "source" | "target"
|
||||
|
||||
interface EdgeRecord {
|
||||
edge: FlowchartEdge
|
||||
sourcePort: FlowchartPoint
|
||||
targetPort: FlowchartPoint
|
||||
}
|
||||
|
||||
function isVerticalDirection(direction: FlowchartDirection): boolean {
|
||||
return direction === "TB" || direction === "TD" || direction === "BT"
|
||||
}
|
||||
|
||||
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
|
||||
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
|
||||
}
|
||||
|
||||
function isVerticalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "BT"
|
||||
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
|
||||
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
|
||||
}
|
||||
|
||||
function isHorizontalBackEdge(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): boolean {
|
||||
return direction === "RL"
|
||||
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
|
||||
}
|
||||
|
||||
function horizontalTravel(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): HorizontalTravel {
|
||||
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
|
||||
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
|
||||
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
|
||||
}
|
||||
|
||||
function verticalBackEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "left")
|
||||
const end = boundsSidePoint(to, "left")
|
||||
const busX = Math.min(
|
||||
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
|
||||
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
|
||||
)
|
||||
return pathViaLane(start, lane("x", busX), end)
|
||||
}
|
||||
|
||||
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const travel = verticalTravel(from, to)
|
||||
const startSide = sideForDirection(travel)
|
||||
const endSide = oppositeSide(startSide)
|
||||
const sourceCenter = boundsCenter(from)
|
||||
const targetCenter = boundsCenter(to)
|
||||
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
|
||||
const end = boundsSidePoint(to, endSide)
|
||||
return orthogonalPath(start, end, { preferredAxis: "y" })
|
||||
}
|
||||
|
||||
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
|
||||
return pathViaLane(start, lane("y", busY), end)
|
||||
}
|
||||
|
||||
function horizontalEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartPoint[] {
|
||||
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
|
||||
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
|
||||
|
||||
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
|
||||
|
||||
const travel = horizontalTravel(from, to, direction)
|
||||
const startSide = sideForDirection(travel)
|
||||
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(bounds, "right")
|
||||
const end = boundsSidePoint(bounds, "bottom")
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
|
||||
const bottomLaneY = bounds.top + bounds.height + 1
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
function parallelEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
laneCoordinate: number,
|
||||
): FlowchartPoint[] {
|
||||
if (!isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "bottom")
|
||||
const end = boundsSidePoint(to, "bottom")
|
||||
return pathViaLane(start, lane("y", laneCoordinate), end)
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
return pathViaLane(start, lane("x", laneCoordinate), end)
|
||||
}
|
||||
|
||||
function labelHeight(edge: FlowchartEdge): number {
|
||||
return edge.label ? splitDiagramLines(edge.label).length : 0
|
||||
}
|
||||
|
||||
function rightRenderExtent(route: FlowchartEdgeRoute): number {
|
||||
let right = Math.max(...route.points.map((point) => point.x))
|
||||
if (route.edge.label) {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
|
||||
right = Math.max(right, label.point.x + label.width - 1)
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
leftBoundary?: number,
|
||||
): FlowchartPoint[] {
|
||||
if (from.id === to.id) return selfEdgePath(from)
|
||||
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
|
||||
return isVerticalBackEdge(from, to, direction)
|
||||
? verticalBackEdgePath(from, to, leftBoundary)
|
||||
: verticalForwardEdgePath(from, to)
|
||||
}
|
||||
|
||||
function sourceFanOutLane(
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPorts: readonly FlowchartPoint[],
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const sourceCoordinate = coordinate(sourcePort, axis)
|
||||
const unclamped = keepBefore(
|
||||
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
|
||||
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepAfter(unclamped, sourceCoordinate, travel)
|
||||
}
|
||||
|
||||
function targetFanInLane(
|
||||
sourcePorts: readonly FlowchartPoint[],
|
||||
targetPort: FlowchartPoint,
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
): number {
|
||||
const targetCoordinate = coordinate(targetPort, axis)
|
||||
const unclamped = keepAfter(
|
||||
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
|
||||
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
|
||||
travel,
|
||||
)
|
||||
return keepBefore(unclamped, targetCoordinate, travel)
|
||||
}
|
||||
|
||||
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
|
||||
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
|
||||
return boundsSidePoint(bounds, side)
|
||||
}
|
||||
|
||||
function horizontalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "RL"
|
||||
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
|
||||
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function verticalForwardRecords(
|
||||
edges: FlowchartEdge[],
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): EdgeRecord[] {
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const records: EdgeRecord[] = []
|
||||
for (const edge of edges) {
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!source || !target) continue
|
||||
const forward =
|
||||
direction === "BT"
|
||||
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
|
||||
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
|
||||
if (!forward) continue
|
||||
records.push({
|
||||
edge,
|
||||
sourcePort: portForTravel(source, travel, "source"),
|
||||
targetPort: portForTravel(target, travel, "target"),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
|
||||
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
|
||||
return subgraph.direction === "RL" ? "left" : "right"
|
||||
}
|
||||
|
||||
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
|
||||
return subgraph.direction === "RL"
|
||||
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
: subgraphBound.left - BUS_CLEARANCE
|
||||
}
|
||||
|
||||
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
|
||||
if (from.centerY <= targetSubgraphBound.centerY) {
|
||||
const start = from.top + from.height
|
||||
const end = targetSubgraphBound.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : start
|
||||
}
|
||||
|
||||
const start = targetSubgraphBound.top + targetSubgraphBound.height
|
||||
const end = from.top - 1
|
||||
return start <= end ? Math.floor((start + end) / 2) : end
|
||||
}
|
||||
|
||||
function horizontalSubgraphExitJoinY(
|
||||
from: FlowchartSubgraphBounds,
|
||||
targetPort: FlowchartPoint,
|
||||
targetBelow: boolean,
|
||||
): number {
|
||||
if (targetBelow) {
|
||||
const outside = from.top + from.height
|
||||
const beforeTarget = targetPort.y - 1
|
||||
const preferred = targetPort.y - BUS_CLEARANCE
|
||||
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
|
||||
}
|
||||
|
||||
const outside = from.top - 1
|
||||
const afterTarget = targetPort.y + 1
|
||||
const preferred = targetPort.y + BUS_CLEARANCE
|
||||
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
|
||||
}
|
||||
|
||||
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
|
||||
const groups = new Map<string, Record[]>()
|
||||
for (const record of records) {
|
||||
const groupKey = key(record)
|
||||
const group = groups.get(groupKey) ?? []
|
||||
group.push(record)
|
||||
groups.set(groupKey, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function fanRoute(
|
||||
edge: FlowchartEdge,
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPort: FlowchartPoint,
|
||||
routeLane: DiagramLane,
|
||||
): FlowchartEdgeRoute {
|
||||
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
|
||||
}
|
||||
|
||||
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
|
||||
const xs = records.map((record) => record.sourcePort.x)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
if (maxX - minX > 1) return [...records]
|
||||
|
||||
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
|
||||
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
|
||||
}
|
||||
|
||||
function routeHorizontalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeVerticalFanIn(
|
||||
records: readonly EdgeRecord[],
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
|
||||
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
|
||||
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
|
||||
if (targetRecords.length < 2) continue
|
||||
const travel = direction === "BT" ? "up" : "down"
|
||||
const targetPort = targetRecords[0]!.targetPort
|
||||
const sourcePorts = targetRecords.map((record) => record.sourcePort)
|
||||
|
||||
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
|
||||
for (const record of targetRecords) {
|
||||
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
leftBoundary: number | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
|
||||
for (const edges of groups.values()) {
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const direction = directionForEdge(edges[0]!)
|
||||
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
|
||||
routes.push(canonicalRoute)
|
||||
handled.add(edges[0]!)
|
||||
let previousRoute = canonicalRoute
|
||||
for (let index = 1; index < edges.length; index++) {
|
||||
const edge = edges[index]!
|
||||
const laneCoordinate = isVerticalDirection(direction)
|
||||
? Math.max(
|
||||
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
|
||||
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
|
||||
)
|
||||
: Math.max(
|
||||
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
|
||||
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
|
||||
)
|
||||
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
|
||||
routes.push(route)
|
||||
handled.add(edge)
|
||||
previousRoute = route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphExitFanIn(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalExitSubgraph(diagram, edge)
|
||||
const source = bounds.get(edge.from)
|
||||
const target = bounds.get(edge.to)
|
||||
if (!subgraph || !source || !target) continue
|
||||
|
||||
const key = `${subgraph.id}:${edge.to}`
|
||||
const group = groups.get(key) ?? []
|
||||
group.push({ edge, subgraph, source })
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const subgraph = group[0]!.subgraph
|
||||
const subgraphBound = subgraphBounds.get(subgraph.id)
|
||||
const target = bounds.get(group[0]!.edge.to)
|
||||
if (!subgraphBound || !target) continue
|
||||
|
||||
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
|
||||
const busX =
|
||||
subgraph.direction === "RL"
|
||||
? subgraphBound.left - BUS_CLEARANCE
|
||||
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
|
||||
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
|
||||
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
|
||||
const targetBelow = target.centerY >= subgraphBound.centerY
|
||||
const targetPort = targetSubgraph
|
||||
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
|
||||
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
|
||||
const joinY = targetSubgraphBound
|
||||
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
|
||||
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
|
||||
const entryX =
|
||||
targetSubgraph && targetSubgraphBound
|
||||
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
|
||||
: targetPort.x
|
||||
|
||||
for (const record of group) {
|
||||
const sourcePort = portForTravel(record.source, travel, "source")
|
||||
routes.push({
|
||||
edge: record.edge,
|
||||
points: pathThrough([
|
||||
sourcePort,
|
||||
{ x: busX, y: sourcePort.y },
|
||||
{ x: busX, y: joinY },
|
||||
{ x: entryX, y: joinY },
|
||||
{ x: entryX, y: targetPort.y },
|
||||
targetPort,
|
||||
]),
|
||||
})
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeHorizontalSubgraphEntries(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
if (!subgraphBounds) return
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const subgraph = horizontalEntrySubgraph(diagram, edge)
|
||||
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!subgraph || !subgraphBound || !from || !to) continue
|
||||
|
||||
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
|
||||
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
|
||||
const travel = verticalTravel(from, to)
|
||||
const sourcePort = portForTravel(from, travel, "source")
|
||||
routes.push({
|
||||
edge,
|
||||
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
|
||||
})
|
||||
handled.add(edge)
|
||||
}
|
||||
}
|
||||
|
||||
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const from = points[index - 1]!
|
||||
const to = points[index]!
|
||||
if (from.x === to.x) {
|
||||
if (
|
||||
from.x >= bounds.left &&
|
||||
from.x <= right &&
|
||||
Math.max(from.y, to.y) >= bounds.top &&
|
||||
Math.min(from.y, to.y) <= bottom
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
from.y >= bounds.top &&
|
||||
from.y <= bottom &&
|
||||
Math.max(from.x, to.x) >= bounds.left &&
|
||||
Math.min(from.x, to.x) <= right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
): FlowchartEdgeRoute {
|
||||
const obstacle = [...bounds.values()].some(
|
||||
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
|
||||
)
|
||||
if (!obstacle) return route
|
||||
|
||||
const from = bounds.get(route.edge.from)
|
||||
const to = bounds.get(route.edge.to)
|
||||
if (!from || !to) return route
|
||||
if (isVerticalDirection(direction)) {
|
||||
const start = boundsSidePoint(from, "right")
|
||||
const end = boundsSidePoint(to, "right")
|
||||
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
|
||||
}
|
||||
|
||||
const start = boundsSidePoint(from, "top")
|
||||
const end = boundsSidePoint(to, "top")
|
||||
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
|
||||
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
|
||||
}
|
||||
|
||||
export function routeFlowchartEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
const routes: FlowchartEdgeRoute[] = []
|
||||
const leftBoundary = subgraphBounds
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(diagram, bounds, directionForEdge, leftBoundary, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (horizontalEdges.length === 0) continue
|
||||
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
|
||||
routeHorizontalFanOut(records, direction, handled, routes)
|
||||
routeHorizontalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
routeHorizontalSubgraphExitFanIn(diagram, bounds, subgraphBounds, handled, routes)
|
||||
routeHorizontalSubgraphEntries(diagram, bounds, subgraphBounds, handled, routes)
|
||||
|
||||
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
|
||||
const verticalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
|
||||
if (verticalEdges.length === 0) continue
|
||||
const records = verticalForwardRecords(verticalEdges, bounds, direction)
|
||||
routeVerticalFanOut(records, direction, handled, routes)
|
||||
routeVerticalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
if (handled.has(edge)) continue
|
||||
const from = bounds.get(edge.from)
|
||||
const to = bounds.get(edge.to)
|
||||
if (!from || !to) continue
|
||||
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
|
||||
}
|
||||
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
if (sourcePoint.y < bounds.top) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
function connectorChar(side: DiagramSide): string {
|
||||
switch (side) {
|
||||
case "left":
|
||||
return "┤"
|
||||
case "right":
|
||||
return "├"
|
||||
case "top":
|
||||
return "┴"
|
||||
case "bottom":
|
||||
return "┬"
|
||||
}
|
||||
}
|
||||
|
||||
export function flowchartSourceConnector(
|
||||
from: FlowchartNodeBounds,
|
||||
sourcePoint: FlowchartPoint,
|
||||
): { x: number; y: number; char: string } {
|
||||
const side = sideForOutsidePoint(from, sourcePoint)
|
||||
const connector = boundsSidePoint(from, side, "border")
|
||||
return {
|
||||
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
|
||||
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
|
||||
char: connectorChar(side),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { RGBA, type ColorInput, type StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRunOptions } from "../core/canvas.js"
|
||||
import { diagramCellColorKey, mappedDiagramColor } from "../core/color/map.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
ansiFg,
|
||||
createColorRampTheme,
|
||||
createAnsiRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramFadeStep,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "activeNode" | "database" | "edge" | "activeEdge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
|
||||
export interface FlowchartCellMetadata {
|
||||
nodeId?: string
|
||||
bgNodeId?: string
|
||||
}
|
||||
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export type FlowchartDiagramAnsiTheme = Partial<Record<FlowchartCellStyle, string>>
|
||||
export type FlowchartNodeColorMap = ReadonlyMap<string, RGBA>
|
||||
export type FlowchartNodeColors = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
|
||||
|
||||
export function flowchartNodeColorKey(nodeId: string, level: number): string {
|
||||
return diagramCellColorKey(nodeId, level)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
activeNode: [221, 255, 246],
|
||||
database: [228, 239, 232],
|
||||
edge: [134, 225, 200],
|
||||
activeEdge: [221, 255, 246],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
|
||||
|
||||
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
|
||||
|
||||
const DEFAULT_ANSI_THEME: Required<Record<FlowchartCellStyle, string>> = {
|
||||
node: ansiFg(DEFAULT_THEME_RGB.node),
|
||||
activeNode: ansiFg(DEFAULT_THEME_RGB.activeNode),
|
||||
database: ansiFg(DEFAULT_THEME_RGB.database),
|
||||
edge: ansiFg(DEFAULT_THEME_RGB.edge),
|
||||
activeEdge: ansiFg(DEFAULT_THEME_RGB.activeEdge),
|
||||
label: ansiFg(DEFAULT_THEME_RGB.label),
|
||||
group: ansiFg(DEFAULT_THEME_RGB.group),
|
||||
...createAnsiRampTheme(NODE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.node, DEFAULT_THEME_RGB.edge),
|
||||
...createAnsiRampTheme(DATABASE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.database, DEFAULT_THEME_RGB.edge),
|
||||
}
|
||||
|
||||
export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const activeNode = colors.activeNode ?? rgba(DEFAULT_THEME_RGB.activeNode)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
const activeEdge = colors.activeEdge ?? rgba(DEFAULT_THEME_RGB.activeEdge)
|
||||
return {
|
||||
node,
|
||||
activeNode,
|
||||
database,
|
||||
edge,
|
||||
activeEdge,
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMappedColor(colors: FlowchartNodeColorMap | undefined, nodeId: string | undefined): RGBA | undefined {
|
||||
return mappedDiagramColor(colors, nodeId)
|
||||
}
|
||||
|
||||
export function renderGridStyledText(
|
||||
grid: FlowchartGrid,
|
||||
colors: FlowchartStyleColors,
|
||||
nodeColors?: FlowchartNodeColorMap,
|
||||
nodeBgColors?: FlowchartNodeColorMap,
|
||||
): StyledText {
|
||||
const useNodeRuns = Boolean(nodeColors?.size || nodeBgColors?.size)
|
||||
const runOptions: DiagramCanvasRunOptions<FlowchartCellStyle, FlowchartCellMetadata> = useNodeRuns
|
||||
? { trimTop: true, trimBottom: true, key: (cell) => [cell.style, cell.nodeId, cell.bgNodeId] }
|
||||
: { trimTop: true, trimBottom: true }
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => nodeMappedColor(nodeColors, run.cell.nodeId) ?? (run.style ? colors[run.style] : undefined),
|
||||
(run) => nodeMappedColor(nodeBgColors, run.cell.bgNodeId),
|
||||
runOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function renderGridAnsi(grid: FlowchartGrid, theme: FlowchartDiagramAnsiTheme = {}): string {
|
||||
const resolved = { ...DEFAULT_ANSI_THEME, ...theme }
|
||||
return renderDiagramGridAnsi(grid, (run) => (run.style ? resolved[run.style] : undefined), {
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
|
||||
|
||||
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
||||
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
|
||||
export type FlowchartEdgeStyle = "thick" | "dashed"
|
||||
|
||||
export interface FlowchartNode {
|
||||
id: string
|
||||
label: string
|
||||
shape: FlowchartNodeShape
|
||||
}
|
||||
|
||||
export interface FlowchartEdge {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style?: FlowchartEdgeStyle
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraph {
|
||||
id: string
|
||||
label: string
|
||||
nodeIds: string[]
|
||||
parentId?: string
|
||||
direction?: FlowchartDirection
|
||||
}
|
||||
|
||||
export interface FlowchartDiagram {
|
||||
direction: FlowchartDirection
|
||||
nodes: FlowchartNode[]
|
||||
edges: FlowchartEdge[]
|
||||
subgraphs?: FlowchartSubgraph[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeSize {
|
||||
width: number
|
||||
height: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface FlowchartSubgraphBounds extends DiagramBounds {
|
||||
id: string
|
||||
label: string
|
||||
labelSide: "top" | "bottom"
|
||||
}
|
||||
|
||||
export type FlowchartPoint = DiagramPoint
|
||||
|
||||
export interface FlowchartEdgeRoute {
|
||||
edge: FlowchartEdge
|
||||
points: FlowchartPoint[]
|
||||
}
|
||||
|
||||
export interface FlowchartActiveEdgeSelection {
|
||||
from: string
|
||||
to: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type FlowchartEdgeDirection = DiagramDirection
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as Flowchart from "./flowchart/index.js"
|
||||
import * as Sequence from "./sequence/index.js"
|
||||
import * as State from "./state/index.js"
|
||||
import { type MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
|
||||
export { Flowchart, Sequence, State }
|
||||
export { MermaidSyntaxError } from "./diagnostics.js"
|
||||
export { createMermaidMarkdownRenderer, type MermaidMarkdownRendererOptions } from "./markdown.js"
|
||||
|
||||
export type DiagramKind = MermaidDiagramKind
|
||||
|
||||
export type ParsedDiagram =
|
||||
| { readonly kind: "flowchart"; readonly diagram: Flowchart.Diagram }
|
||||
| { readonly kind: "sequence"; readonly diagram: Sequence.Diagram }
|
||||
| { readonly kind: "state"; readonly diagram: State.Diagram }
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
/** Theme override. Forwarded to the matching renderer. */
|
||||
theme?: Flowchart.Theme | Sequence.Theme | State.Theme
|
||||
}
|
||||
|
||||
export class UnknownDiagramError extends Error {
|
||||
readonly _tag = "UnknownDiagramError"
|
||||
constructor(content: string) {
|
||||
const head = firstMeaningfulLine(content) ?? "(empty)"
|
||||
super(
|
||||
`Could not detect diagram kind. Expected the first non-empty line to start with ` +
|
||||
`"flowchart", "graph", "sequenceDiagram", or "stateDiagram[-v2]". Got: "${head}"`,
|
||||
)
|
||||
this.name = "UnknownDiagramError"
|
||||
}
|
||||
}
|
||||
|
||||
function firstMeaningfulLine(content: string): string | undefined {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("%%"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the diagram kind from the leading Mermaid declaration.
|
||||
*
|
||||
* Returns `undefined` if the content does not look like any supported diagram.
|
||||
*/
|
||||
export function detect(content: string): DiagramKind | undefined {
|
||||
return detectMermaidDiagram(content)
|
||||
}
|
||||
|
||||
/** True if the content is recognizably a supported Mermaid diagram. */
|
||||
export function isMermaid(content: string): boolean {
|
||||
return detect(content) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render any supported Mermaid string for the terminal.
|
||||
*
|
||||
* The leading `flowchart`/`sequenceDiagram`/`stateDiagram-v2` line picks the
|
||||
* right renderer. Defaults to ANSI-colored output; pass `{ color: false }`
|
||||
* for plain text.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
const opts = options as { color?: boolean; theme?: unknown }
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return Flowchart.render(content, opts as Flowchart.RenderOptions)
|
||||
case "sequence":
|
||||
return Sequence.render(content, opts as Sequence.RenderOptions)
|
||||
case "state":
|
||||
return State.render(content, opts as State.RenderOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse any supported Mermaid string into a discriminated union AST.
|
||||
*
|
||||
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
|
||||
*/
|
||||
export function parse(content: string): ParsedDiagram {
|
||||
const kind = detect(content)
|
||||
if (!kind) throw new UnknownDiagramError(content)
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return { kind, diagram: Flowchart.parse(content) }
|
||||
case "sequence":
|
||||
return { kind, diagram: Sequence.parse(content) }
|
||||
case "state":
|
||||
return { kind, diagram: State.parse(content) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
createMarkdownCodeBlockRenderer,
|
||||
type ColorInput,
|
||||
type MarkdownOptions,
|
||||
type MouseEvent,
|
||||
type RenderContext,
|
||||
} from "@opentui/core"
|
||||
import { MermaidSyntaxError } from "./diagnostics.js"
|
||||
import { detectMermaidDiagram } from "./detect.js"
|
||||
import { FlowchartDiagramRenderable } from "./flowchart/renderable.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { SequenceDiagramRenderable } from "./sequence/renderable.js"
|
||||
import { parseMermaidStateDiagram } from "./state/parser.js"
|
||||
import { StateDiagramRenderable } from "./state/renderable.js"
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
secondary?: ColorInput
|
||||
muted?: ColorInput
|
||||
accent?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
|
||||
export function createMermaidMarkdownRenderer(
|
||||
ctx: RenderContext,
|
||||
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
|
||||
): NonNullable<MarkdownOptions["renderNode"]> {
|
||||
return createMarkdownCodeBlockRenderer({
|
||||
mermaid: (token) => {
|
||||
const kind = detectMermaidDiagram(token.text)
|
||||
if (!kind) return undefined
|
||||
|
||||
try {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
parseMermaidFlowchartDiagram(token.text)
|
||||
break
|
||||
case "sequence":
|
||||
parseMermaidSequenceDiagram(token.text)
|
||||
break
|
||||
case "state":
|
||||
parseMermaidStateDiagram(token.text)
|
||||
break
|
||||
}
|
||||
const options = typeof input === "function" ? input() : input
|
||||
const colors = options.colors ?? {}
|
||||
const diagram = (() => {
|
||||
switch (kind) {
|
||||
case "flowchart":
|
||||
return new FlowchartDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
nodeColor: colors.primary,
|
||||
databaseColor: colors.secondary,
|
||||
edgeColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
groupColor: colors.muted,
|
||||
})
|
||||
case "sequence":
|
||||
return new SequenceDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
compact: options.compact,
|
||||
participantColor: colors.primary,
|
||||
lifelineColor: colors.muted,
|
||||
groupColor: colors.secondary,
|
||||
requestColor: colors.primary,
|
||||
responseColor: colors.primary,
|
||||
noteColor: colors.warning,
|
||||
noteBackgroundColor: colors.background,
|
||||
})
|
||||
case "state":
|
||||
return new StateDiagramRenderable(ctx, {
|
||||
content: token.text,
|
||||
stateColor: colors.primary,
|
||||
compositeColor: colors.muted,
|
||||
transitionColor: colors.secondary,
|
||||
labelColor: colors.text,
|
||||
noteBorderColor: colors.warning,
|
||||
noteTextColor: colors.warning,
|
||||
noteConnectorColor: colors.muted,
|
||||
activeStateColor: colors.accent,
|
||||
activeTransitionColor: colors.accent,
|
||||
startColor: colors.muted,
|
||||
endColor: colors.muted,
|
||||
choiceColor: colors.primary,
|
||||
})
|
||||
}
|
||||
})()
|
||||
diagram.width = "100%"
|
||||
diagram.height = diagram.renderedHeight
|
||||
diagram.marginTop = 1
|
||||
diagram.selectable = false
|
||||
let drag: { x: number; y: number } | undefined
|
||||
diagram.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = { x: event.x, y: event.y }
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDrag = (event: MouseEvent) => {
|
||||
if (!drag) return
|
||||
const dx = event.x - drag.x
|
||||
drag = { x: event.x, y: event.y }
|
||||
if (dx) diagram.scrollX -= dx
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
diagram.onMouseDragEnd = () => {
|
||||
drag = undefined
|
||||
}
|
||||
diagram.onMouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
drag = undefined
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
return diagram
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError) return undefined
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})!
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMermaidMarkdownRenderer } from "./markdown.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.merman",
|
||||
setup(context) {
|
||||
context.markdown.registerCodeBlockRenderer(
|
||||
"mermaid",
|
||||
createMermaidMarkdownRenderer(context.renderer, () => ({
|
||||
compact: true,
|
||||
colors: {
|
||||
text: context.theme.markdown.text,
|
||||
primary: context.theme.text.default,
|
||||
secondary: context.theme.text.subdued,
|
||||
muted: context.theme.border.default,
|
||||
accent: context.theme.text.action.primary.focused,
|
||||
warning: context.theme.text.default,
|
||||
background: context.theme.background.default,
|
||||
},
|
||||
})),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,896 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { SequenceDiagramRenderable } from "./renderable.js"
|
||||
|
||||
describe("SequenceDiagram", () => {
|
||||
test("parses Mermaid sequenceDiagram participants and messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expect(diagram.participants).toEqual([
|
||||
{ id: "B", label: "Browser" },
|
||||
{ id: "S", label: "Server" },
|
||||
])
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "B", to: "S", label: "GET /", style: "solid" },
|
||||
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
|
||||
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a terminal sequence diagram", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────▶
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◀─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders a compact terminal sequence diagram without participant boxes", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Leaf as leaf tool
|
||||
participant Location as LocationMutation
|
||||
participant File as FileMutation
|
||||
Leaf->>Location: resolve(path)
|
||||
Location-->>Leaf: Plan(target, authority anchor)
|
||||
Leaf->>File: commit(plan)
|
||||
File->>Location: revalidate(plan)
|
||||
Location-->>File: same target or reject
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
leaf tool LocationMutation FileMutation
|
||||
│ │ │
|
||||
├─ resolve(path) ───────────────────▶ │
|
||||
│ │ │
|
||||
◀─ Plan(target, authority anchor) ──┤ │
|
||||
│ │ │
|
||||
├─ commit(plan) ───────────────────────────────────────────────▶
|
||||
│ │ │
|
||||
│ ◀─ revalidate(plan) ───────┤
|
||||
│ │ │
|
||||
│ ├─ same target or reject ──▶
|
||||
│ │ │
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps structured sequence steps visible in compact mode", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant Worker
|
||||
participant Store
|
||||
Note over Worker,Store: transaction
|
||||
alt accepted
|
||||
Worker->>Worker: prepare
|
||||
Worker->>Store: commit
|
||||
end
|
||||
`,
|
||||
{ compact: true },
|
||||
)
|
||||
|
||||
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
|
||||
})
|
||||
|
||||
test("keeps compact labels above arrows when they do not fit inline", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
|
||||
{ compact: true },
|
||||
)
|
||||
const lines = output.split("\n")
|
||||
|
||||
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
|
||||
lines.findIndex((line) => line.includes("▶")),
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes invalid participant gaps for text and live rendering", async () => {
|
||||
const content = "sequenceDiagram\n A->>B: hello"
|
||||
|
||||
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
|
||||
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content,
|
||||
minParticipantGap: Number.NaN,
|
||||
})
|
||||
|
||||
expect(diagram.minParticipantGap).toBe(18)
|
||||
diagram.minParticipantGap = 0
|
||||
expect(diagram.minParticipantGap).toBe(1)
|
||||
diagram.minParticipantGap = 3.9
|
||||
expect(diagram.minParticipantGap).toBe(3)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("connects participant headers to lifelines", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("renders notes and long cross-participant messages in order", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
participant Store as Ticket store
|
||||
Note over Browser,Server: native browser Basic prompt
|
||||
Browser->>Server: POST connect-token
|
||||
Server->>Store: issue { ptyID, scope }
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder(
|
||||
"native browser Basic prompt",
|
||||
"POST connect-token",
|
||||
"issue { ptyID, scope }",
|
||||
)
|
||||
})
|
||||
|
||||
test("renders notes to the left and right of a participant", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant OpenTUI
|
||||
Note right of OpenTUI: child is not renderable
|
||||
Note left of OpenTUI: remove failed`)
|
||||
|
||||
expect(output).toContain("child is not renderable")
|
||||
expect(output).toContain("remove failed")
|
||||
})
|
||||
|
||||
test("preserves the parsed shape of notes over participants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
Note over A: hello`)
|
||||
|
||||
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
|
||||
})
|
||||
|
||||
test("parses activation shorthand and control blocks", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
alt accepted
|
||||
Server-->>-Browser: response
|
||||
else rejected
|
||||
activate Server
|
||||
Server-->>Browser: error
|
||||
deactivate Server
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Browser",
|
||||
to: "Server",
|
||||
label: "request",
|
||||
style: "solid",
|
||||
activate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
from: "Server",
|
||||
to: "Browser",
|
||||
label: "response",
|
||||
style: "dashed",
|
||||
deactivate: "Server",
|
||||
},
|
||||
},
|
||||
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
|
||||
{ type: "activation", activation: { participant: "Server", active: true } },
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
|
||||
},
|
||||
{ type: "activation", activation: { participant: "Server", active: false } },
|
||||
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses activation syntax without rendering activation bars", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>+Server: request
|
||||
Server-->>-Browser: response
|
||||
`)
|
||||
|
||||
expect(output).not.toContain("┃")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(diagram.messages).toEqual([
|
||||
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
|
||||
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
|
||||
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
|
||||
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
|
||||
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
|
||||
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
|
||||
])
|
||||
})
|
||||
|
||||
test("renders Mermaid arrow head variants", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
A->B: open solid
|
||||
B-->A: open dashed
|
||||
A-xB: failed solid
|
||||
B--xA: failed dashed
|
||||
A-)B: async solid
|
||||
B--)A: async dashed
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders boxed alt else regions", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
alt accepted
|
||||
Browser->>Server: ok
|
||||
else rejected
|
||||
Server-->>Browser: no
|
||||
end
|
||||
`)
|
||||
|
||||
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
|
||||
expect(output).not.toContain("end alt")
|
||||
})
|
||||
|
||||
test("expands a fragment frame for a longer else label", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`)
|
||||
const lines = output.split("\n")
|
||||
const elseRow = lines.find((line) => line.includes("validation failed"))!
|
||||
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
|
||||
|
||||
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
|
||||
})
|
||||
|
||||
test("preserves combined graphemes in participant names", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A as Cafe\u0301
|
||||
participant B
|
||||
A->>B: hi`)
|
||||
|
||||
expect(output).toContain("Cafe\u0301")
|
||||
})
|
||||
|
||||
test("renders fragment boxes with lifeline overhang", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
})
|
||||
|
||||
test("supports configurable fragment border styles", () => {
|
||||
const output = renderSequenceDiagram(
|
||||
`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else no
|
||||
B-->>A: no
|
||||
end
|
||||
`,
|
||||
{ fragmentBorderStyle: "double" },
|
||||
)
|
||||
|
||||
expect(output).toContain("╔")
|
||||
expect(output).toContain("╠")
|
||||
expect(output).toContain("╚")
|
||||
expect(output).toContain("═")
|
||||
expect(output).toContain("║")
|
||||
})
|
||||
|
||||
test("parses and renders autonumbered messages", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
Browser->>API: request
|
||||
API-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
|
||||
expect(output).toContain("1. request")
|
||||
expect(output).toContain("2. response")
|
||||
})
|
||||
|
||||
test("supports autonumber start and increment", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
autonumber 10 5
|
||||
Browser->>API: first
|
||||
API-->>Browser: second
|
||||
`)
|
||||
|
||||
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
|
||||
expect(output).toContain("10. first")
|
||||
expect(output).toContain("15. second")
|
||||
})
|
||||
|
||||
test("parses and renders loop regions", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
loop retry up to 3x
|
||||
Browser->>API: GET /users/42
|
||||
API-->>Browser: 503
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.steps[0]).toEqual({
|
||||
type: "fragment",
|
||||
fragment: { kind: "loop", label: "retry up to 3x" },
|
||||
})
|
||||
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
|
||||
expect(output).not.toContain("end loop")
|
||||
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
|
||||
})
|
||||
|
||||
test("parses Mermaid box participant groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
end
|
||||
box Purple Storage Layer
|
||||
participant DB
|
||||
end
|
||||
box "Purple Literal Label"
|
||||
participant Worker
|
||||
end
|
||||
Browser->>API: request
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([
|
||||
{ label: "Backend", participantIds: ["API", "Cache"] },
|
||||
{ label: "Storage Layer", participantIds: ["DB"] },
|
||||
{ label: "Purple Literal Label", participantIds: ["Worker"] },
|
||||
])
|
||||
expect(diagram.steps).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
message: { from: "Browser", to: "API", label: "request", style: "solid" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("adds implicit participants inside box groups", () => {
|
||||
const diagram = parseMermaidSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant API
|
||||
box Backend
|
||||
API->>DB: query
|
||||
end
|
||||
`)
|
||||
|
||||
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
|
||||
expect(diagram.steps).toEqual([
|
||||
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not clip long non-adjacent messages or notes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room
|
||||
`)
|
||||
|
||||
expect(output).toContain("this message needs room past the final participant")
|
||||
expect(output).toContain("this note also needs full horizontal room")
|
||||
})
|
||||
|
||||
test("keeps long content inside participant groups and fragment frames", () => {
|
||||
const group = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragment = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`)
|
||||
|
||||
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
const groupedNote = renderSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note over A,C: this note text runs far outside of the group container boundary
|
||||
end`)
|
||||
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
Note over A,C: this non adjacent note is deliberately much wider than the frame
|
||||
end`)
|
||||
const nested = renderSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`)
|
||||
|
||||
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
|
||||
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
|
||||
expect(nested).toContain("span ─╮│")
|
||||
expect(nested).toContain("──────╯│")
|
||||
})
|
||||
|
||||
test("does not draw external participants inside groups expanded by self messages", () => {
|
||||
const output = renderSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`)
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
|
||||
test("renders full-height participant group boxes", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
participant Cache
|
||||
participant DB
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
API->>Cache: get user:42
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("lets message lines pass through group borders without intersections", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
Browser->>API: GET /users/42
|
||||
`)
|
||||
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
|
||||
|
||||
expect(arrowLine).toContain("───────────────▶")
|
||||
expect(arrowLine).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("renders self messages as loopback arrows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◀────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("places two spacer rows above note badges and one below", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
Note over Browser,Server: phase
|
||||
Browser->>Server: two
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const noteRow = lines.findIndex((line) => line.includes("phase"))
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
test("renders br-delimited message labels across multiple rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
`)
|
||||
|
||||
expect(output).toContain("POST connect-token")
|
||||
expect(output).toContain("· Basic (cached by browser)")
|
||||
expect(output).toContain("· X-OpenCode-Ticket: 1")
|
||||
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
|
||||
})
|
||||
|
||||
test("colors request and response messages differently", async () => {
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const responseColor = parseColor("#F59E0B")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response`,
|
||||
requestColor,
|
||||
responseColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const requestSpan = spans.find((span) => span.text.includes("request"))
|
||||
const responseSpan = spans.find((span) => span.text.includes("response"))
|
||||
|
||||
expect(requestSpan?.fg.equals(requestColor)).toBe(true)
|
||||
expect(responseSpan?.fg.equals(responseColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("repaints sequence colors after mounting without changing diagram text", async () => {
|
||||
const initialColor = parseColor("#38BDF8")
|
||||
const updatedColor = parseColor("#A78BFA")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: "sequenceDiagram\n Browser->>Server: request",
|
||||
requestColor: initialColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
const before = testRenderer.captureCharFrame()
|
||||
|
||||
diagram.batchUpdate(() => {
|
||||
diagram.requestColor = updatedColor
|
||||
diagram.lifelineColor = updatedColor
|
||||
})
|
||||
await testRenderer.renderOnce()
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(testRenderer.captureCharFrame()).toBe(before)
|
||||
expect(spans.find((span) => span.text.includes("request"))?.fg.equals(updatedColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens arrow departure colors away from lifelines over five cells", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Browser->>Server: request`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("▶")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tweens self-message departure colors away from lifelines", async () => {
|
||||
const lifelineColor = parseColor("#94A3B8")
|
||||
const requestColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
Service->>Service: validate`,
|
||||
lifelineColor,
|
||||
requestColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const arrowLine = testRenderer
|
||||
.captureSpans()
|
||||
.lines.find((line) => line.spans.some((span) => span.text.includes("├")))
|
||||
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan).toBeDefined()
|
||||
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors headers, header rules, and note badges separately", async () => {
|
||||
const participantColor = parseColor("#E5E7EB")
|
||||
const lifelineColor = parseColor("#64748B")
|
||||
const noteColor = parseColor("#A78BFA")
|
||||
const noteBackgroundColor = parseColor("#312E81")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
participant Browser
|
||||
participant Server
|
||||
Note over Browser,Server: native browser Basic prompt`,
|
||||
participantColor,
|
||||
lifelineColor,
|
||||
noteColor,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const participantSpan = spans.find((span) => span.text.includes("Browser"))
|
||||
const headerRuleSpan = spans.find((span) => span.text.includes("┬"))
|
||||
const noteSpan = spans.find((span) => span.text.includes("native browser Basic prompt"))
|
||||
|
||||
expect(participantSpan?.fg.equals(participantColor)).toBe(true)
|
||||
expect(headerRuleSpan?.fg.equals(lifelineColor)).toBe(true)
|
||||
expect(noteSpan?.fg.equals(noteColor)).toBe(true)
|
||||
expect(noteSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors group boxes separately from fragments", async () => {
|
||||
const groupColor = parseColor("#8BA394")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
box Backend
|
||||
participant API
|
||||
end
|
||||
alt ok
|
||||
API->>API: validate
|
||||
end`,
|
||||
groupColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const groupSpan = spans.find((span) => span.text.includes("Backend"))
|
||||
const fragmentSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
|
||||
expect(groupSpan?.fg.equals(groupColor)).toBe(true)
|
||||
expect(fragmentSpan?.fg.equals(groupColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds a background to fragment labels", async () => {
|
||||
const noteBackgroundColor = parseColor("#24382F")
|
||||
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
|
||||
|
||||
try {
|
||||
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
|
||||
content: `sequenceDiagram
|
||||
alt ok
|
||||
Browser->>Server: request
|
||||
else no
|
||||
Server-->>Browser: response
|
||||
end`,
|
||||
noteBackgroundColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const altSpan = spans.find((span) => span.text.includes("alt: ok"))
|
||||
const elseSpan = spans.find((span) => span.text.includes("else: no"))
|
||||
|
||||
expect(altSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
expect(elseSpan?.bg.equals(noteBackgroundColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("prints ANSI colors for terminal dumps", () => {
|
||||
const output = renderSequenceDiagramAnsi(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: request
|
||||
Server-->>Browser: response
|
||||
`)
|
||||
|
||||
expect(output).toContain("\x1b[38;2;134;225;200m")
|
||||
expect(output).toContain("\x1b[38;2;230;177;126m")
|
||||
expect(output).toContain("\x1b[38;2;115;153;138m")
|
||||
expect(output).toContain("\x1b[38;2;130;211;188m")
|
||||
expect(output).toContain("\x1b[38;2;131;145;126m")
|
||||
expect(output).toContain("\x1b[38;2;210;171;126m")
|
||||
expect(output).toContain("request")
|
||||
expect(output).toContain("response")
|
||||
expect(output).toContain("◀")
|
||||
expect(output).toContain("┤")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridAnsi, renderSequenceGridText } from "./render-grid.js"
|
||||
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
|
||||
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
|
||||
}
|
||||
|
||||
export function renderSequenceDiagramAnsi(content: string, options: SequenceDiagramAnsiOptions = {}): string {
|
||||
return renderSequenceGridAnsi(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { BorderChars, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
|
||||
import {
|
||||
createSequencePlacementPlan,
|
||||
type SequenceGroupPlacement,
|
||||
type SequenceHorizontalBounds,
|
||||
type SequenceStepPlacement,
|
||||
} from "./placement.js"
|
||||
import type { SequenceGrid } from "./render-grid.js"
|
||||
import { SEQUENCE_FADE_STEPS as FADE_STEPS } from "./style.js"
|
||||
import type {
|
||||
MessageStyle,
|
||||
SequenceArrowHead,
|
||||
SequenceCellStyle,
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
} from "./types.js"
|
||||
|
||||
const SEQUENCE_BORDER = BorderChars.rounded
|
||||
|
||||
function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1): string {
|
||||
switch (head) {
|
||||
case "open":
|
||||
return direction === 1 ? ">" : "<"
|
||||
case "cross":
|
||||
return "✕"
|
||||
case "async":
|
||||
return direction === 1 ? ")" : "("
|
||||
default:
|
||||
return direction === 1 ? "▶" : "◀"
|
||||
}
|
||||
}
|
||||
|
||||
function createGrid(width: number, height: number): SequenceGrid {
|
||||
return new DiagramCanvas(width, height)
|
||||
}
|
||||
|
||||
function setCell(grid: SequenceGrid, x: number, y: number, char: string, style?: SequenceCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(grid: SequenceGrid, x: number, y: number, text: string, style?: SequenceCellStyle): void {
|
||||
grid.setText(Math.max(0, x), y, text, style)
|
||||
}
|
||||
|
||||
function setArrowDepartureFade(
|
||||
grid: SequenceGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
direction: 1 | -1,
|
||||
style: SequenceCellStyle,
|
||||
): void {
|
||||
setCell(
|
||||
grid,
|
||||
x,
|
||||
y,
|
||||
direction === 1 ? SEQUENCE_BORDER.leftT : SEQUENCE_BORDER.rightT,
|
||||
`${style}Fade1` as SequenceCellStyle,
|
||||
)
|
||||
for (let step = 2; step <= 5; step++) {
|
||||
setCell(grid, x + direction * (step - 1), y, SEQUENCE_BORDER.horizontal, `${style}Fade${step}` as SequenceCellStyle)
|
||||
}
|
||||
}
|
||||
|
||||
function groupVerticalChar(existing: string | undefined): string | undefined {
|
||||
switch (existing) {
|
||||
case undefined:
|
||||
case " ":
|
||||
return SEQUENCE_BORDER.vertical
|
||||
case SEQUENCE_BORDER.vertical:
|
||||
return SEQUENCE_BORDER.vertical
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setGroupVerticalCell(grid: SequenceGrid, x: number, y: number): void {
|
||||
const existing = grid.getCell(x, y)?.char
|
||||
const char = groupVerticalChar(existing)
|
||||
if (char) setCell(grid, x, y, char, "group")
|
||||
}
|
||||
|
||||
function renderParticipantGroups(
|
||||
grid: SequenceGrid,
|
||||
groupBounds: readonly SequenceGroupPlacement[],
|
||||
bottomY: number,
|
||||
): void {
|
||||
for (const bounds of groupBounds) {
|
||||
for (let x = bounds.leftX; x <= bounds.rightX; x++) {
|
||||
setCell(grid, x, 0, SEQUENCE_BORDER.horizontal, "group")
|
||||
setCell(grid, x, bottomY, SEQUENCE_BORDER.horizontal, "group")
|
||||
}
|
||||
|
||||
setCell(grid, bounds.leftX, 0, SEQUENCE_BORDER.topLeft, "group")
|
||||
setCell(grid, bounds.rightX, 0, SEQUENCE_BORDER.topRight, "group")
|
||||
setCell(grid, bounds.leftX, bottomY, SEQUENCE_BORDER.bottomLeft, "group")
|
||||
setCell(grid, bounds.rightX, bottomY, SEQUENCE_BORDER.bottomRight, "group")
|
||||
|
||||
for (let y = 1; y < bottomY; y++) {
|
||||
setGroupVerticalCell(grid, bounds.leftX, y)
|
||||
setGroupVerticalCell(grid, bounds.rightX, y)
|
||||
}
|
||||
|
||||
if (bounds.labelText) {
|
||||
setText(grid, bounds.leftX + 2, 0, bounds.labelText, "group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFragmentWalls(
|
||||
grid: SequenceGrid,
|
||||
bounds: SequenceHorizontalBounds,
|
||||
startY: number,
|
||||
endY: number,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (endY < startY) return
|
||||
const border = BorderChars[borderStyle]
|
||||
|
||||
for (let y = startY; y <= endY; y++) {
|
||||
setCell(grid, bounds.leftX, y, border.vertical, "fragment")
|
||||
setCell(grid, bounds.rightX, y, border.vertical, "fragment")
|
||||
}
|
||||
}
|
||||
|
||||
function renderFragment(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "fragment" }>,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
const { bounds, fragment, labelText: label, y } = placement
|
||||
const border = BorderChars[borderStyle]
|
||||
const { leftX, rightX } = bounds
|
||||
|
||||
const leftChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topLeft
|
||||
: fragment.kind === "else"
|
||||
? border.leftT
|
||||
: border.bottomLeft
|
||||
const rightChar =
|
||||
fragment.kind === "alt" || fragment.kind === "loop"
|
||||
? border.topRight
|
||||
: fragment.kind === "else"
|
||||
? border.rightT
|
||||
: border.bottomRight
|
||||
|
||||
for (let x = leftX; x <= rightX; x++) {
|
||||
setCell(grid, x, y, border.horizontal, "fragment")
|
||||
}
|
||||
|
||||
setCell(grid, leftX, y, leftChar, "fragment")
|
||||
setCell(grid, rightX, y, rightChar, "fragment")
|
||||
if (label) {
|
||||
setText(grid, leftX + 2, y, label, "fragmentLabel")
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelfMessage(
|
||||
grid: SequenceGrid,
|
||||
placement: Extract<SequenceStepPlacement, { type: "selfMessage" }>,
|
||||
style: MessageStyle,
|
||||
): void {
|
||||
const { centerX, rightX, topY: topRow, bottomY: bottomRow, labelLines, message } = placement
|
||||
|
||||
setArrowDepartureFade(grid, centerX, topRow, 1, style)
|
||||
for (let x = centerX + FADE_STEPS.length; x < rightX; x++) {
|
||||
setCell(grid, x, topRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
setCell(grid, rightX, topRow, SEQUENCE_BORDER.topRight, style)
|
||||
|
||||
for (let lineIndex = 0; lineIndex < labelLines.length; lineIndex++) {
|
||||
const y = topRow + lineIndex + 1
|
||||
setCell(grid, centerX, y, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setText(grid, centerX + 2, y, labelLines[lineIndex]!, style)
|
||||
setCell(grid, rightX, y, SEQUENCE_BORDER.vertical, style)
|
||||
}
|
||||
|
||||
for (let x = centerX + 1; x < rightX; x++) {
|
||||
setCell(grid, x, bottomRow, SEQUENCE_BORDER.horizontal, style)
|
||||
}
|
||||
const headX = message.head === undefined ? centerX : centerX + 1
|
||||
setCell(grid, headX, bottomRow, arrowHeadChar(message.head, -1), style)
|
||||
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
diagram: SequenceDiagram,
|
||||
options: SequenceDiagramRenderOptions = {},
|
||||
): SequenceGrid {
|
||||
const plan = createSequencePlacementPlan(diagram, options)
|
||||
if (plan.width === 0 || plan.height === 0) return createGrid(0, 0)
|
||||
const fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
const grid = createGrid(plan.width, plan.height)
|
||||
|
||||
if (plan.groups.length > 0) renderParticipantGroups(grid, plan.groups, plan.height - 1)
|
||||
|
||||
for (const placement of plan.participants) {
|
||||
const { participant, centerX: center, headerLeftX, headerRightX, labelX } = placement
|
||||
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
|
||||
|
||||
if (options.compact) {
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
} else {
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "lifeline")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "lifeline")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "lifeline")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "lifeline")
|
||||
setCell(grid, headerLeftX, participantHeaderY, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setCell(grid, headerRightX, participantHeaderY, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "lifeline")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "lifeline")
|
||||
setText(grid, labelX, participantHeaderY, participant.label, "participant")
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "lifeline")
|
||||
}
|
||||
|
||||
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
|
||||
setCell(grid, center, y, SEQUENCE_BORDER.vertical, "lifeline")
|
||||
}
|
||||
}
|
||||
|
||||
for (const placement of plan.steps) {
|
||||
if (placement.type === "note") {
|
||||
setText(grid, placement.textX, placement.textY, placement.text, "noteBadge")
|
||||
continue
|
||||
}
|
||||
|
||||
if (placement.type === "fragment") {
|
||||
if (placement.wallsBefore) {
|
||||
drawFragmentWalls(
|
||||
grid,
|
||||
placement.wallsBefore.bounds,
|
||||
placement.wallsBefore.startY,
|
||||
placement.wallsBefore.endY,
|
||||
fragmentBorderStyle,
|
||||
)
|
||||
}
|
||||
renderFragment(grid, placement, fragmentBorderStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
const message = placement.message
|
||||
const messageStyle: MessageStyle = message.style === "dashed" ? "response" : "request"
|
||||
|
||||
if (placement.type === "selfMessage") {
|
||||
renderSelfMessage(grid, placement, messageStyle)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!placement.inlineLabel) {
|
||||
for (let lineIndex = 0; lineIndex < placement.labelLines.length; lineIndex++) {
|
||||
setText(grid, placement.labelX, placement.labelY + lineIndex, placement.labelLines[lineIndex]!, messageStyle)
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = placement.leftX + 1; x < placement.rightX; x++) {
|
||||
setCell(grid, x, placement.arrowY, SEQUENCE_BORDER.horizontal, messageStyle)
|
||||
}
|
||||
|
||||
setArrowDepartureFade(grid, placement.fromX, placement.arrowY, placement.direction, messageStyle)
|
||||
setCell(grid, placement.headX, placement.arrowY, arrowHeadChar(message.head, placement.direction), messageStyle)
|
||||
if (placement.inlineLabel) setText(grid, placement.labelX, placement.labelY, placement.inlineLabel, messageStyle)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
|
||||
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export type {
|
||||
SequenceParticipant as Participant,
|
||||
SequenceParticipantGroup as ParticipantGroup,
|
||||
SequenceMessage as Message,
|
||||
SequenceArrowHead as ArrowHead,
|
||||
SequenceNote as Note,
|
||||
SequenceActivation as Activation,
|
||||
SequenceFragment as Fragment,
|
||||
SequenceStep as Step,
|
||||
SequenceDiagram as Diagram,
|
||||
SequenceDiagramRenderOptions as PlainRenderOptions,
|
||||
SequenceDiagramAnsiTheme as Theme,
|
||||
SequenceDiagramAnsiOptions as AnsiRenderOptions,
|
||||
SequenceDiagramOptions as RenderableOptions,
|
||||
} from "./types.js"
|
||||
|
||||
export { isMermaidSequenceDiagram as is, parseMermaidSequenceDiagram as parse } from "./parser.js"
|
||||
export { SequenceDiagramRenderable as Renderable } from "./renderable.js"
|
||||
|
||||
export interface RenderOptions extends SequenceDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid sequence diagram string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color
|
||||
? renderSequenceDiagramAnsi(content, rest)
|
||||
: renderSequenceDiagram(content, rest as SequenceDiagramRenderOptions)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
|
||||
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
|
||||
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
|
||||
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
firstMeaningfulMermaidLine,
|
||||
meaningfulNumberedMermaidLines,
|
||||
stripMermaidQuotes as stripQuotes,
|
||||
} from "../core/mermaid.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import type {
|
||||
SequenceArrowHead,
|
||||
SequenceDiagram,
|
||||
SequenceMessage,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const MESSAGE_RE = /^(.+?)\s*(-->>|->>|--x|-x|--\)|-\)|-->|->)([+-]?)\s*(.+?)\s*:\s*(.*)$/
|
||||
const NOTE_RE = /^note\s+(over|left\s+of|right\s+of)\s+(.+?)\s*:\s*(.*)$/i
|
||||
const PARTICIPANT_RE = /^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?$/i
|
||||
const ACTIVATION_RE = /^(activate|deactivate)\s+(.+)$/i
|
||||
const BOX_RE = /^box(?:\s+(.+))?$/i
|
||||
const ALT_RE = /^alt\s+(.+)$/i
|
||||
const ELSE_RE = /^else(?:\s+(.+))?$/i
|
||||
const LOOP_RE = /^loop\s+(.+)$/i
|
||||
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
|
||||
const CSS_COLOR_NAMES = new Set([
|
||||
"black",
|
||||
"white",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yellow",
|
||||
"cyan",
|
||||
"magenta",
|
||||
"silver",
|
||||
"gray",
|
||||
"grey",
|
||||
"maroon",
|
||||
"olive",
|
||||
"lime",
|
||||
"aqua",
|
||||
"teal",
|
||||
"navy",
|
||||
"fuchsia",
|
||||
"purple",
|
||||
"orange",
|
||||
"brightblack",
|
||||
"brightred",
|
||||
"brightgreen",
|
||||
"brightblue",
|
||||
"brightyellow",
|
||||
"brightcyan",
|
||||
"brightmagenta",
|
||||
"brightwhite",
|
||||
])
|
||||
|
||||
function arrowHeadForSyntax(arrow: string): SequenceArrowHead | undefined {
|
||||
if (arrow.endsWith("x")) return "cross"
|
||||
if (arrow.endsWith(")")) return "async"
|
||||
if (arrow.endsWith(">") && !arrow.endsWith(">>")) return "open"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isBoxColorToken(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
return (
|
||||
lowerValue === "transparent" ||
|
||||
CSS_COLOR_NAMES.has(lowerValue) ||
|
||||
/^#[0-9a-f]{3,8}$/i.test(value) ||
|
||||
/^rgba?\(.+\)$/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function splitLeadingBoxToken(value: string): { token: string; rest: string } {
|
||||
if (/^rgba?\(/i.test(value)) {
|
||||
const closeIndex = value.indexOf(")")
|
||||
if (closeIndex >= 0) {
|
||||
return { token: value.slice(0, closeIndex + 1), rest: value.slice(closeIndex + 1).trim() }
|
||||
}
|
||||
}
|
||||
|
||||
const firstSpace = value.search(/\s/)
|
||||
return firstSpace < 0
|
||||
? { token: value, rest: "" }
|
||||
: { token: value.slice(0, firstSpace), rest: value.slice(firstSpace + 1).trim() }
|
||||
}
|
||||
|
||||
function boxLabelText(value: string | undefined): string {
|
||||
const rawLabel = (value ?? "").trim()
|
||||
if ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) {
|
||||
return stripQuotes(rawLabel)
|
||||
}
|
||||
|
||||
const label = stripQuotes(rawLabel)
|
||||
if (!label) return ""
|
||||
const { token, rest } = splitLeadingBoxToken(label)
|
||||
return isBoxColorToken(token) ? stripQuotes(rest) : label
|
||||
}
|
||||
|
||||
function addParticipantToGroup(group: SequenceParticipantGroup | undefined, participantId: string): void {
|
||||
if (!group || group.participantIds.includes(participantId)) return
|
||||
group.participantIds.push(participantId)
|
||||
}
|
||||
|
||||
function ensureParticipant(
|
||||
participants: SequenceParticipant[],
|
||||
id: string,
|
||||
label: string = id,
|
||||
replaceExistingLabel: boolean = false,
|
||||
): void {
|
||||
const existing = participants.find((participant) => participant.id === id)
|
||||
if (existing) {
|
||||
if (replaceExistingLabel) existing.label = label
|
||||
return
|
||||
}
|
||||
participants.push({ id, label })
|
||||
}
|
||||
|
||||
export function isMermaidSequenceDiagram(content: string): boolean {
|
||||
return firstMeaningfulMermaidLine(content)?.toLowerCase() === "sequencediagram"
|
||||
}
|
||||
|
||||
export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
|
||||
const participants: SequenceParticipant[] = []
|
||||
const messages: SequenceMessage[] = []
|
||||
const steps: SequenceStep[] = []
|
||||
const groups: SequenceParticipantGroup[] = []
|
||||
const blockStack: Array<{ kind: "box" | "alt" | "loop"; lineNumber: number; sourceLine: string }> = []
|
||||
const groupStack: SequenceParticipantGroup[] = []
|
||||
let nextMessageNumber: number | undefined
|
||||
let messageNumberIncrement = 1
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (line.toLowerCase() === "sequencediagram") continue
|
||||
|
||||
const autonumberMatch = line.match(AUTONUMBER_RE)
|
||||
if (autonumberMatch) {
|
||||
nextMessageNumber = Number.parseInt(autonumberMatch[1] ?? "1", 10)
|
||||
messageNumberIncrement = Number.parseInt(autonumberMatch[2] ?? "1", 10)
|
||||
continue
|
||||
}
|
||||
|
||||
const boxMatch = line.match(BOX_RE)
|
||||
if (boxMatch) {
|
||||
const group: SequenceParticipantGroup = { label: boxLabelText(boxMatch[1]), participantIds: [] }
|
||||
groups.push(group)
|
||||
groupStack.push(group)
|
||||
blockStack.push({ kind: "box", lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
const participantMatch = line.match(PARTICIPANT_RE)
|
||||
if (participantMatch) {
|
||||
const id = stripQuotes(participantMatch[1]!)
|
||||
ensureParticipant(participants, id, stripQuotes(participantMatch[2] ?? id), true)
|
||||
addParticipantToGroup(groupStack[groupStack.length - 1], id)
|
||||
continue
|
||||
}
|
||||
|
||||
const noteMatch = line.match(NOTE_RE)
|
||||
if (noteMatch) {
|
||||
const position = noteMatch[1]!.toLowerCase().replace(/\s+of$/, "") as "over" | "left" | "right"
|
||||
const over = noteMatch[2]!
|
||||
.split(",")
|
||||
.map((participant) => stripQuotes(participant))
|
||||
.filter((participant) => participant.length > 0)
|
||||
for (const participant of over) ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "note",
|
||||
note: {
|
||||
over,
|
||||
label: stripQuotes(noteMatch[3]!),
|
||||
...(position === "over" ? {} : { position }),
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const activationMatch = line.match(ACTIVATION_RE)
|
||||
if (activationMatch) {
|
||||
const participant = stripQuotes(activationMatch[2]!)
|
||||
ensureParticipant(participants, participant)
|
||||
steps.push({
|
||||
type: "activation",
|
||||
activation: { participant, active: activationMatch[1]!.toLowerCase() === "activate" },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const altMatch = line.match(ALT_RE)
|
||||
if (altMatch) {
|
||||
blockStack.push({ kind: "alt", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "alt", label: stripQuotes(altMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const loopMatch = line.match(LOOP_RE)
|
||||
if (loopMatch) {
|
||||
blockStack.push({ kind: "loop", lineNumber: source.lineNumber, sourceLine: line })
|
||||
steps.push({ type: "fragment", fragment: { kind: "loop", label: stripQuotes(loopMatch[1]!) } })
|
||||
continue
|
||||
}
|
||||
|
||||
const elseMatch = line.match(ELSE_RE)
|
||||
if (elseMatch) {
|
||||
if (blockStack[blockStack.length - 1]?.kind !== "alt") {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
source.lineNumber,
|
||||
line,
|
||||
'Unexpected "else" without an open "alt" block',
|
||||
)
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "else", label: stripQuotes(elseMatch[1] ?? "") } })
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.toLowerCase() === "end") {
|
||||
const block = blockStack.pop()
|
||||
if (!block)
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line, 'Unexpected "end" without an open block')
|
||||
if (block.kind === "box") {
|
||||
groupStack.pop()
|
||||
continue
|
||||
}
|
||||
steps.push({ type: "fragment", fragment: { kind: "end", label: block.kind } })
|
||||
continue
|
||||
}
|
||||
|
||||
const messageMatch = line.match(MESSAGE_RE)
|
||||
if (messageMatch) {
|
||||
const from = stripQuotes(messageMatch[1]!)
|
||||
const arrow = messageMatch[2]!
|
||||
const activationMarker = messageMatch[3]!
|
||||
const to = stripQuotes(messageMatch[4]!)
|
||||
const message: SequenceMessage = {
|
||||
from,
|
||||
to,
|
||||
label: stripQuotes(messageMatch[5]!),
|
||||
style: arrow.startsWith("--") ? "dashed" : "solid",
|
||||
}
|
||||
const activeGroup = groupStack[groupStack.length - 1]
|
||||
ensureParticipant(participants, from)
|
||||
ensureParticipant(participants, to)
|
||||
addParticipantToGroup(activeGroup, from)
|
||||
addParticipantToGroup(activeGroup, to)
|
||||
const head = arrowHeadForSyntax(arrow)
|
||||
if (head) message.head = head
|
||||
if (nextMessageNumber !== undefined) {
|
||||
message.number = nextMessageNumber
|
||||
nextMessageNumber += messageNumberIncrement
|
||||
}
|
||||
if (activationMarker === "+") message.activate = to
|
||||
else if (activationMarker === "-") message.deactivate = from
|
||||
messages.push(message)
|
||||
steps.push({ type: "message", message })
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
|
||||
}
|
||||
|
||||
const unclosedBlock = blockStack[blockStack.length - 1]
|
||||
if (unclosedBlock) {
|
||||
throw new MermaidSyntaxError(
|
||||
"sequence",
|
||||
unclosedBlock.lineNumber,
|
||||
unclosedBlock.sourceLine,
|
||||
`Unclosed ${unclosedBlock.kind} block; expected "end"`,
|
||||
)
|
||||
}
|
||||
|
||||
return { participants, messages, steps, groups }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { createSequencePlacementPlan } from "./placement.js"
|
||||
|
||||
describe("createSequencePlacementPlan", () => {
|
||||
test("expands one fragment frame for a longer else label", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
A->>B: start
|
||||
alt ok
|
||||
A->>B: yes
|
||||
else validation failed with a substantially longer explanation
|
||||
B-->>A: no
|
||||
end`),
|
||||
)
|
||||
const fragments = plan.steps.filter((step) => step.type === "fragment")
|
||||
|
||||
expect(fragments).toHaveLength(3)
|
||||
expect(fragments.map((fragment) => fragment.bounds.rightX)).toEqual([
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
fragments[0]!.bounds.rightX,
|
||||
])
|
||||
expect(fragments[1]!.labelText).toContain("validation failed")
|
||||
})
|
||||
|
||||
test("includes non-adjacent message and note labels within planned width", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message needs room past the final participant
|
||||
Note over A,C: this note also needs full horizontal room`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
const note = plan.steps.find((step) => step.type === "note")!
|
||||
|
||||
const messageWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
expect(message.labelX + messageWidth).toBeLessThanOrEqual(plan.width)
|
||||
expect(note.textX + diagramTextWidth(note.text)).toBeLessThanOrEqual(plan.width)
|
||||
})
|
||||
|
||||
test("keeps side notes clear of adjacent participant lifelines", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
Note right of A: a long note between the first two participants
|
||||
Note left of C: another long note between the final participants`),
|
||||
)
|
||||
const notes = plan.steps.filter((step) => step.type === "note")
|
||||
|
||||
expect(notes[0]!.textX + diagramTextWidth(notes[0]!.text)).toBeLessThan(plan.participants[1]!.centerX)
|
||||
expect(notes[1]!.textX).toBeGreaterThan(plan.participants[1]!.centerX)
|
||||
})
|
||||
|
||||
test("allocates group space around a contained self-message loop", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Backend
|
||||
participant Service
|
||||
Service->>Service: Check Permissions
|
||||
end`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const message = plan.steps.find((step) => step.type === "selfMessage")!
|
||||
|
||||
expect(group.leftX).toBeGreaterThanOrEqual(0)
|
||||
expect(group.rightX).toBeGreaterThanOrEqual(message.rightX + 2)
|
||||
expect(plan.width).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("keeps external participants outside a group expanded by internal content", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box G
|
||||
participant A
|
||||
end
|
||||
participant B as External
|
||||
A->>A: this self-loop extends underneath the external participant header`),
|
||||
)
|
||||
const group = plan.groups[0]!
|
||||
const external = plan.participants.find((participant) => participant.participant.id === "B")!
|
||||
|
||||
expect(external.headerLeftX).toBeGreaterThan(group.rightX)
|
||||
})
|
||||
|
||||
test("expands group and fragment frames around contained long content", () => {
|
||||
const groupPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
box Services
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
A->>C: this message text runs far outside of the group container boundary
|
||||
end`),
|
||||
)
|
||||
const group = groupPlan.groups[0]!
|
||||
const groupedMessage = groupPlan.steps.find((step) => step.type === "message")!
|
||||
const groupedMessageRight = groupedMessage.labelX + Math.max(...groupedMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(group.rightX).toBeGreaterThan(groupedMessageRight)
|
||||
|
||||
const fragmentPlan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
participant C
|
||||
alt lookup
|
||||
A->>C: this non adjacent message is deliberately much wider than the frame
|
||||
end`),
|
||||
)
|
||||
const fragment = fragmentPlan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.find((step) => step.fragment.kind === "alt")!
|
||||
const fragmentMessage = fragmentPlan.steps.find((step) => step.type === "message")!
|
||||
const fragmentMessageRight =
|
||||
fragmentMessage.labelX + Math.max(...fragmentMessage.labelLines.map(diagramTextWidth)) - 1
|
||||
|
||||
expect(fragment.bounds.rightX).toBeGreaterThan(fragmentMessageRight)
|
||||
})
|
||||
|
||||
test("preserves nesting inset when a child fragment has a wide heading", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
participant B
|
||||
alt outer
|
||||
loop inner heading wider than outer frame and participant span
|
||||
A->>B: x
|
||||
end
|
||||
end`),
|
||||
)
|
||||
const starts = plan.steps
|
||||
.filter((step) => step.type === "fragment")
|
||||
.filter((step) => step.fragment.kind === "alt" || step.fragment.kind === "loop")
|
||||
|
||||
expect(starts[0]!.bounds.rightX).toBeGreaterThan(starts[1]!.bounds.rightX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,625 @@
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import type {
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
SequenceFragment,
|
||||
SequenceMessage,
|
||||
SequenceNote,
|
||||
SequenceParticipant,
|
||||
SequenceParticipantGroup,
|
||||
SequenceStep,
|
||||
} from "./types.js"
|
||||
|
||||
const NOTE_HORIZONTAL_PADDING = 1
|
||||
const GROUP_HORIZONTAL_PADDING = 2
|
||||
const FRAGMENT_HORIZONTAL_OVERHANG = 3
|
||||
|
||||
export interface SequenceHorizontalBounds {
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceParticipantPlacement {
|
||||
participant: SequenceParticipant
|
||||
centerX: number
|
||||
headerLeftX: number
|
||||
headerRightX: number
|
||||
labelX: number
|
||||
}
|
||||
|
||||
export interface SequenceGroupPlacement {
|
||||
group: SequenceParticipantGroup
|
||||
labelText: string
|
||||
leftX: number
|
||||
rightX: number
|
||||
}
|
||||
|
||||
export interface SequenceWallPlacement {
|
||||
bounds: SequenceHorizontalBounds
|
||||
startY: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export type SequenceStepPlacement =
|
||||
| { type: "note"; note: SequenceNote; text: string; textX: number; textY: number }
|
||||
| {
|
||||
type: "fragment"
|
||||
fragment: SequenceFragment
|
||||
labelText: string
|
||||
bounds: SequenceHorizontalBounds
|
||||
y: number
|
||||
wallsBefore?: SequenceWallPlacement
|
||||
}
|
||||
| {
|
||||
type: "message"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
labelX: number
|
||||
labelY: number
|
||||
arrowY: number
|
||||
fromX: number
|
||||
toX: number
|
||||
leftX: number
|
||||
rightX: number
|
||||
headX: number
|
||||
direction: 1 | -1
|
||||
inlineLabel?: string
|
||||
}
|
||||
| {
|
||||
type: "selfMessage"
|
||||
message: SequenceMessage
|
||||
labelLines: string[]
|
||||
centerX: number
|
||||
rightX: number
|
||||
topY: number
|
||||
bottomY: number
|
||||
}
|
||||
|
||||
export interface SequencePlacementPlan {
|
||||
width: number
|
||||
height: number
|
||||
rows: {
|
||||
participantHeaderTopY: number
|
||||
participantHeaderY: number
|
||||
participantRuleY: number
|
||||
lifelineStartY: number
|
||||
lifelineEndY: number
|
||||
}
|
||||
participants: SequenceParticipantPlacement[]
|
||||
groups: SequenceGroupPlacement[]
|
||||
steps: SequenceStepPlacement[]
|
||||
}
|
||||
|
||||
interface SequenceGroupRange {
|
||||
group: SequenceParticipantGroup
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
interface PendingFragmentFrame {
|
||||
startIndex: number
|
||||
bounds: SequenceHorizontalBounds
|
||||
}
|
||||
|
||||
interface ActiveFragmentFrame {
|
||||
bounds: SequenceHorizontalBounds
|
||||
boundaryY: number
|
||||
}
|
||||
|
||||
function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
function centeredStart(center: number, text: string): number {
|
||||
return center - Math.floor(visualLength(text) / 2)
|
||||
}
|
||||
|
||||
function noteLabelText(label: string): string {
|
||||
const padding = " ".repeat(NOTE_HORIZONTAL_PADDING)
|
||||
return `${padding}${label}${padding}`
|
||||
}
|
||||
|
||||
function messageLabelText(message: SequenceMessage): string {
|
||||
return message.number === undefined ? message.label : `${message.number}. ${message.label}`
|
||||
}
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
if (fragment.kind === "end") return ""
|
||||
const prefix = fragment.kind === "loop" ? "↻ loop" : fragment.kind
|
||||
return ` ${prefix}: ${fragment.label} `
|
||||
}
|
||||
|
||||
function messageLabelLines(label: string): string[] {
|
||||
const lines = label.split(/(?:<br\s*\/?\s*>|\\n)/i).map((line) => line.trimEnd())
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function labelLinesWidth(lines: string[]): number {
|
||||
return lines.reduce((max, line) => Math.max(max, visualLength(line)), 0)
|
||||
}
|
||||
|
||||
function messageWidth(message: SequenceMessage): number {
|
||||
return labelLinesWidth(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function selfMessageLoopWidthForLines(labelLines: string[]): number {
|
||||
return Math.max(10, labelLinesWidth(labelLines) + 4)
|
||||
}
|
||||
|
||||
function selfMessageLoopWidth(message: SequenceMessage): number {
|
||||
return selfMessageLoopWidthForLines(messageLabelLines(messageLabelText(message)))
|
||||
}
|
||||
|
||||
function arrowHeadX(toX: number, direction: 1 | -1, head: SequenceMessage["head"]): number {
|
||||
return head === undefined ? toX : toX - direction
|
||||
}
|
||||
|
||||
function inlineMessageLabel(
|
||||
message: SequenceMessage,
|
||||
labelLines: string[],
|
||||
fromX: number,
|
||||
toX: number,
|
||||
compact: boolean,
|
||||
): string | undefined {
|
||||
if (!compact || message.from === message.to || labelLines.length !== 1) return undefined
|
||||
const label = ` ${labelLines[0]} `
|
||||
return visualLength(label) <= Math.abs(toX - fromX) - 3 ? label : undefined
|
||||
}
|
||||
|
||||
function getStepHeight(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): number {
|
||||
if (step.type === "note") return 3
|
||||
if (step.type === "activation") return 0
|
||||
if (step.type === "fragment") return 2
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
const fromX = centers[participantIndexes.get(step.message.from) ?? -1]
|
||||
const toX = centers[participantIndexes.get(step.message.to) ?? -1]
|
||||
if (fromX !== undefined && toX !== undefined && inlineMessageLabel(step.message, labelLines, fromX, toX, compact)) {
|
||||
return 2
|
||||
}
|
||||
return labelLines.length + (step.message.from === step.message.to ? 3 : 2)
|
||||
}
|
||||
|
||||
function createParticipantIndexMap(diagram: SequenceDiagram): Map<string, number> {
|
||||
return new Map(diagram.participants.map((participant, index) => [participant.id, index]))
|
||||
}
|
||||
|
||||
function getParticipantIndexes(participantIndexes: Map<string, number>, participantIds: string[]): number[] {
|
||||
return participantIds.map((id) => participantIndexes.get(id) ?? -1).filter((index) => index >= 0)
|
||||
}
|
||||
|
||||
function groupLabelText(group: SequenceParticipantGroup): string {
|
||||
return group.label ? ` ${group.label} ` : ""
|
||||
}
|
||||
|
||||
function getGroupRanges(diagram: SequenceDiagram, participantIndexes: Map<string, number>): SequenceGroupRange[] {
|
||||
return diagram.groups.flatMap((group) => {
|
||||
const indexes = getParticipantIndexes(participantIndexes, group.participantIds)
|
||||
return indexes.length === 0 ? [] : [{ group, startIndex: Math.min(...indexes), endIndex: Math.max(...indexes) }]
|
||||
})
|
||||
}
|
||||
|
||||
function getStepParticipantIndexes(step: SequenceStep, participantIndexes: Map<string, number>): number[] {
|
||||
if (step.type === "message") {
|
||||
return getParticipantIndexes(participantIndexes, [step.message.from, step.message.to])
|
||||
}
|
||||
if (step.type === "note") return getParticipantIndexes(participantIndexes, step.note.over)
|
||||
return []
|
||||
}
|
||||
|
||||
function getStepContentBounds(
|
||||
step: SequenceStep,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
if (step.type === "message") {
|
||||
const fromIndex = participantIndexes.get(step.message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) return undefined
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
if (fromIndex === toIndex) return { leftX: fromX, rightX: fromX + selfMessageLoopWidth(step.message) }
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
return { leftX, rightX: Math.max(rightX, leftX + 2 + messageWidth(step.message) - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 0) return undefined
|
||||
const centerX = Math.floor((centers[Math.min(...indexes)]! + centers[Math.max(...indexes)]!) / 2)
|
||||
const text = noteLabelText(step.note.label)
|
||||
const leftX =
|
||||
step.note.position === "left"
|
||||
? centerX - visualLength(text) - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centeredStart(centerX, text)
|
||||
return { leftX, rightX: leftX + visualLength(text) - 1 }
|
||||
}
|
||||
|
||||
function rangeContainsIndexes(range: SequenceGroupRange, indexes: readonly number[]): boolean {
|
||||
return indexes.length > 0 && indexes.every((index) => index >= range.startIndex && index <= range.endIndex)
|
||||
}
|
||||
|
||||
function resolveGroupBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
groupRanges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): SequenceGroupPlacement[] {
|
||||
return groupRanges.map((range) => {
|
||||
let contentLeftX = centers[range.startIndex]!
|
||||
let contentRightX = centers[range.endIndex]!
|
||||
for (let i = range.startIndex; i <= range.endIndex; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const headerStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
contentLeftX = Math.min(contentLeftX, headerStartX)
|
||||
contentRightX = Math.max(contentRightX, headerStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const indexes = getStepParticipantIndexes(step, participantIndexes)
|
||||
if (!rangeContainsIndexes(range, indexes)) continue
|
||||
const bounds = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (bounds) {
|
||||
contentLeftX = Math.min(contentLeftX, bounds.leftX)
|
||||
contentRightX = Math.max(contentRightX, bounds.rightX)
|
||||
}
|
||||
}
|
||||
const labelText = groupLabelText(range.group)
|
||||
let leftX = contentLeftX - GROUP_HORIZONTAL_PADDING
|
||||
let rightX = contentRightX + GROUP_HORIZONTAL_PADDING
|
||||
const extraWidth = Math.max(0, visualLength(labelText) + 4 - (rightX - leftX + 1))
|
||||
leftX -= Math.floor(extraWidth / 2)
|
||||
rightX += Math.ceil(extraWidth / 2)
|
||||
return { group: range.group, labelText, leftX, rightX }
|
||||
})
|
||||
}
|
||||
|
||||
function expandHorizontalBounds(bounds: SequenceHorizontalBounds, leftX: number, rightX: number): void {
|
||||
bounds.leftX = Math.min(bounds.leftX, leftX)
|
||||
bounds.rightX = Math.max(bounds.rightX, rightX)
|
||||
}
|
||||
|
||||
function getDiagramContentBounds(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
compact: boolean,
|
||||
): SequenceHorizontalBounds {
|
||||
const bounds = { leftX: 0, rightX: 0 }
|
||||
for (let i = 0; i < diagram.participants.length; i++) {
|
||||
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
|
||||
const labelStartX = centers[i]! - Math.floor(headerWidth / 2)
|
||||
expandHorizontalBounds(bounds, labelStartX, labelStartX + headerWidth - 1)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) expandHorizontalBounds(bounds, content.leftX, content.rightX)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
function getFragmentFrameBounds(
|
||||
centers: number[],
|
||||
fragment: SequenceFragment,
|
||||
nestingDepth = 0,
|
||||
): SequenceHorizontalBounds | undefined {
|
||||
const leftParticipantX = centers[0]
|
||||
const rightParticipantX = centers[centers.length - 1]
|
||||
if (leftParticipantX === undefined || rightParticipantX === undefined) return undefined
|
||||
const leftX = leftParticipantX - FRAGMENT_HORIZONTAL_OVERHANG + nestingDepth
|
||||
const participantRightX = rightParticipantX + FRAGMENT_HORIZONTAL_OVERHANG - nestingDepth
|
||||
return { leftX, rightX: Math.max(participantRightX, leftX + 2 + visualLength(fragmentLabelText(fragment)) + 1) }
|
||||
}
|
||||
|
||||
function getFragmentFrameBoundsByStep(
|
||||
centers: number[],
|
||||
steps: SequenceStep[],
|
||||
participantIndexes: Map<string, number>,
|
||||
): Map<number, SequenceHorizontalBounds> {
|
||||
const boundsByStep = new Map<number, SequenceHorizontalBounds>()
|
||||
const activeFrames: PendingFragmentFrame[] = []
|
||||
for (const [index, step] of steps.entries()) {
|
||||
if (step.type !== "fragment") {
|
||||
const content = getStepContentBounds(step, centers, participantIndexes)
|
||||
if (content) {
|
||||
for (const frame of activeFrames) {
|
||||
expandHorizontalBounds(frame.bounds, content.leftX - 1, content.rightX + 1)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const bounds = getFragmentFrameBounds(centers, step.fragment, activeFrames.length)
|
||||
if (!bounds) continue
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
activeFrames.push({ startIndex: index, bounds: { ...bounds } })
|
||||
continue
|
||||
}
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (!frame) continue
|
||||
expandHorizontalBounds(frame.bounds, bounds.leftX, bounds.rightX)
|
||||
if (step.fragment.kind !== "end") continue
|
||||
activeFrames.pop()
|
||||
boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
const parent = activeFrames[activeFrames.length - 1]
|
||||
if (parent) expandHorizontalBounds(parent.bounds, frame.bounds.leftX - 1, frame.bounds.rightX + 1)
|
||||
}
|
||||
for (const frame of activeFrames) boundsByStep.set(frame.startIndex, frame.bounds)
|
||||
return boundsByStep
|
||||
}
|
||||
|
||||
function resolveParticipantCenters(
|
||||
diagram: SequenceDiagram,
|
||||
participantIndexes: Map<string, number>,
|
||||
minParticipantGap: number,
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const gaps = Array.from({ length: Math.max(0, diagram.participants.length - 1) }, (_, index) => {
|
||||
const left = diagram.participants[index]!
|
||||
const right = diagram.participants[index + 1]!
|
||||
return Math.max(
|
||||
minParticipantGap,
|
||||
Math.ceil(participantHeaderWidth(left.label, compact) / 2) +
|
||||
Math.ceil(participantHeaderWidth(right.label, compact) / 2) +
|
||||
6,
|
||||
)
|
||||
})
|
||||
for (const message of diagram.messages) {
|
||||
const fromIndex = participantIndexes.get(message.from) ?? -1
|
||||
const toIndex = participantIndexes.get(message.to) ?? -1
|
||||
if (fromIndex === toIndex && fromIndex >= 0 && fromIndex < diagram.participants.length - 1) {
|
||||
gaps[fromIndex] = Math.max(
|
||||
gaps[fromIndex]!,
|
||||
selfMessageLoopWidth(message) + Math.ceil(visualLength(diagram.participants[fromIndex + 1]!.label) / 2) + 2,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (fromIndex < 0 || toIndex < 0 || Math.abs(fromIndex - toIndex) !== 1) continue
|
||||
const gapIndex = Math.min(fromIndex, toIndex)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, messageWidth(message) + 6)
|
||||
}
|
||||
for (const step of diagram.steps) {
|
||||
if (step.type !== "note") continue
|
||||
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
|
||||
if (indexes.length === 1 && step.note.position && step.note.position !== "over") {
|
||||
const participantIndex = indexes[0]!
|
||||
const gapIndex = step.note.position === "left" ? participantIndex - 1 : participantIndex
|
||||
if (gapIndex >= 0 && gapIndex < gaps.length) {
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, visualLength(noteLabelText(step.note.label)) + 4)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (indexes.length !== 2 || Math.abs(indexes[0]! - indexes[1]!) !== 1) continue
|
||||
const gapIndex = Math.min(indexes[0]!, indexes[1]!)
|
||||
gaps[gapIndex] = Math.max(gaps[gapIndex]!, visualLength(noteLabelText(step.note.label)) + 4)
|
||||
}
|
||||
const centers = [Math.max(1, Math.floor(participantHeaderWidth(diagram.participants[0]?.label ?? "", compact) / 2))]
|
||||
for (let i = 1; i < diagram.participants.length; i++) centers[i] = centers[i - 1]! + gaps[i - 1]!
|
||||
return centers
|
||||
}
|
||||
|
||||
function separateExpandedGroupsFromExternalParticipants(
|
||||
diagram: SequenceDiagram,
|
||||
centers: number[],
|
||||
participantIndexes: Map<string, number>,
|
||||
ranges: SequenceGroupRange[],
|
||||
compact: boolean,
|
||||
): number[] {
|
||||
const adjusted = [...centers]
|
||||
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
|
||||
let changed = false
|
||||
const groups = resolveGroupBounds(diagram, adjusted, participantIndexes, ranges, compact)
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
const group = groups[index]!
|
||||
if (range.startIndex > 0) {
|
||||
const previousIndex = range.startIndex - 1
|
||||
const previousWidth = participantHeaderWidth(diagram.participants[previousIndex]!.label, compact)
|
||||
const previousRight = adjusted[previousIndex]! - Math.floor(previousWidth / 2) + previousWidth - 1
|
||||
const shift = previousRight + GROUP_HORIZONTAL_PADDING + 1 - group.leftX
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = range.startIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (range.endIndex < diagram.participants.length - 1) {
|
||||
const nextIndex = range.endIndex + 1
|
||||
const nextWidth = participantHeaderWidth(diagram.participants[nextIndex]!.label, compact)
|
||||
const nextLeft = adjusted[nextIndex]! - Math.floor(nextWidth / 2)
|
||||
const shift = group.rightX + GROUP_HORIZONTAL_PADDING + 1 - nextLeft
|
||||
if (shift > 0) {
|
||||
for (let participantIndex = nextIndex; participantIndex < adjusted.length; participantIndex++) {
|
||||
adjusted[participantIndex]! += shift
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) return adjusted
|
||||
}
|
||||
return adjusted
|
||||
}
|
||||
|
||||
export function createSequencePlacementPlan(
|
||||
diagram: SequenceDiagram,
|
||||
options: Pick<SequenceDiagramRenderOptions, "compact" | "minParticipantGap"> = {},
|
||||
): SequencePlacementPlan {
|
||||
if (diagram.participants.length === 0) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
rows: {
|
||||
participantHeaderTopY: 0,
|
||||
participantHeaderY: 0,
|
||||
participantRuleY: 0,
|
||||
lifelineStartY: 0,
|
||||
lifelineEndY: 0,
|
||||
},
|
||||
participants: [],
|
||||
groups: [],
|
||||
steps: [],
|
||||
}
|
||||
}
|
||||
const indexes = createParticipantIndexMap(diagram)
|
||||
const compact = options.compact ?? false
|
||||
let centers = resolveParticipantCenters(
|
||||
diagram,
|
||||
indexes,
|
||||
normalizeSequenceMinParticipantGap(options.minParticipantGap),
|
||||
compact,
|
||||
)
|
||||
const ranges = getGroupRanges(diagram, indexes)
|
||||
centers = separateExpandedGroupsFromExternalParticipants(diagram, centers, indexes, ranges, compact)
|
||||
let groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
let contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
let frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
const fragmentBounds = (): SequenceHorizontalBounds => {
|
||||
const result = { leftX: 0, rightX: 0 }
|
||||
for (const bounds of frameBounds.values()) expandHorizontalBounds(result, bounds.leftX, bounds.rightX)
|
||||
return result
|
||||
}
|
||||
let fragments = fragmentBounds()
|
||||
const leftOverflow = Math.min(
|
||||
groups.reduce((left, group) => Math.min(left, group.leftX), 0),
|
||||
contentBounds.leftX,
|
||||
fragments.leftX,
|
||||
0,
|
||||
)
|
||||
if (leftOverflow < 0) {
|
||||
centers = centers.map((center) => center - leftOverflow)
|
||||
groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
|
||||
contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
|
||||
frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
|
||||
fragments = fragmentBounds()
|
||||
}
|
||||
const hasGroups = groups.length > 0
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
const baseHeight =
|
||||
stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
|
||||
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
|
||||
const lifelineEndY = hasGroups ? height - 2 : height - 1
|
||||
const participants = diagram.participants.map((participant, index) => {
|
||||
const centerX = centers[index]!
|
||||
const width = participantHeaderWidth(participant.label, compact)
|
||||
const headerLeftX = centerX - Math.floor(width / 2)
|
||||
return {
|
||||
participant,
|
||||
centerX,
|
||||
headerLeftX,
|
||||
headerRightX: headerLeftX + width - 1,
|
||||
labelX: centeredStart(centerX, participant.label),
|
||||
}
|
||||
})
|
||||
const steps: SequenceStepPlacement[] = []
|
||||
let stepY = stepStartY
|
||||
const activeFrames: ActiveFragmentFrame[] = []
|
||||
for (const [stepIndex, step] of diagram.steps.entries()) {
|
||||
if (step.type === "activation") continue
|
||||
const stepHeight = getStepHeight(step, centers, indexes, compact)
|
||||
if (step.type === "note") {
|
||||
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
|
||||
if (noteIndexes.length > 0) {
|
||||
const centerX = Math.floor((centers[Math.min(...noteIndexes)]! + centers[Math.max(...noteIndexes)]!) / 2)
|
||||
const text = noteLabelText(step.note.label)
|
||||
const textX =
|
||||
step.note.position === "left"
|
||||
? centerX - visualLength(text) - 2
|
||||
: step.note.position === "right"
|
||||
? centerX + 2
|
||||
: centeredStart(centerX, text)
|
||||
steps.push({ type: "note", note: step.note, text, textX, textY: stepY + 1 })
|
||||
}
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
if (step.type === "fragment") {
|
||||
let bounds = frameBounds.get(stepIndex) ?? getFragmentFrameBounds(centers, step.fragment)
|
||||
let wallsBefore: SequenceWallPlacement | undefined
|
||||
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
|
||||
if (bounds) activeFrames.push({ bounds, boundaryY: stepY })
|
||||
} else {
|
||||
const frame = activeFrames[activeFrames.length - 1]
|
||||
if (frame) {
|
||||
bounds = frame.bounds
|
||||
wallsBefore = { bounds, startY: frame.boundaryY + 1, endY: stepY - 1 }
|
||||
if (step.fragment.kind === "end") activeFrames.pop()
|
||||
else frame.boundaryY = stepY
|
||||
}
|
||||
}
|
||||
if (bounds)
|
||||
steps.push({
|
||||
type: "fragment",
|
||||
fragment: step.fragment,
|
||||
labelText: fragmentLabelText(step.fragment),
|
||||
bounds,
|
||||
y: stepY,
|
||||
wallsBefore,
|
||||
})
|
||||
stepY += stepHeight
|
||||
continue
|
||||
}
|
||||
const fromIndex = indexes.get(step.message.from) ?? -1
|
||||
const toIndex = indexes.get(step.message.to) ?? -1
|
||||
if (fromIndex < 0 || toIndex < 0) continue
|
||||
const labelLines = messageLabelLines(messageLabelText(step.message))
|
||||
if (fromIndex === toIndex) {
|
||||
const centerX = centers[fromIndex]!
|
||||
steps.push({
|
||||
type: "selfMessage",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
centerX,
|
||||
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
|
||||
topY: stepY,
|
||||
bottomY: stepY + labelLines.length + 1,
|
||||
})
|
||||
} else {
|
||||
const fromX = centers[fromIndex]!
|
||||
const toX = centers[toIndex]!
|
||||
const direction: 1 | -1 = toX > fromX ? 1 : -1
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
labelLines,
|
||||
labelX: leftX + 2,
|
||||
labelY: stepY,
|
||||
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
|
||||
fromX,
|
||||
toX,
|
||||
leftX,
|
||||
rightX,
|
||||
direction,
|
||||
headX: arrowHeadX(toX, direction, step.message.head),
|
||||
inlineLabel,
|
||||
})
|
||||
}
|
||||
stepY += stepHeight
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rows: { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY },
|
||||
participants,
|
||||
groups,
|
||||
steps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import {
|
||||
resolveSequenceAnsiTheme,
|
||||
sequenceStyleBackgroundColor,
|
||||
sequenceStyleColor,
|
||||
type SequenceStyleColors,
|
||||
} from "./style.js"
|
||||
import type { SequenceCellStyle, SequenceDiagramAnsiTheme } from "./types.js"
|
||||
|
||||
export type SequenceGrid = DiagramCanvas<SequenceCellStyle>
|
||||
|
||||
export function renderSequenceGridText(grid: SequenceGrid): string {
|
||||
return grid.toString()
|
||||
}
|
||||
|
||||
export function renderSequenceGridStyledText(grid: SequenceGrid, colors: SequenceStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => sequenceStyleColor(run.style, colors),
|
||||
(run) => sequenceStyleBackgroundColor(run.style, colors),
|
||||
)
|
||||
}
|
||||
|
||||
export function renderSequenceGridAnsi(grid: SequenceGrid, theme: SequenceDiagramAnsiTheme = {}): string {
|
||||
const resolvedTheme = resolveSequenceAnsiTheme(theme)
|
||||
return renderDiagramGridAnsi(grid, (run) => {
|
||||
if (run.style === "noteBadge") return resolvedTheme.note
|
||||
return run.style ? resolvedTheme[run.style] : undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { type BorderStyle, type ColorInput, type RenderContext, type RGBA } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { brightenColor } from "../core/color/style.js"
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE, normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { renderSequenceGridStyledText, type SequenceGrid } from "./render-grid.js"
|
||||
import { resolveSequenceStyleColors } from "./style.js"
|
||||
import type { SequenceDiagram, SequenceDiagramOptions } from "./types.js"
|
||||
|
||||
export class SequenceDiagramRenderable extends DiagramRenderable<SequenceDiagram, SequenceGrid> {
|
||||
private _compact: boolean
|
||||
private _minParticipantGap: number
|
||||
private _fragmentBorderStyle: BorderStyle
|
||||
private _participantColor?: RGBA
|
||||
private _lifelineColor?: RGBA
|
||||
private _groupColor?: RGBA
|
||||
private _requestColor?: RGBA
|
||||
private _responseColor?: RGBA
|
||||
private _noteColor?: RGBA
|
||||
private _noteBackgroundColor?: RGBA
|
||||
constructor(ctx: RenderContext, options: SequenceDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._compact = options.compact ?? false
|
||||
this._minParticipantGap = normalizeSequenceMinParticipantGap(options.minParticipantGap)
|
||||
this._fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
this._participantColor = parseDiagramRenderableColor(options.participantColor)
|
||||
this._lifelineColor = parseDiagramRenderableColor(options.lifelineColor)
|
||||
this._groupColor = parseDiagramRenderableColor(options.groupColor)
|
||||
this._requestColor = parseDiagramRenderableColor(options.requestColor)
|
||||
this._responseColor = parseDiagramRenderableColor(options.responseColor)
|
||||
this._noteColor = parseDiagramRenderableColor(options.noteColor)
|
||||
this._noteBackgroundColor = parseDiagramRenderableColor(options.noteBackgroundColor)
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidSequenceDiagram(this.content),
|
||||
draw: (diagram) => this.drawGrid(diagram),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
})
|
||||
}
|
||||
|
||||
get compact(): boolean {
|
||||
return this._compact
|
||||
}
|
||||
|
||||
set compact(value: boolean) {
|
||||
if (this._compact === value) return
|
||||
this._compact = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get minParticipantGap(): number {
|
||||
return this._minParticipantGap
|
||||
}
|
||||
|
||||
set minParticipantGap(value: number) {
|
||||
const next = normalizeSequenceMinParticipantGap(value)
|
||||
if (this._minParticipantGap === next) return
|
||||
this._minParticipantGap = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get fragmentBorderStyle(): BorderStyle {
|
||||
return this._fragmentBorderStyle
|
||||
}
|
||||
|
||||
set fragmentBorderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_FRAGMENT_BORDER_STYLE
|
||||
if (this._fragmentBorderStyle === next) return
|
||||
this._fragmentBorderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get participantColor(): RGBA | undefined {
|
||||
return this._participantColor
|
||||
}
|
||||
|
||||
set participantColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._participantColor, value, (color) => {
|
||||
this._participantColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get lifelineColor(): RGBA | undefined {
|
||||
return this._lifelineColor
|
||||
}
|
||||
|
||||
set lifelineColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._lifelineColor, value, (color) => {
|
||||
this._lifelineColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get groupColor(): RGBA | undefined {
|
||||
return this._groupColor
|
||||
}
|
||||
|
||||
set groupColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._groupColor, value, (color) => {
|
||||
this._groupColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get requestColor(): RGBA | undefined {
|
||||
return this._requestColor
|
||||
}
|
||||
|
||||
set requestColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._requestColor, value, (color) => {
|
||||
this._requestColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get responseColor(): RGBA | undefined {
|
||||
return this._responseColor
|
||||
}
|
||||
|
||||
set responseColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._responseColor, value, (color) => {
|
||||
this._responseColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get noteColor(): RGBA | undefined {
|
||||
return this._noteColor
|
||||
}
|
||||
|
||||
set noteColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteColor, value, (color) => {
|
||||
this._noteColor = color
|
||||
})
|
||||
}
|
||||
|
||||
get noteBackgroundColor(): RGBA | undefined {
|
||||
return this._noteBackgroundColor
|
||||
}
|
||||
|
||||
set noteBackgroundColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteBackgroundColor, value, (color) => {
|
||||
this._noteBackgroundColor = color
|
||||
})
|
||||
}
|
||||
|
||||
private drawGrid(diagram: SequenceDiagram): SequenceGrid {
|
||||
return drawSequenceDiagramGrid(diagram, {
|
||||
compact: this._compact,
|
||||
minParticipantGap: this._minParticipantGap,
|
||||
fragmentBorderStyle: this._fragmentBorderStyle,
|
||||
})
|
||||
}
|
||||
|
||||
private styledText(grid: SequenceGrid) {
|
||||
return renderSequenceGridStyledText(
|
||||
grid,
|
||||
resolveSequenceStyleColors({
|
||||
participant: this._participantColor,
|
||||
lifeline: this._lifelineColor,
|
||||
group: this._groupColor ?? brightenColor(this._lifelineColor, 0.08),
|
||||
request: this._requestColor,
|
||||
response: this._responseColor,
|
||||
fragment: brightenColor(this._lifelineColor, 0.18),
|
||||
fragmentLabelBg: this._noteBackgroundColor,
|
||||
note: this._noteColor,
|
||||
noteBg: this._noteBackgroundColor,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import {
|
||||
ansiBg,
|
||||
ansiFg,
|
||||
createAnsiRampTheme,
|
||||
createColorRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
import type {
|
||||
AnsiSequenceCellStyle,
|
||||
FadeStyle,
|
||||
MessageStyle,
|
||||
SequenceCellStyle,
|
||||
SequenceDiagramAnsiTheme,
|
||||
} from "./types.js"
|
||||
|
||||
export type SequenceStyleColors = Partial<Record<AnsiSequenceCellStyle, RGBA>> & {
|
||||
noteBg?: RGBA
|
||||
fragmentLabelBg?: RGBA
|
||||
}
|
||||
|
||||
export const SEQUENCE_FADE_STEPS = DIAGRAM_FADE_STEPS
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
participant: [228, 239, 232],
|
||||
lifeline: [111, 138, 126],
|
||||
group: [76, 99, 89],
|
||||
request: [134, 225, 200],
|
||||
response: [230, 177, 126],
|
||||
fragment: [154, 184, 169],
|
||||
fragmentLabelBg: [28, 43, 36],
|
||||
noteFg: [215, 229, 221],
|
||||
noteBg: [36, 56, 47],
|
||||
} as const satisfies Record<string, DiagramRgb>
|
||||
|
||||
function createAnsiFadeTheme(style: MessageStyle, from: DiagramRgb, to: DiagramRgb): Record<FadeStyle, string> {
|
||||
return createAnsiRampTheme(numberedStyleKeys(`${style}Fade`, SEQUENCE_FADE_STEPS), from, to) as Record<
|
||||
FadeStyle,
|
||||
string
|
||||
>
|
||||
}
|
||||
|
||||
const DEFAULT_ANSI_THEME: Required<Record<AnsiSequenceCellStyle, string>> = {
|
||||
participant: ansiFg(DEFAULT_THEME_RGB.participant),
|
||||
lifeline: ansiFg(DEFAULT_THEME_RGB.lifeline),
|
||||
group: ansiFg(DEFAULT_THEME_RGB.group),
|
||||
request: ansiFg(DEFAULT_THEME_RGB.request),
|
||||
response: ansiFg(DEFAULT_THEME_RGB.response),
|
||||
fragment: ansiFg(DEFAULT_THEME_RGB.fragment),
|
||||
fragmentLabel: `${ansiFg(DEFAULT_THEME_RGB.fragment)}${ansiBg(DEFAULT_THEME_RGB.fragmentLabelBg)}`,
|
||||
note: `${ansiFg(DEFAULT_THEME_RGB.noteFg)}${ansiBg(DEFAULT_THEME_RGB.noteBg)}`,
|
||||
...createAnsiFadeTheme("request", DEFAULT_THEME_RGB.lifeline, DEFAULT_THEME_RGB.request),
|
||||
...createAnsiFadeTheme("response", DEFAULT_THEME_RGB.lifeline, DEFAULT_THEME_RGB.response),
|
||||
}
|
||||
|
||||
function createColorFadeTheme(
|
||||
style: MessageStyle,
|
||||
from: RGBA | undefined,
|
||||
to: RGBA | undefined,
|
||||
): Record<FadeStyle, RGBA | undefined> {
|
||||
return createColorRampTheme(numberedStyleKeys(`${style}Fade`, SEQUENCE_FADE_STEPS), from, to) as Record<
|
||||
FadeStyle,
|
||||
RGBA | undefined
|
||||
>
|
||||
}
|
||||
|
||||
export function resolveSequenceStyleColors(colors: SequenceStyleColors): SequenceStyleColors {
|
||||
return {
|
||||
...colors,
|
||||
...createColorFadeTheme("request", colors.lifeline, colors.request),
|
||||
...createColorFadeTheme("response", colors.lifeline, colors.response),
|
||||
}
|
||||
}
|
||||
|
||||
export function sequenceStyleColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: SequenceStyleColors,
|
||||
): RGBA | undefined {
|
||||
if (style === "noteBadge") return colors.note
|
||||
if (style === "fragmentLabel") return colors.fragment
|
||||
return style ? colors[style] : undefined
|
||||
}
|
||||
|
||||
export function sequenceStyleBackgroundColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: SequenceStyleColors,
|
||||
): RGBA | undefined {
|
||||
if (style === "fragmentLabel") return colors.fragmentLabelBg
|
||||
return style === "noteBadge" ? colors.noteBg : undefined
|
||||
}
|
||||
|
||||
export function resolveSequenceAnsiTheme(
|
||||
theme: SequenceDiagramAnsiTheme = {},
|
||||
): Required<Record<AnsiSequenceCellStyle, string>> {
|
||||
return { ...DEFAULT_ANSI_THEME, ...theme }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
|
||||
export interface SequenceParticipant {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SequenceParticipantGroup {
|
||||
label: string
|
||||
participantIds: string[]
|
||||
}
|
||||
|
||||
export interface SequenceMessage {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
style: "solid" | "dashed"
|
||||
head?: SequenceArrowHead
|
||||
number?: number
|
||||
activate?: string
|
||||
deactivate?: string
|
||||
}
|
||||
|
||||
export type SequenceArrowHead = "open" | "cross" | "async"
|
||||
|
||||
export interface SequenceNote {
|
||||
over: string[]
|
||||
label: string
|
||||
position?: "over" | "left" | "right"
|
||||
}
|
||||
|
||||
export interface SequenceActivation {
|
||||
participant: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface SequenceFragment {
|
||||
kind: "alt" | "else" | "loop" | "end"
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SequenceStep =
|
||||
| { type: "message"; message: SequenceMessage }
|
||||
| { type: "note"; note: SequenceNote }
|
||||
| { type: "activation"; activation: SequenceActivation }
|
||||
| { type: "fragment"; fragment: SequenceFragment }
|
||||
|
||||
export interface SequenceDiagram {
|
||||
participants: SequenceParticipant[]
|
||||
messages: SequenceMessage[]
|
||||
steps: SequenceStep[]
|
||||
groups: SequenceParticipantGroup[]
|
||||
}
|
||||
|
||||
export interface SequenceDiagramRenderOptions {
|
||||
compact?: boolean
|
||||
minParticipantGap?: number
|
||||
fragmentBorderStyle?: BorderStyle
|
||||
}
|
||||
|
||||
export type SequenceDiagramAnsiTheme = Partial<Record<AnsiSequenceCellStyle, string>>
|
||||
|
||||
export interface SequenceDiagramAnsiOptions extends SequenceDiagramRenderOptions {
|
||||
theme?: SequenceDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface SequenceDiagramOptions extends TextBufferOptions, SequenceDiagramRenderOptions {
|
||||
content?: string
|
||||
participantColor?: ColorInput
|
||||
lifelineColor?: ColorInput
|
||||
groupColor?: ColorInput
|
||||
requestColor?: ColorInput
|
||||
responseColor?: ColorInput
|
||||
noteColor?: ColorInput
|
||||
noteBackgroundColor?: ColorInput
|
||||
}
|
||||
|
||||
export type MessageStyle = "request" | "response"
|
||||
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
|
||||
export type AnsiSequenceCellStyle =
|
||||
| "participant"
|
||||
| "lifeline"
|
||||
| "group"
|
||||
| MessageStyle
|
||||
| FadeStyle
|
||||
| "fragment"
|
||||
| "fragmentLabel"
|
||||
| "note"
|
||||
export type SequenceCellStyle = AnsiSequenceCellStyle | "noteBadge"
|
||||
@@ -0,0 +1,81 @@
|
||||
import { normalizeStateDiagramEndpoint } from "./endpoint.js"
|
||||
import type {
|
||||
StateDiagramActiveTransition,
|
||||
StateDiagramActiveTransitionSelection,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
type ActiveTransitionMatchTransition = StateDiagramTransition & {
|
||||
sourceTransitions?: readonly StateDiagramTransition[]
|
||||
}
|
||||
|
||||
function normalizeActiveTransition(activeTransition: StateDiagramActiveTransition): StateDiagramActiveTransition {
|
||||
return {
|
||||
from: normalizeStateDiagramEndpoint(activeTransition.from, "from"),
|
||||
to: normalizeStateDiagramEndpoint(activeTransition.to, "to"),
|
||||
label: activeTransition.label,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeActiveTransitions(
|
||||
activeTransition: StateDiagramActiveTransitionSelection | undefined,
|
||||
): StateDiagramActiveTransition[] {
|
||||
if (!activeTransition) return []
|
||||
const transitions = Array.isArray(activeTransition) ? activeTransition : [activeTransition]
|
||||
return transitions.map(normalizeActiveTransition)
|
||||
}
|
||||
|
||||
function activeTransitionEqual(left: StateDiagramActiveTransition, right: StateDiagramActiveTransition): boolean {
|
||||
return left.from === right.from && left.to === right.to && left.label === right.label
|
||||
}
|
||||
|
||||
function activeTransitionMatchesTransition(
|
||||
activeTransition: StateDiagramActiveTransition,
|
||||
transition: StateDiagramTransition,
|
||||
): boolean {
|
||||
return (
|
||||
activeTransition.from === transition.from &&
|
||||
activeTransition.to === transition.to &&
|
||||
(activeTransition.label === undefined || activeTransition.label === transition.label)
|
||||
)
|
||||
}
|
||||
|
||||
export function activeTransitionListsEqual(
|
||||
left: readonly StateDiagramActiveTransition[],
|
||||
right: readonly StateDiagramActiveTransition[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length && left.every((transition, index) => activeTransitionEqual(transition, right[index]!))
|
||||
)
|
||||
}
|
||||
|
||||
export function isActiveTransition(
|
||||
transition: ActiveTransitionMatchTransition,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): boolean {
|
||||
return activeTransitionIndex(transition, activeTransitions) !== -1
|
||||
}
|
||||
|
||||
export function activeTransitionIndex(
|
||||
transition: ActiveTransitionMatchTransition,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): number {
|
||||
const exactIndex = activeTransitions.findIndex((activeTransition) =>
|
||||
activeTransitionMatchesTransition(activeTransition, transition),
|
||||
)
|
||||
if (exactIndex !== -1) return exactIndex
|
||||
|
||||
const sourceTransitions = transition.sourceTransitions
|
||||
if (!sourceTransitions || sourceTransitions.length <= 1 || activeTransitions.length < sourceTransitions.length)
|
||||
return -1
|
||||
|
||||
for (let index = 0; index <= activeTransitions.length - sourceTransitions.length; index++) {
|
||||
const matches = sourceTransitions.every((sourceTransition, offset) => {
|
||||
const activeTransition = activeTransitions[index + offset]!
|
||||
return activeTransitionMatchesTransition(activeTransition, sourceTransition)
|
||||
})
|
||||
if (matches) return index
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import stringWidth from "string-width"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderStateDiagram, renderStateDiagramAnsi } from "./diagram.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { StateDiagramRenderable } from "./renderable.js"
|
||||
|
||||
describe("StateDiagram", () => {
|
||||
test("detects and parses Mermaid state diagrams", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
%% request lifecycle
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
expect(diagram.states).toEqual([
|
||||
{ id: "__start", label: "●", kind: "start" },
|
||||
{ id: "Idle", label: "Idle", kind: "state" },
|
||||
{ id: "Loading", label: "Loading", kind: "state" },
|
||||
{ id: "Success", label: "Success", kind: "state" },
|
||||
{ id: "__end", label: "◎", kind: "end" },
|
||||
])
|
||||
expect(diagram.transitions).toEqual([
|
||||
{ from: "__start", to: "Idle", label: "" },
|
||||
{ from: "Idle", to: "Loading", label: "submit" },
|
||||
{ from: "Loading", to: "Success", label: "done" },
|
||||
{ from: "Success", to: "__end", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses quoted state aliases", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state "Waiting<br/>for Payment" as WaitingPayment
|
||||
[*] --> WaitingPayment
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "WaitingPayment",
|
||||
label: "Waiting<br/>for Payment",
|
||||
kind: "state",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses choice pseudo-states", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
[*] --> Decision
|
||||
state Decision <<choice>>
|
||||
Decision --> Accepted: yes
|
||||
`)
|
||||
|
||||
expect(diagram.states).toContainEqual({ id: "Decision", label: "┼", kind: "choice" })
|
||||
})
|
||||
|
||||
test("parses composite states and notes", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
stateDiagram-v2
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
}
|
||||
note right of Editing
|
||||
Draft changes
|
||||
end note
|
||||
`)
|
||||
|
||||
expect(diagram.composites).toContainEqual({ id: "Authenticated", label: "Authenticated" })
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Idle",
|
||||
label: "Idle",
|
||||
kind: "state",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.states).toContainEqual({
|
||||
id: "Authenticated.__start",
|
||||
label: "●",
|
||||
kind: "start",
|
||||
parentId: "Authenticated",
|
||||
})
|
||||
expect(diagram.notes).toEqual([{ target: "Editing", position: "right", lines: ["Draft changes"] }])
|
||||
})
|
||||
|
||||
test("renders a horizontal state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: done
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭──────╮ submit ╭─────────╮ done ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰─────────╯ ╰─────────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders reverse horizontal direction from right to left", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(" A ") && line.includes(" B "))!
|
||||
|
||||
expect(labelRow.indexOf("B")).toBeLessThan(labelRow.indexOf("A"))
|
||||
expect(output).toContain("◀")
|
||||
})
|
||||
|
||||
test("does not mutate a parsed diagram when rendering with a direction override", () => {
|
||||
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`)
|
||||
|
||||
drawStateDiagramGrid(diagram, { direction: "RL" })
|
||||
|
||||
expect(diagram.direction).toBe("LR")
|
||||
})
|
||||
|
||||
test("normalizes mutable renderable state gaps before exposing them", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 40, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: "stateDiagram-v2\n A --> B",
|
||||
minStateGap: Number.NaN,
|
||||
})
|
||||
|
||||
expect(diagram.minStateGap).toBe(5)
|
||||
diagram.minStateGap = 0
|
||||
expect(diagram.minStateGap).toBe(1)
|
||||
diagram.minStateGap = 3.9
|
||||
expect(diagram.minStateGap).toBe(3)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("places right-to-left transition labels between intact frames", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction RL
|
||||
A --> B: reopen after a very detailed reviewer comment`)
|
||||
|
||||
expect(output).toContain("╭───╮")
|
||||
expect(output.match(/╭───╮/g)?.length).toBe(2)
|
||||
expect(output).toContain("reopen after a very detailed reviewer comment")
|
||||
})
|
||||
|
||||
test("keeps Unicode state labels inside their measured frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state "界" as Wide`)
|
||||
const widths = output.split("\n").map((line) => stringWidth(line))
|
||||
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
expect(output).toContain("界")
|
||||
})
|
||||
|
||||
test("reserves horizontal room for long transition labels", () => {
|
||||
const label = "this transition label is much wider than the route"
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: ${label}`)
|
||||
const labelRow = output.split("\n").find((line) => line.includes(label))!
|
||||
|
||||
expect(labelRow.match(/╭───╮/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("renders every line of multiline transition labels", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first<br/>second`)
|
||||
|
||||
expect(output).toContain("first")
|
||||
expect(output).toContain("second")
|
||||
})
|
||||
|
||||
test("renders a vertical state diagram", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction TB
|
||||
[*] --> Cart
|
||||
Cart --> Payment: checkout
|
||||
Payment --> Complete
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ●
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────╮
|
||||
│ Cart │
|
||||
╰───┬──╯
|
||||
│
|
||||
│ checkout
|
||||
│
|
||||
▼
|
||||
╭─────────╮
|
||||
│ Payment │
|
||||
╰────┬────╯
|
||||
│
|
||||
│
|
||||
│
|
||||
▼
|
||||
╭──────────╮
|
||||
│ Complete │
|
||||
╰──────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders branched and backward transitions visibly", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("captures converging labeled branches with long state names", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Waiting
|
||||
state "Waiting for surface and workspace" as Waiting
|
||||
state "Surface bound only" as Surface
|
||||
state "Workspace bound only" as Workspace
|
||||
state "Ready with queued input" as Ready
|
||||
state "Agent activity requested" as Active
|
||||
Waiting --> Surface: InteractionSurfaceBound
|
||||
Waiting --> Workspace: WorkspaceBound
|
||||
Surface --> Ready: WorkspaceBound
|
||||
Workspace --> Ready: InteractionSurfaceBound
|
||||
Ready --> Active: AgentActivityRequested`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭───────────────────────────────────╮ InteractionSurfaceBound ╭────────────────────╮ WorkspaceBound ╭─────────────────────────╮ AgentActivityRequested ╭──────────────────────────╮
|
||||
●────────────▶│ Waiting for surface and workspace ├────────────────────────▶│ Surface bound only ├───────────────▶│ Ready with queued input ├───────────────────────▶│ Agent activity requested │
|
||||
╰───────────────┬───────────────────╯ ╰────────────────────╯ ╰─────────────────────────╯ ╰──────────────────────────╯
|
||||
│ WorkspaceBound ▲
|
||||
╰──────────────────────────────────────────────────────╮ │
|
||||
│ InteractionSurfaceBound │
|
||||
▼ ╭───────────────────────────────────────╯
|
||||
╭─────────────┴────────╮
|
||||
│ Workspace bound only │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps raised note connectors off outgoing transitions", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
Loading --> Success: 200 OK
|
||||
Loading --> Error: timeout
|
||||
note right of Loading : waiting for response
|
||||
Error --> Loading: retry
|
||||
Success --> [*]
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╔══════════════════════╗
|
||||
╔═══╣ waiting for response ║
|
||||
║ ╚══════════════════════╝
|
||||
║
|
||||
║
|
||||
╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
|
||||
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
|
||||
╰──────╯ ╰──┬──────╯ ╰─────────╯
|
||||
│ ▲
|
||||
timeout │ │
|
||||
▼ │ retry
|
||||
╭─────┴─╮
|
||||
│ Error │
|
||||
╰───────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders configurable line arrowheads", () => {
|
||||
const output = renderStateDiagram(
|
||||
`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
`,
|
||||
{ arrowHeadStyle: "line" },
|
||||
)
|
||||
|
||||
expect(output).toContain("→")
|
||||
expect(output).not.toContain("▶")
|
||||
})
|
||||
|
||||
test("renders self transitions and choice branches", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Editing
|
||||
Editing --> Editing: type
|
||||
Editing --> Decision: submit
|
||||
Decision --> Saved: ok
|
||||
Decision --> Error: fail
|
||||
Error --> Editing: retry
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─────────╮ submit ok ╭───────╮
|
||||
●────────────▶│ Editing ├─────────────┬────────────▶│ Saved │
|
||||
╰──┬──────╯ │ ╰───────╯
|
||||
▲ │ ▲ type │ fail
|
||||
│ ╰────╯ │
|
||||
│ ▼
|
||||
│ ╭───────╮
|
||||
│ │ Error │
|
||||
│ ╰───┬───╯
|
||||
│ │
|
||||
│ │
|
||||
│ retry │
|
||||
╰──────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("connects lower routed branches into choice junctions", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
[*] --> Fork
|
||||
Fork --> Upper
|
||||
Fork --> Lower
|
||||
Upper --> Decision
|
||||
Lower --> Decision
|
||||
Decision --> Done
|
||||
Done --> [*]`)
|
||||
|
||||
expect(output).toContain("Upper ├─────────────┬────────────▶│ Done")
|
||||
})
|
||||
|
||||
test("renders self transitions as loops in vertical diagrams", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
Working --> Working: retry`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮
|
||||
│ Working │
|
||||
╰──┬──────╯
|
||||
│ ▲ retry
|
||||
╰────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders parallel transitions without losing labels", () => {
|
||||
const horizontal = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
const vertical = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: first
|
||||
A --> B: second`)
|
||||
|
||||
expect(horizontal).toContain("first")
|
||||
expect(horizontal).toContain("second")
|
||||
expect(vertical).toContain("first")
|
||||
expect(vertical).toContain("second")
|
||||
})
|
||||
|
||||
test("keeps independent overlapping feedback labels and paths distinct", () => {
|
||||
const content = (direction: "LR" | "RL") => `stateDiagram-v2
|
||||
direction ${direction}
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B`
|
||||
|
||||
for (const direction of ["LR", "RL"] as const) {
|
||||
const output = renderStateDiagram(content(direction))
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("res│t")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps independent internal feedback paths inside their composite frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Runtime {
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
}`)
|
||||
const lines = output.split("\n")
|
||||
const frameTop = lines.findIndex((line) => line.includes("Runtime"))
|
||||
const upperFeedback = lines.findIndex((line) => line.includes("reset B"))
|
||||
|
||||
expect(upperFeedback).toBeGreaterThan(frameTop)
|
||||
expect(output).toContain("reset A")
|
||||
expect(output).not.toContain("res│t")
|
||||
})
|
||||
|
||||
test("places notes away from independent feedback corridors", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
C --> D: finish
|
||||
C --> A: reset A
|
||||
D --> B: reset B
|
||||
note right of B : note beside B`)
|
||||
|
||||
expect(output).toContain("note beside B")
|
||||
expect(output).toContain("reset B")
|
||||
expect(output).not.toContain("╭─║")
|
||||
expect(output).not.toContain("║──")
|
||||
})
|
||||
|
||||
test("keeps duplicate feedback labels away from an independent return path", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
C --> A: ca
|
||||
D --> B: db1
|
||||
D --> B: db2`)
|
||||
|
||||
expect(output).toContain("ca")
|
||||
expect(output).toContain("db1")
|
||||
expect(output).toContain("db2")
|
||||
expect(output).not.toContain("c│")
|
||||
})
|
||||
|
||||
test("renders composite state containers", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─ Authenticated ──────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ╭──────╮ open ╭─────────╮ save │
|
||||
│ ─────────────▶│ Idle ├────────────▶│ Editing ├────────────── │
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("routes transitions entering and leaving composite states through scoped markers", () => {
|
||||
const content = `
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
Authenticated --> [*]: logout
|
||||
`
|
||||
const diagram = parseMermaidStateDiagram(content)
|
||||
const output = renderStateDiagram(content)
|
||||
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "__start",
|
||||
to: "Authenticated.__start",
|
||||
label: "login",
|
||||
})
|
||||
expect(diagram.transitions).toContainEqual({
|
||||
from: "Authenticated.__end",
|
||||
to: "__end",
|
||||
label: "logout",
|
||||
})
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Authenticated ──────────────────╮
|
||||
│ │
|
||||
login │ ╭──────╮ open ╭─────────╮ │ save
|
||||
●────────────▶│ Idle ├────────────▶│ Editing ├────────────▶◎
|
||||
│ ╰──────╯ ╰─────────╯ │
|
||||
│ │
|
||||
╰──────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders notes attached to states", () => {
|
||||
const output = renderStateDiagram(`
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
note right of Loading : waits for response
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭──────╮ submit ╭─────────╮ ╔════════════════════╗
|
||||
●────────────▶│ Idle ├────────────▶│ Loading │════╣ waits for response ║
|
||||
╰──────╯ ╰─────────╯ ╚════════════════════╝"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders ANSI styles", () => {
|
||||
const output = renderStateDiagramAnsi(`
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
`)
|
||||
|
||||
expect(output).toContain("\x1b[")
|
||||
expect(output).toContain("●")
|
||||
})
|
||||
|
||||
test("colors states, transitions, labels, and markers separately", async () => {
|
||||
const stateColor = parseColor("#E5E7EB")
|
||||
const activeStateColor = parseColor("#DDFFF6")
|
||||
const transitionColor = parseColor("#86E1C8")
|
||||
const labelColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 12 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
stateColor,
|
||||
activeStateColor,
|
||||
transitionColor,
|
||||
labelColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const idleSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
const arrowSpan = spans.find((span) => span.text.includes("▶"))
|
||||
const labelSpan = spans.find((span) => span.text.includes("submit"))
|
||||
const fadeSpan = spans.find((span) => span.text.includes("├") || span.text.includes("┤"))
|
||||
|
||||
expect(idleSpan?.fg.equals(stateColor)).toBe(true)
|
||||
expect(loadingSpan?.fg.equals(activeStateColor)).toBe(true)
|
||||
expect(arrowSpan?.fg.equals(transitionColor)).toBe(true)
|
||||
expect(labelSpan?.fg.equals(labelColor)).toBe(true)
|
||||
expect(fadeSpan?.fg.equals(stateColor)).toBe(false)
|
||||
expect(fadeSpan?.fg.equals(transitionColor)).toBe(false)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("repaints state colors after mounting without changing diagram text", async () => {
|
||||
const initialColor = parseColor("#86E1C8")
|
||||
const updatedColor = parseColor("#38BDF8")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: "stateDiagram-v2\n A --> B: next",
|
||||
transitionColor: initialColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
const before = testRenderer.captureCharFrame()
|
||||
|
||||
diagram.transitionColor = updatedColor
|
||||
await testRenderer.renderOnce()
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(testRenderer.captureCharFrame()).toBe(before)
|
||||
expect(spans.some((span) => span.text.includes("▶") && span.fg.equals(updatedColor))).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors active transitions separately", async () => {
|
||||
const transitionColor = parseColor("#86E1C8")
|
||||
const activeTransitionColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit`,
|
||||
activeTransition: { from: "Idle", to: "Loading" },
|
||||
transitionColor,
|
||||
activeTransitionColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const activeArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(activeTransitionColor))
|
||||
const inactiveArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(transitionColor))
|
||||
const departureSpan = spans.find((span) => span.text.includes("├"))
|
||||
const labelSpan = spans.find((span) => span.text.includes("submit"))
|
||||
|
||||
expect(activeArrowSpan).toBeTruthy()
|
||||
expect(inactiveArrowSpan).toBeTruthy()
|
||||
expect(departureSpan?.fg.equals(activeTransitionColor)).toBe(false)
|
||||
expect(departureSpan?.fg.equals(transitionColor)).toBe(false)
|
||||
expect(labelSpan?.fg.equals(activeTransitionColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("active transitions preserve route glyph shape", () => {
|
||||
const content = `stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: next`
|
||||
|
||||
const inactive = renderStateDiagram(content)
|
||||
const active = renderStateDiagram(content, {
|
||||
activeTransition: { from: "A", to: "B" },
|
||||
})
|
||||
|
||||
expect(active).toBe(inactive)
|
||||
})
|
||||
|
||||
test("derives transition boundary fades from per-state colors", async () => {
|
||||
const sourceColor = parseColor("#000000")
|
||||
const activeTransitionColor = parseColor("#060000")
|
||||
const expectedBoundaryColor = parseColor("#010000")
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Idle",
|
||||
activeStateColor: "#FF0000",
|
||||
activeTransition: { from: "Idle", to: "Loading" },
|
||||
activeTransitionColor,
|
||||
stateColors: { Idle: sourceColor },
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const departureSpan = spans.find((span) => span.text.includes("├"))
|
||||
|
||||
expect(departureSpan?.fg.equals(expectedBoundaryColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors individual states with per-state overrides", async () => {
|
||||
const stateColor = parseColor("#E4EFE8")
|
||||
const activeStateColor = parseColor("#FFD3A0")
|
||||
const outgoingColor = parseColor("#F0C198")
|
||||
const incomingColor = parseColor("#CFE4D7")
|
||||
const testRenderer = await createTestRenderer({ width: 90, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
stateColor,
|
||||
activeStateColor,
|
||||
stateColors: {
|
||||
Idle: outgoingColor,
|
||||
Loading: incomingColor,
|
||||
},
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
let spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const idleSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
|
||||
expect(idleSpan?.fg.equals(outgoingColor)).toBe(true)
|
||||
expect(loadingSpan?.fg.equals(incomingColor)).toBe(true)
|
||||
|
||||
diagram.stateColors = undefined
|
||||
await testRenderer.renderOnce()
|
||||
spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
|
||||
expect(spans.find((span) => span.text.includes("Idle"))?.fg.equals(stateColor)).toBe(true)
|
||||
expect(spans.find((span) => span.text.includes("Loading"))?.fg.equals(activeStateColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders state backgrounds inside the state box only", async () => {
|
||||
const activeStateColor = parseColor("#FFD3A0")
|
||||
const activeStateBg = parseColor("#26352F")
|
||||
const testRenderer = await createTestRenderer({ width: 90, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
Idle --> Loading: submit`,
|
||||
activeState: "Loading",
|
||||
activeStateColor,
|
||||
stateBgColors: { Loading: activeStateBg },
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const borderSpan = spans.find((span) => span.text.includes("╭") || span.text.includes("╮"))
|
||||
const loadingSpan = spans.find((span) => span.text.includes("Loading"))
|
||||
|
||||
expect(borderSpan?.bg.equals(activeStateBg)).toBe(false)
|
||||
expect(loadingSpan?.fg.equals(activeStateColor)).toBe(true)
|
||||
expect(loadingSpan?.bg.equals(activeStateBg)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("fades active transitions from the active state color", () => {
|
||||
const output = renderStateDiagramAnsi(
|
||||
`
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
`,
|
||||
{
|
||||
activeState: "__start",
|
||||
activeTransition: { from: "__start", to: "Idle" },
|
||||
theme: {
|
||||
activeStateActiveTransitionFade1: "[active-state-fade]",
|
||||
startActiveTransitionFade1: "[start-fade]",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(output).toContain("[active-state-fade]")
|
||||
expect(output).not.toContain("[start-fade]")
|
||||
})
|
||||
|
||||
test("colors note connector, border, and text separately", async () => {
|
||||
const noteConnectorColor = parseColor("#8DA99B")
|
||||
const noteBorderColor = parseColor("#B68B68")
|
||||
const noteTextColor = parseColor("#F1D9BE")
|
||||
const testRenderer = await createTestRenderer({ width: 110, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Idle
|
||||
Idle --> Loading: submit
|
||||
note right of Loading : waits for response`,
|
||||
noteConnectorColor,
|
||||
noteBorderColor,
|
||||
noteTextColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const connectorSpan = spans.find((span) => span.text.includes("═") && span.fg?.equals(noteConnectorColor))
|
||||
const borderSpan = spans.find((span) => span.text.includes("╔") && span.fg?.equals(noteBorderColor))
|
||||
const textSpan = spans.find((span) => span.text.includes("waits for response"))
|
||||
|
||||
expect(connectorSpan).toBeTruthy()
|
||||
expect(borderSpan).toBeTruthy()
|
||||
expect(textSpan?.fg.equals(noteTextColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors active transition paths through choice junctions", async () => {
|
||||
const activeTransitionColor = parseColor("#E6B17E")
|
||||
const testRenderer = await createTestRenderer({ width: 120, height: 8 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
Validating --> Decision
|
||||
Decision --> Submitted: valid
|
||||
Decision --> Invalid: errors`,
|
||||
activeTransition: [
|
||||
{ from: "Validating", to: "Decision" },
|
||||
{ from: "Decision", to: "Submitted", label: "valid" },
|
||||
],
|
||||
activeTransitionColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const validSpan = spans.find((span) => span.text.includes("valid"))
|
||||
const errorsSpan = spans.find((span) => span.text.includes("errors"))
|
||||
const activeArrowSpan = spans.find((span) => span.text.includes("▶") && span.fg?.equals(activeTransitionColor))
|
||||
|
||||
expect(validSpan?.fg.equals(activeTransitionColor)).toBe(true)
|
||||
expect(errorsSpan?.fg.equals(activeTransitionColor)).toBe(false)
|
||||
expect(activeArrowSpan).toBeTruthy()
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("colors composite containers separately", async () => {
|
||||
const compositeColor = parseColor("#6F8A7E")
|
||||
const stateColor = parseColor("#E4EFE8")
|
||||
const testRenderer = await createTestRenderer({ width: 100, height: 10 })
|
||||
|
||||
try {
|
||||
const diagram = new StateDiagramRenderable(testRenderer.renderer, {
|
||||
content: `stateDiagram-v2
|
||||
direction LR
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
}`,
|
||||
compositeColor,
|
||||
stateColor,
|
||||
})
|
||||
|
||||
testRenderer.renderer.root.add(diagram)
|
||||
await testRenderer.renderOnce()
|
||||
|
||||
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
|
||||
const compositeSpan = spans.find((span) => span.text.includes("Authenticated"))
|
||||
const stateSpan = spans.find((span) => span.text.includes("Idle"))
|
||||
|
||||
expect(compositeSpan?.fg.equals(compositeColor)).toBe(true)
|
||||
expect(stateSpan?.fg.equals(stateColor)).toBe(true)
|
||||
} finally {
|
||||
testRenderer.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores stale active transition selections while highlighting matches", () => {
|
||||
expect(() =>
|
||||
renderStateDiagram(
|
||||
`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`,
|
||||
{
|
||||
activeTransition: [
|
||||
{ from: "Missing", to: "Gone" },
|
||||
{ from: "A", to: "B" },
|
||||
],
|
||||
},
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { renderStateGridAnsi, renderStateGridText } from "./render-grid.js"
|
||||
import type { StateDiagramAnsiOptions, StateDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderStateDiagram(content: string, options: StateDiagramRenderOptions = {}): string {
|
||||
return renderStateGridText(drawStateDiagramGrid(parseMermaidStateDiagram(content), options))
|
||||
}
|
||||
|
||||
export function renderStateDiagramAnsi(content: string, options: StateDiagramAnsiOptions = {}): string {
|
||||
return renderStateGridAnsi(drawStateDiagramGrid(parseMermaidStateDiagram(content), options), options.theme)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { diagramRadialCellColorLevel } from "../core/color/map.js"
|
||||
import { diagramArrowHead, diagramLineGlyph, drawDiagramFrame, mergeDiagramLineGlyph } from "../core/drawing.js"
|
||||
import { activeTransitionIndex, isActiveTransition, normalizeActiveTransitions } from "./active-transition.js"
|
||||
import {
|
||||
createStateDiagramLayout,
|
||||
expandCompositeBoundsForFeedback,
|
||||
expandCompositeBoundsForInternalTransitions,
|
||||
type StateDiagramBoxBounds as BoxBounds,
|
||||
type StateDiagramNoteBounds as StateNoteBounds,
|
||||
} from "./layout.js"
|
||||
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
|
||||
import type { StateCellMetadata, StateGrid } from "./render-grid.js"
|
||||
import {
|
||||
createStateTransitionJunctionPlans,
|
||||
createStateTransitionRenderPlans,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import {
|
||||
isStateActiveTransitionStyle,
|
||||
isStateTransitionFadeStyle,
|
||||
stateDiagramStateColorKey,
|
||||
stateTransitionFadeStyle,
|
||||
} from "./style.js"
|
||||
import type {
|
||||
FadeSourceStyle,
|
||||
StateCellStyle,
|
||||
StateDiagram,
|
||||
StateDiagramActiveTransition,
|
||||
StateDiagramArrowHeadStyle,
|
||||
StateDiagramRenderOptions,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
import { isHiddenCompositeMarker, prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
type StateCell = DiagramCanvasCell<StateCellStyle, StateCellMetadata>
|
||||
|
||||
interface TransitionDrawContext {
|
||||
fadeSource: FadeSourceStyle
|
||||
active: boolean
|
||||
fadeFromSource: boolean
|
||||
sourceStateId: string
|
||||
}
|
||||
|
||||
function translateTransitionPlans(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
dy: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return plans.map((plan) => ({
|
||||
...plan,
|
||||
cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })),
|
||||
path: plan.path.map(([x, y]) => [x, y + dy]),
|
||||
label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function makeGrid(width: number, height: number): StateGrid {
|
||||
return new DiagramCanvas(width, height, {
|
||||
mergeCell: (existing, incoming): StateCell => {
|
||||
const shouldMerge = isTransitionDrawingStyle(existing.style) && isTransitionDrawingStyle(incoming.style)
|
||||
return {
|
||||
...incoming,
|
||||
char: shouldMerge
|
||||
? (mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char)
|
||||
: incoming.char,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function isTransitionDrawingStyle(style: StateCellStyle | undefined): boolean {
|
||||
return (
|
||||
style === "transition" ||
|
||||
style === "activeTransition" ||
|
||||
isStateActiveTransitionStyle(style) ||
|
||||
isStateTransitionFadeStyle(style)
|
||||
)
|
||||
}
|
||||
|
||||
function setCell(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
char: string,
|
||||
style?: StateCellStyle,
|
||||
stateId?: string,
|
||||
bgStateId?: string,
|
||||
): void {
|
||||
grid.setCell(x, y, char, style, { stateId, bgStateId })
|
||||
}
|
||||
|
||||
function setText(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style?: StateCellStyle,
|
||||
stateId?: string,
|
||||
bgStateId?: string,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, { stateId, bgStateId })
|
||||
}
|
||||
|
||||
function setTransitionLabel(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
lines: readonly string[],
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
lines.forEach((line, index) => setText(grid, x, y + index, line, style))
|
||||
}
|
||||
|
||||
function drawBox(
|
||||
grid: StateGrid,
|
||||
state: StateDiagramState,
|
||||
bounds: BoxBounds,
|
||||
lines: string[],
|
||||
active: boolean,
|
||||
borderStyle: BorderStyle,
|
||||
): void {
|
||||
if (isHiddenCompositeMarker(state)) return
|
||||
|
||||
if (state.kind !== "state") {
|
||||
setCell(grid, bounds.left, bounds.top, state.label, active ? "activeState" : state.kind, state.id)
|
||||
return
|
||||
}
|
||||
const style: StateCellStyle = active ? "activeState" : "state"
|
||||
fillBoxInterior(grid, bounds, style, state.id)
|
||||
drawStateFrame(grid, bounds, BorderChars[borderStyle], style, state.id)
|
||||
lines.forEach((line, index) => {
|
||||
setStateText(grid, bounds, bounds.left + 2, bounds.top + 1 + index, line, style, state.id)
|
||||
})
|
||||
}
|
||||
|
||||
function stateColorKeyForCell(bounds: BoxBounds, stateId: string, x: number, y: number, border = false): string {
|
||||
return stateDiagramStateColorKey(stateId, diagramRadialCellColorLevel(bounds, x, y, border))
|
||||
}
|
||||
|
||||
function fillBoxInterior(grid: StateGrid, bounds: BoxBounds, style: StateCellStyle, stateId: string): void {
|
||||
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
|
||||
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
|
||||
const colorKey = stateColorKeyForCell(bounds, stateId, x, y)
|
||||
setCell(grid, x, y, " ", style, colorKey, colorKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawStateFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
stateId: string,
|
||||
): void {
|
||||
const setBorderCell = (x: number, y: number, char: string) => {
|
||||
setCell(grid, x, y, char, style, stateColorKeyForCell(bounds, stateId, x, y, true))
|
||||
}
|
||||
|
||||
drawDiagramFrame(bounds, chars, setBorderCell)
|
||||
}
|
||||
|
||||
function setStateText(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style: StateCellStyle,
|
||||
stateId: string,
|
||||
): void {
|
||||
grid.setText(x, y, text, style, (cellX, cellY) => {
|
||||
const colorKey = stateColorKeyForCell(bounds, stateId, cellX, cellY)
|
||||
return { stateId: colorKey, bgStateId: colorKey }
|
||||
})
|
||||
}
|
||||
|
||||
function drawContainerFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
label: string,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
stateId?: string,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style, stateId))
|
||||
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style, stateId)
|
||||
}
|
||||
|
||||
function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void {
|
||||
const step = fromX <= toX ? 1 : -1
|
||||
for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) {
|
||||
setCell(grid, x, y, char, "noteConnector")
|
||||
}
|
||||
}
|
||||
|
||||
function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void {
|
||||
const chars = BorderChars.double
|
||||
const connectorChars = BorderChars.double
|
||||
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const targetBottom = target.top + target.height - 1
|
||||
const noteBottom = bounds.top + bounds.height - 1
|
||||
const noteAbove = noteBottom < target.top
|
||||
const noteBelow = bounds.top > targetBottom
|
||||
let connectorY: number
|
||||
|
||||
if (noteAbove || noteBelow) {
|
||||
const targetY = noteAbove ? target.top - 1 : targetBottom + 1
|
||||
connectorY = bounds.centerY
|
||||
const verticalStep = targetY <= connectorY ? 1 : -1
|
||||
|
||||
for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) {
|
||||
setCell(grid, targetX, y, connectorChars.vertical, "noteConnector")
|
||||
}
|
||||
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
const connectorTurnsRight = targetX <= noteX
|
||||
const corner = noteAbove
|
||||
? connectorTurnsRight
|
||||
? connectorChars.topLeft
|
||||
: connectorChars.topRight
|
||||
: connectorTurnsRight
|
||||
? connectorChars.bottomLeft
|
||||
: connectorChars.bottomRight
|
||||
setCell(grid, targetX, connectorY, corner, "noteConnector")
|
||||
} else {
|
||||
connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
}
|
||||
|
||||
drawContainerFrame(grid, bounds, "", chars, "noteBorder")
|
||||
setCell(
|
||||
grid,
|
||||
bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1,
|
||||
connectorY,
|
||||
bounds.note.position === "right" ? chars.rightT : chars.leftT,
|
||||
"noteBorder",
|
||||
)
|
||||
bounds.lines.forEach((line, index) => setText(grid, bounds.left + 2, bounds.top + 1 + index, line, "noteText"))
|
||||
}
|
||||
|
||||
function transitionLineStyle(active: boolean): StateCellStyle {
|
||||
return active ? "activeTransition" : "transition"
|
||||
}
|
||||
|
||||
function transitionLabelStyle(active: boolean): StateCellStyle {
|
||||
return active ? "activeTransition" : "label"
|
||||
}
|
||||
|
||||
function transitionFadeCellStyle(context: TransitionDrawContext, distance: number): StateCellStyle {
|
||||
return stateTransitionFadeStyle(context.fadeSource, context.active, distance, context.fadeFromSource)
|
||||
}
|
||||
|
||||
function drawTransitionRenderPlan(
|
||||
grid: StateGrid,
|
||||
plan: StateTransitionRenderPlan,
|
||||
arrowHeadStyle: StateDiagramArrowHeadStyle,
|
||||
context: TransitionDrawContext,
|
||||
): void {
|
||||
const lineStyle = transitionLineStyle(context.active)
|
||||
for (const cell of plan.cells) {
|
||||
const char = cell.arrowDirection ? diagramArrowHead(cell.arrowDirection, arrowHeadStyle) : cell.char
|
||||
const style = cell.fadeDistance === undefined ? lineStyle : transitionFadeCellStyle(context, cell.fadeDistance)
|
||||
setCell(grid, cell.x, cell.y, char, style, cell.fadeDistance === undefined ? undefined : context.sourceStateId)
|
||||
}
|
||||
if (plan.label) {
|
||||
setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, transitionLabelStyle(context.active))
|
||||
}
|
||||
}
|
||||
|
||||
function drawTransitionJunctionPlans(
|
||||
grid: StateGrid,
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, BoxBounds>,
|
||||
renderPlans: readonly StateTransitionRenderPlan[],
|
||||
activeState: string | undefined,
|
||||
activeTransitions: readonly StateDiagramActiveTransition[],
|
||||
): void {
|
||||
for (const plan of createStateTransitionJunctionPlans(diagram, bounds, renderPlans)) {
|
||||
const active = plan.transitions.some((transition) => isActiveTransition(transition, activeTransitions))
|
||||
const style =
|
||||
plan.state.id === activeState
|
||||
? "activeState"
|
||||
: active
|
||||
? "activeTransition"
|
||||
: plan.kind === "choice"
|
||||
? "choice"
|
||||
: "transition"
|
||||
setCell(grid, plan.bounds.left, plan.bounds.top, diagramLineGlyph(plan.connections, "rounded"), style)
|
||||
}
|
||||
}
|
||||
|
||||
function transitionFadeSource(
|
||||
statesById: Map<string, StateDiagramState>,
|
||||
transition: StateDiagramTransition,
|
||||
activeState: string | undefined,
|
||||
): FadeSourceStyle {
|
||||
if (transition.from === activeState) return "activeState"
|
||||
const source = statesById.get(transition.from)
|
||||
if (isHiddenCompositeMarker(source)) return "composite"
|
||||
if (source?.kind === "start") return "start"
|
||||
if (source?.kind === "end") return "end"
|
||||
if (source?.kind === "choice") return "choice"
|
||||
return "state"
|
||||
}
|
||||
|
||||
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
|
||||
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
|
||||
const diagram = prepareVisibleStateDiagram(directedDiagram)
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
const activeTransitions = normalizeActiveTransitions(options.activeTransition)
|
||||
const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, {
|
||||
minStateGap,
|
||||
})
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
let allBounds = [...bounds.values(), ...noteBounds]
|
||||
let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
let feedbackLaneY = maxY + 3
|
||||
let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
const transitionTop = Math.min(
|
||||
0,
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (transitionTop < 0) {
|
||||
const dy = -transitionTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
feedbackLaneY += dy
|
||||
feedbackTopY += dy
|
||||
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
}
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
const contentTop = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.top),
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (contentTop < 0) {
|
||||
const dy = -contentTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
transitionPlans = translateTransitionPlans(transitionPlans, dy)
|
||||
}
|
||||
allBounds = [...bounds.values(), ...noteBounds]
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label))
|
||||
const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width))
|
||||
const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height))
|
||||
const transitionRight = Math.max(
|
||||
maxX,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.x + 1),
|
||||
...(plan.label ? [plan.label.x + measureStateTransitionLabel(plan.route.transition.label).width] : []),
|
||||
]),
|
||||
)
|
||||
const transitionBottom = Math.max(
|
||||
maxY,
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y + 1),
|
||||
...(plan.label ? [plan.label.y + plan.label.lines.length] : []),
|
||||
]),
|
||||
)
|
||||
const grid = makeGrid(
|
||||
Math.max(maxX + Math.max(24, maxTransitionLabelWidth + 4), transitionRight + 2),
|
||||
Math.max(maxY + 8 + maxTransitionLabelLines, transitionBottom + 2),
|
||||
)
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
drawContainerFrame(
|
||||
grid,
|
||||
bound,
|
||||
composite.label,
|
||||
BorderChars[borderStyle],
|
||||
options.activeState === composite.id ? "activeState" : "composite",
|
||||
)
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
const bound = bounds.get(state.id)
|
||||
const size = sizes.get(state.id)
|
||||
if (!bound || !size) continue
|
||||
drawBox(grid, state, bound, size.lines, options.activeState === state.id, borderStyle)
|
||||
}
|
||||
|
||||
for (const plan of transitionPlans) {
|
||||
const transition = plan.route.transition
|
||||
const fadeSource = transitionFadeSource(statesById, transition, options.activeState)
|
||||
const activeIndex = activeTransitionIndex(transition, activeTransitions)
|
||||
const active = activeIndex !== -1
|
||||
const fadeFromSource = activeIndex <= 0
|
||||
const drawContext: TransitionDrawContext = {
|
||||
fadeSource,
|
||||
active,
|
||||
fadeFromSource,
|
||||
sourceStateId: transition.from,
|
||||
}
|
||||
drawTransitionRenderPlan(grid, plan, arrowHeadStyle, drawContext)
|
||||
}
|
||||
|
||||
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans, options.activeState, activeTransitions)
|
||||
|
||||
for (const noteBound of noteBounds) {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
if (target) drawNote(grid, noteBound, target)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function stateDiagramStartMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__start` : "__start"
|
||||
}
|
||||
|
||||
export function stateDiagramEndMarkerId(scope?: string): string {
|
||||
return scope ? `${scope}.__end` : "__end"
|
||||
}
|
||||
|
||||
export function stateDiagramMarkerId(position: "from" | "to", scope?: string): string {
|
||||
return position === "from" ? stateDiagramStartMarkerId(scope) : stateDiagramEndMarkerId(scope)
|
||||
}
|
||||
|
||||
export function normalizeStateDiagramEndpoint(value: string, position: "from" | "to", scope?: string): string {
|
||||
return value === "[*]" ? stateDiagramMarkerId(position, scope) : value
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { renderStateDiagram, renderStateDiagramAnsi } from "./diagram.js"
|
||||
import type { StateDiagramAnsiOptions, StateDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export type {
|
||||
StateDiagram as Diagram,
|
||||
StateDiagramActiveTransition as ActiveTransition,
|
||||
StateDiagramActiveTransitionSelection as ActiveTransitionSelection,
|
||||
StateDiagramAnsiOptions as AnsiRenderOptions,
|
||||
StateDiagramAnsiTheme as Theme,
|
||||
StateDiagramArrowHeadStyle as ArrowHeadStyle,
|
||||
StateDiagramCompositeState as CompositeState,
|
||||
StateDiagramDirection as Direction,
|
||||
StateDiagramNote as Note,
|
||||
StateDiagramOptions as RenderableOptions,
|
||||
StateDiagramRenderOptions as PlainRenderOptions,
|
||||
StateDiagramState as State,
|
||||
StateDiagramStateColors as StateColors,
|
||||
StateDiagramTransition as Transition,
|
||||
} from "./types.js"
|
||||
export { isMermaidStateDiagram as is, parseMermaidStateDiagram as parse } from "./parser.js"
|
||||
export { StateDiagramRenderable as Renderable } from "./renderable.js"
|
||||
export { stateDiagramStateColorKey as stateColorKey } from "./style.js"
|
||||
|
||||
export interface RenderOptions extends StateDiagramAnsiOptions {
|
||||
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
|
||||
color?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Mermaid state diagram string for the terminal.
|
||||
*
|
||||
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
|
||||
*/
|
||||
export function render(content: string, options: RenderOptions = {}): string {
|
||||
const { color = true, ...rest } = options
|
||||
return color ? renderStateDiagramAnsi(content, rest) : renderStateDiagram(content, rest as StateDiagramRenderOptions)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StateDiagram } from "./types.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
|
||||
describe("StateDiagramLayout", () => {
|
||||
test("lays out horizontal main-path states before branch states", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "main" },
|
||||
{ from: "A", to: "C", label: "branch" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
|
||||
expect(a.left).toBeLessThan(b.left)
|
||||
expect(c.top).toBeGreaterThan(a.top)
|
||||
})
|
||||
|
||||
test("aligns a reconverging side branch under the parallel main-path stage", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "Fork", to: "Upper", label: "upper" },
|
||||
{ from: "Fork", to: "Lower", label: "lower" },
|
||||
{ from: "Upper", to: "Join", label: "join" },
|
||||
{ from: "Lower", to: "Join", label: "join" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const upper = layout.bounds.get("Upper")!
|
||||
const lower = layout.bounds.get("Lower")!
|
||||
|
||||
expect(lower.centerX).toBe(upper.centerX)
|
||||
expect(lower.top).toBeGreaterThan(upper.top)
|
||||
})
|
||||
|
||||
test("places note bounds outside their target state", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
],
|
||||
transitions: [{ from: "A", to: "B", label: "next" }],
|
||||
composites: [],
|
||||
notes: [{ target: "A", position: "right", lines: ["note"] }],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const target = layout.bounds.get("A")!
|
||||
const note = layout.noteBounds[0]!
|
||||
|
||||
expect(note.left).toBeGreaterThanOrEqual(target.left + target.width)
|
||||
expect(note.lines).toEqual(["note"])
|
||||
})
|
||||
|
||||
test("widens only the horizontal gap that carries a long label", () => {
|
||||
const diagram: StateDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "a transition label requiring substantially more room" },
|
||||
{ from: "B", to: "C", label: "ok" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
const longGap = b.left - (a.left + a.width)
|
||||
const shortGap = c.left - (b.left + b.width)
|
||||
|
||||
expect(longGap).toBeGreaterThan(shortGap)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,656 @@
|
||||
import { translateDiagramBounds } from "../core/geometry.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import {
|
||||
hasReverseTransition,
|
||||
isStateHorizontalFeedback,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import type {
|
||||
StateDiagram,
|
||||
StateDiagramCompositeState,
|
||||
StateDiagramNote,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
export interface StateDiagramBoxBounds {
|
||||
id: string
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface StateDiagramLayout {
|
||||
bounds: Map<string, StateDiagramBoxBounds>
|
||||
sizes: Map<string, { width: number; height: number; lines: string[] }>
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>
|
||||
noteBounds: StateDiagramNoteBounds[]
|
||||
}
|
||||
|
||||
export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
|
||||
note: StateDiagramNote
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface StateDiagramLayoutOptions {
|
||||
minStateGap: number
|
||||
}
|
||||
|
||||
function visualLength(value: string): number {
|
||||
return diagramTextWidth(value)
|
||||
}
|
||||
|
||||
function splitStateDiagramLines(value: string): string[] {
|
||||
return splitDiagramLines(value)
|
||||
}
|
||||
|
||||
function computeRanks(diagram: StateDiagram): Map<string, number> {
|
||||
const ranks = new Map<string, number>()
|
||||
const outgoing = new Map<string, string[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const list = outgoing.get(transition.from) ?? []
|
||||
list.push(transition.to)
|
||||
outgoing.set(transition.from, list)
|
||||
}
|
||||
|
||||
const first = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
||||
if (!first) return ranks
|
||||
ranks.set(first, 0)
|
||||
const queue = [first]
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!
|
||||
const rank = ranks.get(id) ?? 0
|
||||
for (const to of outgoing.get(id) ?? []) {
|
||||
const nextRank = rank + 1
|
||||
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
||||
ranks.set(to, nextRank)
|
||||
queue.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
if (!ranks.has(state.id)) ranks.set(state.id, ranks.size)
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTransition[]> {
|
||||
const outgoing = new Map<string, StateDiagramTransition[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const list = outgoing.get(transition.from) ?? []
|
||||
list.push(transition)
|
||||
outgoing.set(transition.from, list)
|
||||
}
|
||||
return outgoing
|
||||
}
|
||||
|
||||
function reaches(diagram: StateDiagram, from: string, target: string): boolean {
|
||||
const outgoing = outgoingTransitions(diagram)
|
||||
const visited = new Set<string>()
|
||||
const stack = [from]
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!
|
||||
if (id === target) return true
|
||||
if (visited.has(id)) continue
|
||||
visited.add(id)
|
||||
for (const transition of outgoing.get(id) ?? []) stack.push(transition.to)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function computeMainPath(diagram: StateDiagram): string[] {
|
||||
const outgoing = outgoingTransitions(diagram)
|
||||
const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
||||
if (!start) return []
|
||||
|
||||
const path = [start]
|
||||
const visited = new Set(path)
|
||||
let current = start
|
||||
while (true) {
|
||||
const candidates = (outgoing.get(current) ?? []).filter((transition) => !visited.has(transition.to))
|
||||
if (candidates.length === 0) break
|
||||
const next =
|
||||
candidates.find((transition) => diagram.states.find((state) => state.id === transition.to)?.kind === "end") ??
|
||||
candidates.find((transition) => !reaches(diagram, transition.to, current)) ??
|
||||
candidates.find((transition) => !hasReverseTransition(diagram, transition))
|
||||
if (!next) break
|
||||
path.push(next.to)
|
||||
visited.add(next.to)
|
||||
current = next.to
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function stateSize(state: StateDiagramState): { width: number; height: number; lines: string[] } {
|
||||
if (state.kind !== "state") return { width: 1, height: 1, lines: [state.label] }
|
||||
return measureDiagramTextBox(state.label, { paddingX: 2, paddingY: 1 })
|
||||
}
|
||||
|
||||
function noteLines(note: StateDiagramNote): string[] {
|
||||
const lines = note.lines.flatMap(splitStateDiagramLines).map((line) => line.trim())
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function noteSize(note: StateDiagramNote): { width: number; height: number; lines: string[] } {
|
||||
const lines = noteLines(note)
|
||||
const innerWidth = Math.max(...lines.map(diagramTextWidth), 1)
|
||||
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
||||
}
|
||||
|
||||
function emptyLayout(
|
||||
bounds: Map<string, StateDiagramBoxBounds>,
|
||||
sizes: Map<string, { width: number; height: number; lines: string[] }>,
|
||||
): StateDiagramLayout {
|
||||
return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
|
||||
}
|
||||
|
||||
function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
|
||||
for (const bound of bounds) {
|
||||
translateDiagramBounds(bound, dx, dy)
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
|
||||
return [...new Set(bounds.flatMap((group) => [...group]))]
|
||||
}
|
||||
|
||||
function normalizeLayout(layout: StateDiagramLayout): void {
|
||||
const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
|
||||
if (allBounds.length === 0) return
|
||||
const minX = Math.min(0, ...allBounds.map((bound) => bound.left))
|
||||
const minY = Math.min(0, ...allBounds.map((bound) => bound.top))
|
||||
if (minX === 0 && minY === 0) return
|
||||
shiftBounds(allBounds, -minX, -minY)
|
||||
}
|
||||
|
||||
function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesByParent = new Map<string, string[]>()
|
||||
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
|
||||
for (const state of diagram.states) {
|
||||
if (!state.parentId) continue
|
||||
const states = statesByParent.get(state.parentId) ?? []
|
||||
states.push(state.id)
|
||||
statesByParent.set(state.parentId, states)
|
||||
}
|
||||
for (const composite of diagram.composites) {
|
||||
if (!composite.parentId) continue
|
||||
const composites = compositesByParent.get(composite.parentId) ?? []
|
||||
composites.push(composite)
|
||||
compositesByParent.set(composite.parentId, composites)
|
||||
}
|
||||
|
||||
const addComposite = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
|
||||
const existing = layout.compositeBounds.get(composite.id)
|
||||
if (existing) return existing
|
||||
|
||||
for (const child of compositesByParent.get(composite.id) ?? []) addComposite(child)
|
||||
|
||||
const childBounds = [
|
||||
...(statesByParent.get(composite.id) ?? []),
|
||||
...(compositesByParent.get(composite.id) ?? []).map((child) => child.id),
|
||||
]
|
||||
.map((id) => layout.bounds.get(id))
|
||||
.filter((bound): bound is StateDiagramBoxBounds => Boolean(bound))
|
||||
if (childBounds.length === 0) return undefined
|
||||
|
||||
const left = Math.min(...childBounds.map((bound) => bound.left)) - 2
|
||||
const top = Math.min(...childBounds.map((bound) => bound.top)) - 2
|
||||
const right = Math.max(...childBounds.map((bound) => bound.left + bound.width)) + 2
|
||||
const bottom = Math.max(...childBounds.map((bound) => bound.top + bound.height)) + 2
|
||||
const width = Math.max(right - left, visualLength(composite.label) + 5)
|
||||
const bound = {
|
||||
id: composite.id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height: bottom - top,
|
||||
centerX: left + Math.floor(width / 2),
|
||||
centerY: top + Math.floor((bottom - top) / 2),
|
||||
}
|
||||
layout.compositeBounds.set(composite.id, bound)
|
||||
layout.bounds.set(composite.id, bound)
|
||||
return bound
|
||||
}
|
||||
|
||||
for (const composite of diagram.composites) addComposite(composite)
|
||||
}
|
||||
|
||||
function addNoteBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
|
||||
const avoidBounds = [...layout.bounds.values()].filter((bound) => !compositeIds.has(bound.id))
|
||||
const noteBounds: StateDiagramNoteBounds[] = []
|
||||
|
||||
for (const [index, note] of diagram.notes.entries()) {
|
||||
const target = layout.bounds.get(note.target)
|
||||
if (!target) continue
|
||||
const size = noteSize(note)
|
||||
noteBounds.push(placeNote(note, index, target, size, avoidBounds, noteBounds))
|
||||
}
|
||||
|
||||
layout.noteBounds = noteBounds
|
||||
}
|
||||
|
||||
function intersects(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bound: StateDiagramBoxBounds,
|
||||
padding = 1,
|
||||
): boolean {
|
||||
return (
|
||||
left < bound.left + bound.width + padding &&
|
||||
left + width + padding > bound.left &&
|
||||
top < bound.top + bound.height + padding &&
|
||||
top + height + padding > bound.top
|
||||
)
|
||||
}
|
||||
|
||||
function createNoteBound(
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
left: number,
|
||||
top: number,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
): StateDiagramNoteBounds {
|
||||
return {
|
||||
id: `${note.target}-note-${index}`,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
note,
|
||||
lines: size.lines,
|
||||
}
|
||||
}
|
||||
|
||||
function placeNote(
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
target: StateDiagramBoxBounds,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
avoidBounds: readonly StateDiagramBoxBounds[],
|
||||
existingNotes: readonly StateDiagramNoteBounds[],
|
||||
): StateDiagramNoteBounds {
|
||||
const gap = 4
|
||||
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
|
||||
const baseTop = target.centerY - Math.floor(size.height / 2)
|
||||
const topOffsets = [0, -(size.height + 2), target.height + 2, -(size.height * 2 + 4), target.height + size.height + 4]
|
||||
const collides = (left: number, top: number) => {
|
||||
for (const bound of avoidBounds) {
|
||||
if (bound.id !== target.id && intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
for (const bound of existingNotes) {
|
||||
if (intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const offset of topOffsets) {
|
||||
const top = baseTop + offset
|
||||
if (!collides(baseLeft, top)) return createNoteBound(note, index, baseLeft, top, size)
|
||||
}
|
||||
|
||||
const shiftedLeft =
|
||||
note.position === "right"
|
||||
? Math.max(...avoidBounds.map((bound) => bound.left + bound.width), target.left + target.width) + gap
|
||||
: Math.min(...avoidBounds.map((bound) => bound.left), target.left) - size.width - gap
|
||||
return createNoteBound(note, index, shiftedLeft, baseTop + target.height + 1, size)
|
||||
}
|
||||
|
||||
function belongsToComposite(
|
||||
id: string,
|
||||
compositeId: string,
|
||||
statesById: Map<string, StateDiagramState>,
|
||||
compositesById: Map<string, StateDiagramCompositeState>,
|
||||
): boolean {
|
||||
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
||||
|
||||
while (parentId) {
|
||||
if (parentId === compositeId) return true
|
||||
parentId = compositesById.get(parentId)?.parentId
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of [...diagram.composites].reverse()) {
|
||||
const bound = layout.compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
|
||||
const descendantNotes = layout.noteBounds.filter((noteBound) =>
|
||||
belongsToComposite(noteBound.note.target, composite.id, statesById, compositesById),
|
||||
)
|
||||
if (descendantNotes.length === 0) continue
|
||||
|
||||
const childBounds = [bound, ...descendantNotes]
|
||||
const noteTop = Math.min(...childBounds.map((child) => child.top), bound.top)
|
||||
const noteBottom = Math.max(...childBounds.map((child) => child.top + child.height), bound.top + bound.height)
|
||||
const left = Math.min(...childBounds.map((child) => child.left)) - 2
|
||||
const top = noteTop < bound.top ? noteTop - 1 : bound.top
|
||||
const right = Math.max(...childBounds.map((child) => child.left + child.width)) + 2
|
||||
const bottom = noteBottom > bound.top + bound.height ? noteBottom + 1 : bound.top + bound.height
|
||||
|
||||
bound.left = left
|
||||
bound.top = top
|
||||
bound.width = Math.max(right - left, visualLength(composite.label) + 5)
|
||||
bound.height = bottom - top
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
|
||||
return intersects(left.left, left.top, left.width, left.height, right, 0)
|
||||
}
|
||||
|
||||
function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const compositeBound = layout.compositeBounds.get(composite.id)
|
||||
if (!compositeBound) continue
|
||||
|
||||
for (const state of diagram.states) {
|
||||
if (belongsToComposite(state.id, composite.id, statesById, compositesById)) continue
|
||||
const bound = layout.bounds.get(state.id)
|
||||
if (!bound || !boundsIntersect(bound, compositeBound)) continue
|
||||
|
||||
const dx = compositeBound.left + compositeBound.width + 4 - bound.left
|
||||
if (dx <= 0) continue
|
||||
const leftThreshold = bound.left
|
||||
const boundsToShift: StateDiagramBoxBounds[] = []
|
||||
|
||||
for (const candidate of diagram.states) {
|
||||
if (belongsToComposite(candidate.id, composite.id, statesById, compositesById)) continue
|
||||
const candidateBound = layout.bounds.get(candidate.id)
|
||||
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
||||
}
|
||||
|
||||
for (const candidate of diagram.composites) {
|
||||
if (candidate.id === composite.id || belongsToComposite(candidate.id, composite.id, statesById, compositesById))
|
||||
continue
|
||||
const candidateBound = layout.compositeBounds.get(candidate.id)
|
||||
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
||||
}
|
||||
|
||||
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeLayout(diagram: StateDiagram, layout: StateDiagramLayout): StateDiagramLayout {
|
||||
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
|
||||
addCompositeBounds(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
addNoteBounds(diagram, layout)
|
||||
expandCompositeBoundsForNotes(diagram, layout)
|
||||
separateExternalBoundsFromComposites(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
export function createStateDiagramLayout(
|
||||
diagram: StateDiagram,
|
||||
options: StateDiagramLayoutOptions,
|
||||
): StateDiagramLayout {
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
||||
return finalizeLayout(diagram, createHorizontalLayout(diagram, options))
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
const byRank = new Map<number, StateDiagramState[]>()
|
||||
for (const state of diagram.states) {
|
||||
const rank = ranks.get(state.id) ?? 0
|
||||
const list = byRank.get(rank) ?? []
|
||||
list.push(state)
|
||||
byRank.set(rank, list)
|
||||
}
|
||||
|
||||
const rankKeys = [...byRank.keys()].sort((a, b) => a - b)
|
||||
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
||||
const bounds = new Map<string, StateDiagramBoxBounds>()
|
||||
const outgoingLabelRows = new Map<string, number>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const rows = measureStateTransitionLabel(transition.label).height
|
||||
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
|
||||
}
|
||||
|
||||
const singleColumnCenter = Math.max(
|
||||
0,
|
||||
...rankKeys.flatMap((rank) => {
|
||||
const states = byRank.get(rank)!
|
||||
return states.length === 1 ? [Math.floor(sizes.get(states[0]!.id)!.width / 2)] : []
|
||||
}),
|
||||
)
|
||||
let y = 0
|
||||
for (const rank of rankKeys) {
|
||||
const states = byRank.get(rank)!
|
||||
const rowHeight = Math.max(...states.map((state) => sizes.get(state.id)!.height))
|
||||
let x = 0
|
||||
for (const state of states) {
|
||||
const size = sizes.get(state.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
const left = states.length === 1 ? singleColumnCenter - Math.floor(size.width / 2) : x
|
||||
bounds.set(state.id, {
|
||||
id: state.id,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + options.minStateGap + 8
|
||||
}
|
||||
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
|
||||
y += rowHeight + Math.max(4, labelRows + 3)
|
||||
}
|
||||
|
||||
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
|
||||
}
|
||||
|
||||
function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
|
||||
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
||||
const bounds = new Map<string, StateDiagramBoxBounds>()
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const mainPath = computeMainPath(diagram)
|
||||
const mainIds = new Set(mainPath)
|
||||
const mainPathIndex = new Map(mainPath.map((id, index) => [id, index]))
|
||||
const baselineY = Math.max(
|
||||
1,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height),
|
||||
)
|
||||
const defaultGap = options.minStateGap + 8
|
||||
let x = 0
|
||||
|
||||
for (const [index, id] of mainPath.entries()) {
|
||||
const state = statesById.get(id)
|
||||
const size = sizes.get(id)
|
||||
if (!state || !size) continue
|
||||
const top = state.kind === "state" ? baselineY - Math.floor(size.height / 2) : baselineY
|
||||
bounds.set(id, {
|
||||
id,
|
||||
left: x,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
const nextId = mainPath[index + 1]
|
||||
const adjacentLabelWidth = diagram.transitions
|
||||
.filter((transition) => transition.from === id && transition.to === nextId)
|
||||
.reduce((width, transition) => Math.max(width, measureStateTransitionLabel(transition.label).width), 0)
|
||||
x += size.width + Math.max(defaultGap, adjacentLabelWidth + 2)
|
||||
}
|
||||
|
||||
const branchesByParent = new Map<string, string[]>()
|
||||
for (const transition of diagram.transitions) {
|
||||
if (!mainIds.has(transition.from) || mainIds.has(transition.to)) continue
|
||||
const list = branchesByParent.get(transition.from) ?? []
|
||||
if (!list.includes(transition.to)) list.push(transition.to)
|
||||
branchesByParent.set(transition.from, list)
|
||||
}
|
||||
|
||||
for (const [parentId, branchIds] of branchesByParent) {
|
||||
const parent = bounds.get(parentId)
|
||||
if (!parent) continue
|
||||
const branchGap = defaultGap
|
||||
const branchSizes = branchIds.map((id) => sizes.get(id)!).filter(Boolean)
|
||||
const totalWidth =
|
||||
branchSizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, branchSizes.length - 1) * branchGap
|
||||
const parentIndex = mainPathIndex.get(parentId)
|
||||
const joinIds = branchIds.map(
|
||||
(id) =>
|
||||
diagram.transitions.find(
|
||||
(transition) =>
|
||||
transition.from === id &&
|
||||
mainIds.has(transition.to) &&
|
||||
parentIndex !== undefined &&
|
||||
(mainPathIndex.get(transition.to) ?? -1) > parentIndex + 1,
|
||||
)?.to,
|
||||
)
|
||||
const commonJoin = joinIds[0] && joinIds.every((id) => id === joinIds[0]) ? joinIds[0] : undefined
|
||||
const parallelLane = commonJoin && parentIndex !== undefined ? bounds.get(mainPath[parentIndex + 1]!) : undefined
|
||||
let left = (parallelLane?.centerX ?? parent.centerX) - Math.floor(totalWidth / 2)
|
||||
for (const branchId of branchIds) {
|
||||
if (bounds.has(branchId)) continue
|
||||
const size = sizes.get(branchId)
|
||||
if (!size) continue
|
||||
const top = baselineY + (parallelLane ? 6 : 5)
|
||||
bounds.set(branchId, {
|
||||
id: branchId,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
left += size.width + branchGap
|
||||
}
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
const fallbackStates = diagram.states.filter((state) => !bounds.has(state.id))
|
||||
for (const state of fallbackStates) {
|
||||
const size = sizes.get(state.id)!
|
||||
const rank = ranks.get(state.id) ?? bounds.size
|
||||
const top = baselineY + 5
|
||||
const left = rank * (size.width + defaultGap)
|
||||
bounds.set(state.id, {
|
||||
id: state.id,
|
||||
left,
|
||||
top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
centerX: left + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
}
|
||||
|
||||
const minX = Math.min(0, ...[...bounds.values()].map((bound) => bound.left))
|
||||
if (minX < 0) {
|
||||
for (const bound of bounds.values()) {
|
||||
bound.left -= minX
|
||||
bound.centerX -= minX
|
||||
}
|
||||
}
|
||||
|
||||
if (diagram.direction === "RL") {
|
||||
const right = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width))
|
||||
for (const bound of bounds.values()) {
|
||||
bound.left = right - bound.left - bound.width
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
}
|
||||
|
||||
const branchLabelGutter = Math.max(
|
||||
0,
|
||||
...diagram.transitions.flatMap((transition) => {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
return from && to && from.centerY !== to.centerY
|
||||
? [measureStateTransitionLabel(transition.label).width + 2]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
if (branchLabelGutter > 0) {
|
||||
shiftBounds(bounds.values(), branchLabelGutter, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyLayout(bounds, sizes)
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForFeedback(
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, StateDiagramBoxBounds>,
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
): void {
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return
|
||||
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const compositeBound = compositeBounds.get(composite.id)
|
||||
if (!compositeBound) continue
|
||||
|
||||
const hasInternalFeedback = diagram.transitions.some((transition) => {
|
||||
if (!belongsToComposite(transition.from, composite.id, statesById, compositesById)) return false
|
||||
if (!belongsToComposite(transition.to, composite.id, statesById, compositesById)) return false
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
return Boolean(from && to && isStateHorizontalFeedback(diagram, from, to))
|
||||
})
|
||||
if (!hasInternalFeedback) continue
|
||||
|
||||
const bottom = Math.max(compositeBound.top + compositeBound.height, feedbackLaneY + 2)
|
||||
compositeBound.height = bottom - compositeBound.top
|
||||
compositeBound.centerY = compositeBound.top + Math.floor(compositeBound.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForInternalTransitions(
|
||||
diagram: StateDiagram,
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
||||
transitionPlans: readonly StateTransitionRenderPlan[],
|
||||
): void {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
const internalPlans = transitionPlans.filter(
|
||||
(plan) =>
|
||||
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
|
||||
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
|
||||
)
|
||||
const occupiedYs = internalPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y),
|
||||
...(plan.label ? plan.label.lines.map((_, index) => plan.label!.y + index) : []),
|
||||
])
|
||||
if (occupiedYs.length === 0) continue
|
||||
|
||||
const top = Math.min(bound.top, Math.min(...occupiedYs) - 1)
|
||||
const bottom = Math.max(bound.top + bound.height, Math.max(...occupiedYs) + 2)
|
||||
bound.top = top
|
||||
bound.height = bottom - top
|
||||
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
import type { StateDiagramArrowHeadStyle } from "./types.js"
|
||||
|
||||
export const DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP = 5
|
||||
export const DEFAULT_STATE_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
export const DEFAULT_STATE_ARROW_HEAD_STYLE = "filled" satisfies StateDiagramArrowHeadStyle
|
||||
|
||||
export function normalizeStateMinStateGap(value: number | undefined): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP
|
||||
return Math.max(1, Math.trunc(value))
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
|
||||
import { splitDiagramLines } from "../core/text-lines.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { normalizeStateDiagramEndpoint, stateDiagramEndMarkerId, stateDiagramStartMarkerId } from "./endpoint.js"
|
||||
import type {
|
||||
StateDiagram,
|
||||
StateDiagramCompositeState,
|
||||
StateDiagramDirection,
|
||||
StateDiagramNote,
|
||||
StateDiagramState,
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
const DEFAULT_DIRECTION = "LR" satisfies StateDiagramDirection
|
||||
const STATE_RE = /^state\s+"([^"]+)"\s+as\s+(\S+)$/i
|
||||
const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\S+)\s*\{$/i
|
||||
const CHOICE_STATE_RE = /^state\s+(\S+)\s+<<choice>>$/i
|
||||
const TRANSITION_RE = /^(\[\*\]|[^\s:]+)\s*-->\s*(\[\*\]|[^\s:]+)(?:\s*:\s*(.*))?$/
|
||||
const DIRECTION_RE = /^direction\s+(TB|TD|LR|RL)$/i
|
||||
const NOTE_INLINE_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$/i
|
||||
const NOTE_START_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*$/i
|
||||
const NOTE_END_RE = /^end\s+note$/i
|
||||
|
||||
function normalizeDirection(value?: string): StateDiagramDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
function isMermaidHeader(line: string): boolean {
|
||||
return line.toLowerCase() === "statediagram-v2" || line.toLowerCase() === "statediagram"
|
||||
}
|
||||
|
||||
function ensureState(
|
||||
states: Map<string, StateDiagramState>,
|
||||
id: string,
|
||||
label = id,
|
||||
kind: StateDiagramState["kind"] = "state",
|
||||
parentId?: string,
|
||||
): void {
|
||||
const existing = states.get(id)
|
||||
if (existing) {
|
||||
if (existing.label === existing.id && label !== id) existing.label = label
|
||||
if (parentId && !existing.parentId) existing.parentId = parentId
|
||||
if (kind !== "state") {
|
||||
existing.kind = kind
|
||||
existing.label = label
|
||||
}
|
||||
return
|
||||
}
|
||||
states.set(id, parentId ? { id, label, kind, parentId } : { id, label, kind })
|
||||
}
|
||||
|
||||
function resolveCompositeTransitionEndpoint(
|
||||
id: string,
|
||||
markerId: (scope?: string) => string,
|
||||
compositeIds: ReadonlySet<string>,
|
||||
states: Map<string, StateDiagramState>,
|
||||
): string {
|
||||
if (!compositeIds.has(id)) return id
|
||||
const marker = markerId(id)
|
||||
return states.has(marker) ? marker : id
|
||||
}
|
||||
|
||||
function resolveCompositeTransitions(
|
||||
transitions: readonly StateDiagramTransition[],
|
||||
compositeIds: ReadonlySet<string>,
|
||||
states: Map<string, StateDiagramState>,
|
||||
): StateDiagramTransition[] {
|
||||
return transitions.map((transition) => ({
|
||||
from: resolveCompositeTransitionEndpoint(transition.from, stateDiagramEndMarkerId, compositeIds, states),
|
||||
to: resolveCompositeTransitionEndpoint(transition.to, stateDiagramStartMarkerId, compositeIds, states),
|
||||
label: transition.label,
|
||||
}))
|
||||
}
|
||||
|
||||
export function isMermaidStateDiagram(content: string): boolean {
|
||||
return isMermaidHeader(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidStateDiagram(content: string): StateDiagram {
|
||||
const states = new Map<string, StateDiagramState>()
|
||||
const transitions: StateDiagramTransition[] = []
|
||||
const composites: StateDiagramCompositeState[] = []
|
||||
const notes: StateDiagramNote[] = []
|
||||
const parentStack: Array<{ id: string; lineNumber: number; sourceLine: string }> = []
|
||||
let pendingNote:
|
||||
| { target: string; position: "left" | "right"; lines: string[]; lineNumber: number; sourceLine: string }
|
||||
| undefined
|
||||
let direction: StateDiagramDirection = DEFAULT_DIRECTION
|
||||
|
||||
for (const source of numberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (pendingNote) {
|
||||
if (NOTE_END_RE.test(line)) {
|
||||
notes.push({
|
||||
target: pendingNote.target,
|
||||
position: pendingNote.position,
|
||||
lines: pendingNote.lines,
|
||||
})
|
||||
pendingNote = undefined
|
||||
} else if (line || pendingNote.lines.length > 0) {
|
||||
pendingNote.lines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line || line.startsWith("%%") || isMermaidHeader(line)) continue
|
||||
|
||||
if (line === "}") {
|
||||
if (parentStack.length === 0) {
|
||||
throw new MermaidSyntaxError("state", source.lineNumber, line, 'Unexpected "}" without an open composite state')
|
||||
}
|
||||
parentStack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const parentId = parentStack[parentStack.length - 1]?.id
|
||||
|
||||
const directionMatch = line.match(DIRECTION_RE)
|
||||
if (directionMatch) {
|
||||
direction = normalizeDirection(directionMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const inlineNoteMatch = line.match(NOTE_INLINE_RE)
|
||||
if (inlineNoteMatch) {
|
||||
notes.push({
|
||||
position: inlineNoteMatch[1]!.toLowerCase() as "left" | "right",
|
||||
target: inlineNoteMatch[2]!,
|
||||
lines: splitDiagramLines(inlineNoteMatch[3]!.trim()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const noteMatch = line.match(NOTE_START_RE)
|
||||
if (noteMatch) {
|
||||
pendingNote = {
|
||||
position: noteMatch[1]!.toLowerCase() as "left" | "right",
|
||||
target: noteMatch[2]!,
|
||||
lines: [],
|
||||
lineNumber: source.lineNumber,
|
||||
sourceLine: line,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const compositeMatch = line.match(COMPOSITE_STATE_RE)
|
||||
if (compositeMatch) {
|
||||
const id = compositeMatch[2]!
|
||||
composites.push({
|
||||
id,
|
||||
label: compositeMatch[1] ?? id,
|
||||
...(parentId ? { parentId } : {}),
|
||||
})
|
||||
parentStack.push({ id, lineNumber: source.lineNumber, sourceLine: line })
|
||||
continue
|
||||
}
|
||||
|
||||
const stateMatch = line.match(STATE_RE)
|
||||
if (stateMatch) {
|
||||
ensureState(states, stateMatch[2]!, stateMatch[1]!, "state", parentId)
|
||||
continue
|
||||
}
|
||||
|
||||
const choiceMatch = line.match(CHOICE_STATE_RE)
|
||||
if (choiceMatch) {
|
||||
ensureState(states, choiceMatch[1]!, "┼", "choice", parentId)
|
||||
continue
|
||||
}
|
||||
|
||||
const transitionMatch = line.match(TRANSITION_RE)
|
||||
if (transitionMatch) {
|
||||
const rawFrom = transitionMatch[1]!
|
||||
const rawTo = transitionMatch[2]!
|
||||
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
|
||||
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
|
||||
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)
|
||||
ensureState(states, to, rawTo === "[*]" ? "◎" : to, rawTo === "[*]" ? "end" : "state", parentId)
|
||||
transitions.push({ from, to, label: transitionMatch[3]?.trim() ?? "" })
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MermaidSyntaxError("state", source.lineNumber, line)
|
||||
}
|
||||
|
||||
if (pendingNote) {
|
||||
throw new MermaidSyntaxError(
|
||||
"state",
|
||||
pendingNote.lineNumber,
|
||||
pendingNote.sourceLine,
|
||||
'Unclosed note; expected "end note"',
|
||||
)
|
||||
}
|
||||
|
||||
const unclosedComposite = parentStack[parentStack.length - 1]
|
||||
if (unclosedComposite) {
|
||||
throw new MermaidSyntaxError(
|
||||
"state",
|
||||
unclosedComposite.lineNumber,
|
||||
unclosedComposite.sourceLine,
|
||||
'Unclosed composite state; expected "}"',
|
||||
)
|
||||
}
|
||||
|
||||
if (composites.length === 0) {
|
||||
return { direction, states: [...states.values()], transitions, composites, notes }
|
||||
}
|
||||
|
||||
const compositeIds = new Set(composites.map((composite) => composite.id))
|
||||
return {
|
||||
direction,
|
||||
states: [...states.values()].filter((state) => !compositeIds.has(state.id)),
|
||||
transitions: resolveCompositeTransitions(transitions, compositeIds, states),
|
||||
composites,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RGBA, StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas, DiagramCanvasRunOptions } from "../core/canvas.js"
|
||||
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import { resolveStateAnsiTheme, stateStyleBgColor, stateStyleColor, type StateStyleColors } from "./style.js"
|
||||
import type { StateCellStyle, StateDiagramAnsiTheme } from "./types.js"
|
||||
|
||||
export interface StateCellMetadata {
|
||||
stateId?: string
|
||||
bgStateId?: string
|
||||
}
|
||||
|
||||
export type StateGrid = DiagramCanvas<StateCellStyle, StateCellMetadata>
|
||||
|
||||
export function renderStateGridText(grid: StateGrid): string {
|
||||
return grid.toString({ trimBottom: true })
|
||||
}
|
||||
|
||||
export function renderStateGridStyledText(
|
||||
grid: StateGrid,
|
||||
colors: StateStyleColors,
|
||||
stateColors?: ReadonlyMap<string, RGBA>,
|
||||
stateBgColors?: ReadonlyMap<string, RGBA>,
|
||||
): StyledText {
|
||||
const useStateRuns = Boolean(stateColors?.size || stateBgColors?.size)
|
||||
const runOptions: DiagramCanvasRunOptions<StateCellStyle, StateCellMetadata> = useStateRuns
|
||||
? { trimBottom: true, key: (cell) => [cell.style, cell.stateId, cell.bgStateId] }
|
||||
: { trimBottom: true }
|
||||
|
||||
return renderDiagramGridStyledText(
|
||||
grid,
|
||||
(run) => stateStyleColor(run.style, colors, stateColors, useStateRuns ? run.cell.stateId : undefined),
|
||||
(run) => stateStyleBgColor(stateBgColors, useStateRuns ? run.cell.bgStateId : undefined),
|
||||
runOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function renderStateGridAnsi(grid: StateGrid, theme: StateDiagramAnsiTheme = {}): string {
|
||||
const resolved = resolveStateAnsiTheme(theme)
|
||||
return renderDiagramGridAnsi(grid, (run) => (run.style ? resolved[run.style] : undefined), {
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { type BorderStyle, type ColorInput, type RenderContext, type RGBA } from "@opentui/core"
|
||||
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
|
||||
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
|
||||
import { diagramColorMapsEqual, normalizeDiagramColorMap } from "../core/color/map.js"
|
||||
import { activeTransitionListsEqual, normalizeActiveTransitions } from "./active-transition.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { renderStateGridStyledText, type StateGrid } from "./render-grid.js"
|
||||
import { resolveStateStyleColors } from "./style.js"
|
||||
import type {
|
||||
StateDiagramActiveTransition,
|
||||
StateDiagramActiveTransitionSelection,
|
||||
StateDiagramArrowHeadStyle,
|
||||
StateDiagramDirection,
|
||||
StateDiagramOptions,
|
||||
StateDiagramStateColors,
|
||||
StateDiagram,
|
||||
} from "./types.js"
|
||||
|
||||
export class StateDiagramRenderable extends DiagramRenderable<StateDiagram, StateGrid> {
|
||||
private _direction?: StateDiagramDirection
|
||||
private _borderStyle: BorderStyle
|
||||
private _arrowHeadStyle: StateDiagramArrowHeadStyle
|
||||
private _minStateGap: number
|
||||
private _activeState?: string
|
||||
private _activeTransitions: StateDiagramActiveTransition[]
|
||||
private _stateColor?: RGBA
|
||||
private _activeStateColor?: RGBA
|
||||
private _compositeColor?: RGBA
|
||||
private _transitionColor?: RGBA
|
||||
private _activeTransitionColor?: RGBA
|
||||
private _labelColor?: RGBA
|
||||
private _noteBorderColor?: RGBA
|
||||
private _noteTextColor?: RGBA
|
||||
private _noteConnectorColor?: RGBA
|
||||
private _startColor?: RGBA
|
||||
private _endColor?: RGBA
|
||||
private _choiceColor?: RGBA
|
||||
private _stateColors: Map<string, RGBA>
|
||||
private _stateBgColors: Map<string, RGBA>
|
||||
constructor(ctx: RenderContext, options: StateDiagramOptions = {}) {
|
||||
super(ctx, options)
|
||||
this._direction = options.direction
|
||||
this._borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
this._arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
this._minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
this._activeState = options.activeState
|
||||
this._activeTransitions = normalizeActiveTransitions(options.activeTransition)
|
||||
this._stateColor = parseDiagramRenderableColor(options.stateColor)
|
||||
this._activeStateColor = parseDiagramRenderableColor(options.activeStateColor)
|
||||
this._compositeColor = parseDiagramRenderableColor(options.compositeColor)
|
||||
this._transitionColor = parseDiagramRenderableColor(options.transitionColor)
|
||||
this._activeTransitionColor = parseDiagramRenderableColor(options.activeTransitionColor)
|
||||
this._labelColor = parseDiagramRenderableColor(options.labelColor)
|
||||
this._noteBorderColor = parseDiagramRenderableColor(options.noteBorderColor)
|
||||
this._noteTextColor = parseDiagramRenderableColor(options.noteTextColor)
|
||||
this._noteConnectorColor = parseDiagramRenderableColor(options.noteConnectorColor)
|
||||
this._startColor = parseDiagramRenderableColor(options.startColor)
|
||||
this._endColor = parseDiagramRenderableColor(options.endColor)
|
||||
this._choiceColor = parseDiagramRenderableColor(options.choiceColor)
|
||||
this._stateColors = normalizeDiagramColorMap(options.stateColors)
|
||||
this._stateBgColors = normalizeDiagramColorMap(options.stateBgColors)
|
||||
this.initializeDiagram({
|
||||
parse: () => parseMermaidStateDiagram(this.content),
|
||||
draw: (diagram) => this.drawGrid(diagram),
|
||||
publish: (grid) => this.styledText(grid),
|
||||
measure: { trimBottom: true },
|
||||
})
|
||||
}
|
||||
|
||||
get activeState(): string | undefined {
|
||||
return this._activeState
|
||||
}
|
||||
|
||||
set activeState(value: string | undefined) {
|
||||
if (this._activeState === value) return
|
||||
this._activeState = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get direction(): StateDiagramDirection | undefined {
|
||||
return this._direction
|
||||
}
|
||||
|
||||
set direction(value: StateDiagramDirection | undefined) {
|
||||
if (this._direction === value) return
|
||||
this._direction = value
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get borderStyle(): BorderStyle {
|
||||
return this._borderStyle
|
||||
}
|
||||
|
||||
set borderStyle(value: BorderStyle | undefined) {
|
||||
const next = value ?? DEFAULT_STATE_BORDER_STYLE
|
||||
if (this._borderStyle === next) return
|
||||
this._borderStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get minStateGap(): number {
|
||||
return this._minStateGap
|
||||
}
|
||||
|
||||
set minStateGap(value: number | undefined) {
|
||||
const next = normalizeStateMinStateGap(value)
|
||||
if (this._minStateGap === next) return
|
||||
this._minStateGap = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get activeTransition(): StateDiagramActiveTransitionSelection | undefined {
|
||||
if (this._activeTransitions.length === 0) return undefined
|
||||
if (this._activeTransitions.length === 1) return this._activeTransitions[0]
|
||||
return [...this._activeTransitions]
|
||||
}
|
||||
|
||||
set activeTransition(value: StateDiagramActiveTransitionSelection | undefined) {
|
||||
const next = normalizeActiveTransitions(value)
|
||||
if (activeTransitionListsEqual(this._activeTransitions, next)) return
|
||||
this._activeTransitions = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
get arrowHeadStyle(): StateDiagramArrowHeadStyle {
|
||||
return this._arrowHeadStyle
|
||||
}
|
||||
|
||||
set arrowHeadStyle(value: StateDiagramArrowHeadStyle | undefined) {
|
||||
const next = value ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
if (this._arrowHeadStyle === next) return
|
||||
this._arrowHeadStyle = next
|
||||
this.invalidateGrid()
|
||||
}
|
||||
|
||||
set stateColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._stateColor, value, (color) => (this._stateColor = color))
|
||||
}
|
||||
|
||||
set activeStateColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeStateColor, value, (color) => (this._activeStateColor = color))
|
||||
}
|
||||
|
||||
set compositeColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._compositeColor, value, (color) => (this._compositeColor = color))
|
||||
}
|
||||
|
||||
set transitionColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._transitionColor, value, (color) => (this._transitionColor = color))
|
||||
}
|
||||
|
||||
set activeTransitionColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._activeTransitionColor, value, (color) => (this._activeTransitionColor = color))
|
||||
}
|
||||
|
||||
set labelColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._labelColor, value, (color) => (this._labelColor = color))
|
||||
}
|
||||
|
||||
set noteBorderColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteBorderColor, value, (color) => (this._noteBorderColor = color))
|
||||
}
|
||||
|
||||
set noteTextColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteTextColor, value, (color) => (this._noteTextColor = color))
|
||||
}
|
||||
|
||||
set noteConnectorColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._noteConnectorColor, value, (color) => (this._noteConnectorColor = color))
|
||||
}
|
||||
|
||||
set startColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._startColor, value, (color) => (this._startColor = color))
|
||||
}
|
||||
|
||||
set endColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._endColor, value, (color) => (this._endColor = color))
|
||||
}
|
||||
|
||||
set choiceColor(value: ColorInput | undefined) {
|
||||
this.setColor(this._choiceColor, value, (color) => (this._choiceColor = color))
|
||||
}
|
||||
|
||||
set stateColors(value: StateDiagramStateColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._stateColors, next)) return
|
||||
this._stateColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
set stateBgColors(value: StateDiagramStateColors | undefined) {
|
||||
const next = normalizeDiagramColorMap(value)
|
||||
if (diagramColorMapsEqual(this._stateBgColors, next)) return
|
||||
this._stateBgColors = next
|
||||
this.invalidateStyle()
|
||||
}
|
||||
|
||||
private drawGrid(diagram: StateDiagram): StateGrid {
|
||||
return drawStateDiagramGrid(diagram, {
|
||||
direction: this._direction,
|
||||
borderStyle: this._borderStyle,
|
||||
arrowHeadStyle: this._arrowHeadStyle,
|
||||
minStateGap: this._minStateGap,
|
||||
activeState: this._activeState,
|
||||
activeTransition: this._activeTransitions,
|
||||
})
|
||||
}
|
||||
|
||||
private styledText(grid: StateGrid) {
|
||||
return renderStateGridStyledText(
|
||||
grid,
|
||||
resolveStateStyleColors({
|
||||
state: this._stateColor,
|
||||
activeState: this._activeStateColor,
|
||||
composite: this._compositeColor,
|
||||
transition: this._transitionColor,
|
||||
activeTransition: this._activeTransitionColor,
|
||||
label: this._labelColor,
|
||||
noteBorder: this._noteBorderColor,
|
||||
noteText: this._noteTextColor,
|
||||
noteConnector: this._noteConnectorColor,
|
||||
start: this._startColor,
|
||||
end: this._endColor,
|
||||
choice: this._choiceColor,
|
||||
}),
|
||||
this._stateColors,
|
||||
this._stateBgColors,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { activeTransitionIndex } from "./active-transition.js"
|
||||
import type { StateDiagramBoxBounds } from "./layout.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import {
|
||||
createStateTransitionJunctionPlans,
|
||||
createStateTransitionRenderPlans,
|
||||
createStateTransitionRoutePlans,
|
||||
} from "./routing.js"
|
||||
import { prepareVisibleStateDiagram, type StateVisibleDiagram } from "./visible-model.js"
|
||||
|
||||
function bounds(id: string, centerX: number, centerY: number): StateDiagramBoxBounds {
|
||||
return { id, left: centerX - 2, top: centerY - 1, width: 5, height: 3, centerX, centerY }
|
||||
}
|
||||
|
||||
describe("createStateTransitionRoutePlans", () => {
|
||||
test("classifies horizontal transition behavior before painting", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "forward" },
|
||||
{ from: "B", to: "C", label: "branch" },
|
||||
{ from: "C", to: "A", label: "reset" },
|
||||
{ from: "B", to: "B", label: "retry" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 10)],
|
||||
])
|
||||
|
||||
const plans = createStateTransitionRoutePlans(diagram, placements, 18)
|
||||
|
||||
expect(plans.map((plan) => [plan.transition.label, plan.kind])).toEqual([
|
||||
["forward", "horizontal-forward"],
|
||||
["branch", "vertical-elbow"],
|
||||
["reset", "bottom-feedback"],
|
||||
["retry", "self"],
|
||||
])
|
||||
expect(plans.find((plan) => plan.kind === "bottom-feedback")).toMatchObject({ railY: 18 })
|
||||
})
|
||||
|
||||
test("retains visible-model source transitions for active composite paths", () => {
|
||||
const visible = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
}`),
|
||||
)
|
||||
const placements = new Map([
|
||||
["__start", bounds("__start", 4, 4)],
|
||||
["Idle", bounds("Idle", 14, 4)],
|
||||
])
|
||||
const entry = createStateTransitionRoutePlans(visible, placements, 12).find(
|
||||
(plan) => plan.transition.from === "__start",
|
||||
)
|
||||
|
||||
expect(entry?.transition.sourceTransitions).toHaveLength(2)
|
||||
expect(activeTransitionIndex(entry!.transition, entry!.transition.sourceTransitions!)).toBe(0)
|
||||
})
|
||||
|
||||
test("classifies vertical self-transitions as loops before directional routing", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "TB",
|
||||
states: [{ id: "Working", label: "Working", kind: "state" }],
|
||||
transitions: [{ from: "Working", to: "Working", label: "retry" }],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
expect(
|
||||
createStateTransitionRoutePlans(diagram, new Map([["Working", bounds("Working", 5, 3)]]), 12)[0],
|
||||
).toMatchObject({
|
||||
kind: "self",
|
||||
})
|
||||
})
|
||||
|
||||
test("allocates separate lanes for parallel transitions", () => {
|
||||
const horizontal: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const vertical = { ...horizontal, direction: "TB" as const }
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 18, 4)],
|
||||
])
|
||||
const verticalPlacements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 4, 14)],
|
||||
])
|
||||
|
||||
expect(createStateTransitionRoutePlans(horizontal, placements, 12).map((plan) => plan.kind)).toEqual([
|
||||
"horizontal-forward",
|
||||
"bottom-parallel",
|
||||
])
|
||||
expect(createStateTransitionRoutePlans(vertical, verticalPlacements, 22).map((plan) => plan.kind)).toEqual([
|
||||
"vertical",
|
||||
"side-parallel",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes interleaving independent feedback transitions on opposite sides", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "advance" },
|
||||
{ from: "B", to: "C", label: "continue" },
|
||||
{ from: "C", to: "D", label: "finish" },
|
||||
{ from: "C", to: "A", label: "reset A" },
|
||||
{ from: "D", to: "B", label: "reset B" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12).filter((plan) =>
|
||||
plan.route.transition.label.startsWith("reset"),
|
||||
)
|
||||
|
||||
expect(plans.map((plan) => plan.route.kind)).toEqual(["bottom-feedback", "top-feedback"])
|
||||
const firstCells = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
|
||||
expect(plans[1]!.path.some(([x, y]) => firstCells.has(`${x}:${y}`))).toBe(false)
|
||||
})
|
||||
|
||||
test("routes nested same-side feedback transitions from inner to outer rails", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "D", to: "A", label: "outer" },
|
||||
{ from: "C", to: "B", label: "inner" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
|
||||
const outer = plans.find((plan) => plan.route.transition.label === "outer")!
|
||||
const inner = plans.find((plan) => plan.route.transition.label === "inner")!
|
||||
const outerCells = new Set(outer.path.map(([x, y]) => `${x}:${y}`))
|
||||
|
||||
expect(outer.route).toMatchObject({ kind: "bottom-feedback", railY: 15 })
|
||||
expect(inner.route).toMatchObject({ kind: "bottom-feedback", railY: 12 })
|
||||
expect(inner.path.some(([x, y]) => outerCells.has(`${x}:${y}`))).toBe(false)
|
||||
})
|
||||
|
||||
test("allocates duplicate feedback transitions without crossing an independent feedback path", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "C", to: "A", label: "ca" },
|
||||
{ from: "D", to: "B", label: "db1" },
|
||||
{ from: "D", to: "B", label: "db2" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
["C", bounds("C", 24, 4)],
|
||||
["D", bounds("D", 34, 4)],
|
||||
])
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
|
||||
const independent = plans.find((plan) => plan.route.transition.label === "ca")!
|
||||
const independentCells = new Set(independent.path.map(([x, y]) => `${x}:${y}`))
|
||||
const duplicates = plans.filter((plan) => plan.route.transition.label.startsWith("db"))
|
||||
|
||||
expect(duplicates.map((plan) => plan.route.kind)).toEqual(["top-feedback", "top-feedback"])
|
||||
expect(duplicates.every((plan) => plan.path.every(([x, y]) => !independentCells.has(`${x}:${y}`)))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStateTransitionRenderPlans", () => {
|
||||
test("prepares concrete cells, labels, and route paths before painting", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [{ from: "A", to: "B", label: "next" }],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 14, 4)],
|
||||
])
|
||||
|
||||
const plan = createStateTransitionRenderPlans(diagram, placements, 18)[0]!
|
||||
|
||||
expect(plan.cells).toEqual([
|
||||
{ x: 6, y: 4, char: "├", fadeDistance: 0 },
|
||||
{ x: 7, y: 4, char: "─", fadeDistance: 1 },
|
||||
{ x: 8, y: 4, char: "─", fadeDistance: 2 },
|
||||
{ x: 9, y: 4, char: "─", fadeDistance: 3 },
|
||||
{ x: 10, y: 4, char: "─", fadeDistance: 4 },
|
||||
{ x: 11, y: 4, arrowDirection: "right" },
|
||||
])
|
||||
expect(plan.label).toEqual({ x: 8, y: 3, lines: ["next"] })
|
||||
expect(plan.path).toEqual([
|
||||
[6, 4],
|
||||
[7, 4],
|
||||
[8, 4],
|
||||
[9, 4],
|
||||
[10, 4],
|
||||
[11, 4],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStateTransitionJunctionPlans", () => {
|
||||
test("prepares choice topology from connected transitions", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "A", label: "A", kind: "state" },
|
||||
{ id: "Decision", label: "Decision", kind: "choice" },
|
||||
{ id: "B", label: "B", kind: "state" },
|
||||
{ id: "C", label: "C", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "A", to: "Decision", label: "" },
|
||||
{ from: "Decision", to: "B", label: "yes" },
|
||||
{ from: "Decision", to: "C", label: "no" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["Decision", { id: "Decision", left: 14, top: 4, width: 1, height: 1, centerX: 14, centerY: 4 }],
|
||||
["B", bounds("B", 24, 4)],
|
||||
["C", bounds("C", 4, 10)],
|
||||
])
|
||||
|
||||
const plan = createStateTransitionJunctionPlans(
|
||||
diagram,
|
||||
placements,
|
||||
createStateTransitionRenderPlans(diagram, placements, 18),
|
||||
)[0]!
|
||||
|
||||
expect(plan.kind).toBe("choice")
|
||||
expect([...plan.connections]).toEqual(["left", "right", "down"])
|
||||
expect(plan.transitions.map((transition) => transition.label)).toEqual(["", "yes", "no"])
|
||||
})
|
||||
|
||||
test("derives a lower choice connection from its routed elbow approach", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: [
|
||||
{ id: "Upper", label: "Upper", kind: "state" },
|
||||
{ id: "Lower", label: "Lower", kind: "state" },
|
||||
{ id: "Decision", label: "Decision", kind: "choice" },
|
||||
{ id: "Done", label: "Done", kind: "state" },
|
||||
],
|
||||
transitions: [
|
||||
{ from: "Upper", to: "Decision", label: "" },
|
||||
{ from: "Lower", to: "Decision", label: "" },
|
||||
{ from: "Decision", to: "Done", label: "" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["Upper", bounds("Upper", 14, 4)],
|
||||
["Lower", bounds("Lower", 14, 11)],
|
||||
["Decision", { ...bounds("Decision", 24, 4), left: 24, top: 4, width: 1, height: 1, centerX: 24, centerY: 4 }],
|
||||
["Done", bounds("Done", 34, 4)],
|
||||
])
|
||||
const renderPlans = createStateTransitionRenderPlans(diagram, placements, 18)
|
||||
|
||||
const plan = createStateTransitionJunctionPlans(diagram, placements, renderPlans)[0]!
|
||||
|
||||
expect([...plan.connections]).toContain("down")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconverging vertical elbows", () => {
|
||||
test("uses separate top connectors for a lower parallel lane", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "Fork", to: "Upper", label: "" },
|
||||
{ from: "Fork", to: "Lower", label: "down" },
|
||||
{ from: "Upper", to: "Join", label: "" },
|
||||
{ from: "Lower", to: "Join", label: "up" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["Fork", bounds("Fork", 4, 4)],
|
||||
["Upper", bounds("Upper", 14, 4)],
|
||||
["Lower", bounds("Lower", 14, 11)],
|
||||
["Join", bounds("Join", 24, 4)],
|
||||
])
|
||||
|
||||
const plans = createStateTransitionRenderPlans(diagram, placements, 18)
|
||||
const entering = plans.find((plan) => plan.route.transition.to === "Lower")!
|
||||
const leaving = plans.find((plan) => plan.route.transition.from === "Lower")!
|
||||
|
||||
expect(entering.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
|
||||
expect(leaving.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
|
||||
expect(entering.path.at(-1)).not.toEqual(leaving.path[0])
|
||||
expect(entering.cells.at(-1)).toMatchObject({ arrowDirection: "down" })
|
||||
expect(entering.cells.at(-2)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "│" })
|
||||
expect(entering.cells.at(-2)!.y).toBe(entering.cells.at(-1)!.y - 1)
|
||||
expect(entering.cells.at(-3)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "╮" })
|
||||
expect(entering.cells.at(-3)!.y).toBe(entering.cells.at(-1)!.y - 2)
|
||||
const enteringHorizontalY = entering.cells.find((cell) => cell.char === "╰")!.y
|
||||
const leavingHorizontalY = leaving.cells.find((cell) => cell.char === "╭")!.y
|
||||
expect(entering.label!.y).toBeLessThan(enteringHorizontalY)
|
||||
expect(leaving.label!.y).toBeLessThan(leavingHorizontalY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,665 @@
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import type { DiagramDirection } from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js"
|
||||
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
||||
import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
|
||||
|
||||
interface StateTransitionRoutePlanBase {
|
||||
transition: StateVisibleTransition
|
||||
from: BoxBounds
|
||||
to: BoxBounds
|
||||
targetIsChoice: boolean
|
||||
}
|
||||
|
||||
export type StateTransitionRoutePlan =
|
||||
| (StateTransitionRoutePlanBase & { kind: "self" })
|
||||
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical" })
|
||||
|
||||
export type StateTransitionPathPoint = readonly [number, number]
|
||||
|
||||
interface StateTransitionRenderCellBase {
|
||||
x: number
|
||||
y: number
|
||||
fadeDistance?: number
|
||||
}
|
||||
|
||||
export type StateTransitionRenderCell = StateTransitionRenderCellBase &
|
||||
({ char: string; arrowDirection?: never } | { char?: never; arrowDirection: DiagramDirection })
|
||||
|
||||
export interface StateTransitionRenderLabel {
|
||||
x: number
|
||||
y: number
|
||||
lines: readonly string[]
|
||||
}
|
||||
|
||||
export interface StateTransitionRenderPlan {
|
||||
route: StateTransitionRoutePlan
|
||||
cells: readonly StateTransitionRenderCell[]
|
||||
path: readonly StateTransitionPathPoint[]
|
||||
label?: StateTransitionRenderLabel
|
||||
}
|
||||
|
||||
export interface StateTransitionJunctionPlan {
|
||||
state: StateDiagramState
|
||||
bounds: BoxBounds
|
||||
connections: ReadonlySet<DiagramDirection>
|
||||
transitions: readonly StateVisibleTransition[]
|
||||
kind: "choice" | "hidden-composite-marker"
|
||||
}
|
||||
|
||||
interface StateTransitionRenderBuilder {
|
||||
route: StateTransitionRoutePlan
|
||||
cells: StateTransitionRenderCell[]
|
||||
path: StateTransitionPathPoint[]
|
||||
label?: StateTransitionRenderLabel
|
||||
}
|
||||
|
||||
export function measureStateTransitionLabel(label: string): { lines: string[]; width: number; height: number } {
|
||||
if (!label) return { lines: [], width: 0, height: 0 }
|
||||
const lines = splitDiagramLines(label)
|
||||
return { lines, width: Math.max(...lines.map(diagramTextWidth)), height: lines.length }
|
||||
}
|
||||
|
||||
export function hasReverseTransition(diagram: StateDiagram, transition: StateDiagramTransition): boolean {
|
||||
return diagram.transitions.some((other) => other.from === transition.to && other.to === transition.from)
|
||||
}
|
||||
|
||||
export function isStateHorizontalFeedback(
|
||||
diagram: Pick<StateDiagram, "direction">,
|
||||
from: BoxBounds,
|
||||
to: BoxBounds,
|
||||
): boolean {
|
||||
if (diagram.direction === "RL") return to.centerX > from.centerX
|
||||
return to.centerX < from.centerX
|
||||
}
|
||||
|
||||
interface FeedbackAllocation {
|
||||
side: "bottom" | "top"
|
||||
railY: number
|
||||
}
|
||||
|
||||
interface AllocatedFeedbackInterval extends FeedbackAllocation {
|
||||
left: number
|
||||
right: number
|
||||
lane: number
|
||||
}
|
||||
|
||||
interface FeedbackInterval {
|
||||
transition: StateVisibleTransition
|
||||
left: number
|
||||
right: number
|
||||
side?: "bottom" | "top"
|
||||
}
|
||||
|
||||
function feedbackIntervalsOverlap(
|
||||
left: { left: number; right: number },
|
||||
right: { left: number; right: number },
|
||||
): boolean {
|
||||
return left.left <= right.right && right.left <= left.right
|
||||
}
|
||||
|
||||
function feedbackIntervalsCross(
|
||||
left: { left: number; right: number },
|
||||
right: { left: number; right: number },
|
||||
): boolean {
|
||||
return (
|
||||
(left.left < right.left && right.left < left.right && left.right < right.right) ||
|
||||
(right.left < left.left && left.left < right.right && right.right < left.right)
|
||||
)
|
||||
}
|
||||
|
||||
function createFeedbackAllocations(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
laneGap: number,
|
||||
feedbackTopY?: number,
|
||||
): ReadonlyMap<StateVisibleTransition, FeedbackAllocation> {
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return new Map()
|
||||
const allocations = new Map<StateVisibleTransition, FeedbackAllocation>()
|
||||
const sidedIntervals: Record<"bottom" | "top", FeedbackInterval[]> = { bottom: [], top: [] }
|
||||
const canonicalSides = new Map<string, "bottom" | "top">()
|
||||
const topLaneY = feedbackTopY ?? Math.min(...[...bounds.values()].map((bound) => bound.top)) - 3
|
||||
const intervals: FeedbackInterval[] = []
|
||||
|
||||
for (const transition of diagram.transitions) {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to || transition.from === transition.to || !isStateHorizontalFeedback(diagram, from, to)) continue
|
||||
if (from.centerY !== to.centerY && !(from.centerY > to.centerY)) continue
|
||||
|
||||
intervals.push({ transition, left: Math.min(from.centerX, to.centerX), right: Math.max(from.centerX, to.centerX) })
|
||||
}
|
||||
|
||||
for (const interval of intervals) {
|
||||
const endpointKey = `${interval.transition.from}\u0000${interval.transition.to}`
|
||||
const side =
|
||||
canonicalSides.get(endpointKey) ??
|
||||
(["bottom", "top"] as const).find(
|
||||
(candidate) => !sidedIntervals[candidate].some((existing) => feedbackIntervalsCross(existing, interval)),
|
||||
)
|
||||
if (!side) continue
|
||||
canonicalSides.set(endpointKey, side)
|
||||
interval.side = side
|
||||
sidedIntervals[side].push(interval)
|
||||
}
|
||||
|
||||
for (const side of ["bottom", "top"] as const) {
|
||||
const occupied: AllocatedFeedbackInterval[] = []
|
||||
const intervalsByWidth = [...sidedIntervals[side]].sort(
|
||||
(left, right) => left.right - left.left - (right.right - right.left),
|
||||
)
|
||||
for (const interval of intervalsByWidth) {
|
||||
let lane = 0
|
||||
while (occupied.some((existing) => existing.lane === lane && feedbackIntervalsOverlap(existing, interval))) lane++
|
||||
const railY = side === "bottom" ? feedbackLaneY + lane * laneGap : topLaneY - lane * laneGap
|
||||
occupied.push({ ...interval, side, lane, railY })
|
||||
allocations.set(interval.transition, { side, railY })
|
||||
}
|
||||
}
|
||||
|
||||
return allocations
|
||||
}
|
||||
|
||||
function hasOpposingTopConnector(
|
||||
diagram: StateVisibleDiagram,
|
||||
transition: StateVisibleTransition,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
): boolean {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to || from.centerY === to.centerY) return false
|
||||
|
||||
const lowerId = from.centerY > to.centerY ? transition.from : transition.to
|
||||
const lower = bounds.get(lowerId)!
|
||||
const leavesLower = transition.from === lowerId
|
||||
return diagram.transitions.some((other) => {
|
||||
if (other === transition || (leavesLower ? other.to !== lowerId : other.from !== lowerId)) return false
|
||||
const otherFrom = bounds.get(other.from)
|
||||
const otherTo = bounds.get(other.to)
|
||||
if (!otherFrom || !otherTo || Math.max(otherFrom.centerY, otherTo.centerY) !== lower.centerY) return false
|
||||
if (other.from !== lowerId) return true
|
||||
return !(
|
||||
(diagram.direction === "LR" || diagram.direction === "RL") &&
|
||||
isStateHorizontalFeedback(diagram, otherFrom, otherTo)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function createStateTransitionRoutePlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
feedbackTopY?: number,
|
||||
): StateTransitionRoutePlan[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const endpointOccurrences = new Map<string, number>()
|
||||
const maxLabelWidth = Math.max(
|
||||
0,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).width),
|
||||
)
|
||||
const parallelLaneGap = Math.max(
|
||||
3,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
|
||||
)
|
||||
const sideLaneX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + maxLabelWidth + 3
|
||||
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
|
||||
|
||||
return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
|
||||
const from = bounds.get(transition.from)
|
||||
const to = bounds.get(transition.to)
|
||||
if (!from || !to) return []
|
||||
|
||||
const targetState = statesById.get(transition.to)
|
||||
const targetIsChoice = targetState?.kind === "choice" || isHiddenCompositeMarker(targetState)
|
||||
const base = { transition, from, to, targetIsChoice }
|
||||
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
|
||||
const endpointKey = `${transition.from}\u0000${transition.to}`
|
||||
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
|
||||
endpointOccurrences.set(endpointKey, parallelIndex + 1)
|
||||
const feedback =
|
||||
(diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
|
||||
const feedbackAllocation = feedbackAllocations.get(transition)
|
||||
if (feedbackAllocation) {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: feedbackAllocation.side === "bottom" ? "bottom-feedback" : "top-feedback",
|
||||
railY: feedbackAllocation.railY,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (parallelIndex > 0) {
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: "bottom-parallel",
|
||||
railY: feedbackLaneY + (parallelIndex - 1) * parallelLaneGap,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ ...base, kind: "side-parallel", railX: sideLaneX + (parallelIndex - 1) * parallelLaneGap }]
|
||||
}
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") return [{ ...base, kind: "vertical" }]
|
||||
|
||||
if (from.centerY !== to.centerY) {
|
||||
if (from.centerY > to.centerY && feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
|
||||
const hasReverse = hasReverseTransition(diagram, transition)
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
kind: "vertical-elbow",
|
||||
hasReverse,
|
||||
offsetConnector: hasReverse || hasOpposingTopConnector(diagram, transition, bounds),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
|
||||
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
|
||||
})
|
||||
}
|
||||
|
||||
function addCell(builder: StateTransitionRenderBuilder, cell: StateTransitionRenderCell): void {
|
||||
builder.cells.push(cell)
|
||||
builder.path.push([cell.x, cell.y])
|
||||
}
|
||||
|
||||
function addPathPoint(builder: StateTransitionRenderBuilder, x: number, y: number): void {
|
||||
builder.path.push([x, y])
|
||||
}
|
||||
|
||||
function addLabel(builder: StateTransitionRenderBuilder, x: number, y: number, label: string): void {
|
||||
const metrics = measureStateTransitionLabel(label)
|
||||
if (metrics.lines.length > 0) builder.label = { x, y, lines: metrics.lines }
|
||||
}
|
||||
|
||||
function addHorizontalFadeRamp(
|
||||
builder: StateTransitionRenderBuilder,
|
||||
fromX: number,
|
||||
toX: number,
|
||||
y: number,
|
||||
direction: 1 | -1,
|
||||
startDistance: number,
|
||||
): void {
|
||||
let fadeDistance = startDistance
|
||||
for (let x = fromX; direction === 1 ? x <= toX : x >= toX; x += direction) {
|
||||
addCell(builder, { x, y, char: "─", fadeDistance })
|
||||
fadeDistance += 1
|
||||
}
|
||||
}
|
||||
|
||||
function addVerticalFadeRamp(
|
||||
builder: StateTransitionRenderBuilder,
|
||||
x: number,
|
||||
fromY: number,
|
||||
toY: number,
|
||||
direction: 1 | -1,
|
||||
startDistance: number,
|
||||
): void {
|
||||
let fadeDistance = startDistance
|
||||
for (let y = fromY; direction === 1 ? y <= toY : y >= toY; y += direction) {
|
||||
addCell(builder, { x, y, char: "│", fadeDistance })
|
||||
fadeDistance += 1
|
||||
}
|
||||
}
|
||||
|
||||
function addRightDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, {
|
||||
x: bounds.left + bounds.width - 1,
|
||||
y: bounds.centerY,
|
||||
char: BorderChars.rounded.leftT,
|
||||
fadeDistance: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function addLeftDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, { x: bounds.left, y: bounds.centerY, char: BorderChars.rounded.rightT, fadeDistance: 0 })
|
||||
}
|
||||
|
||||
function addBottomDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, {
|
||||
x,
|
||||
y: bounds.top + bounds.height - 1,
|
||||
char: BorderChars.rounded.topT,
|
||||
fadeDistance: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
addCell(builder, { x, y: bounds.top, char: BorderChars.rounded.bottomT, fadeDistance: 0 })
|
||||
}
|
||||
|
||||
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, leftToRight, transition } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "horizontal-forward" }
|
||||
>
|
||||
const y = from.centerY
|
||||
if (leftToRight) addRightDeparture(builder, from)
|
||||
else addLeftDeparture(builder, from)
|
||||
const step = leftToRight ? 1 : -1
|
||||
const startX = leftToRight ? from.left + from.width : from.left - 1
|
||||
const endX = leftToRight ? to.left - 1 : to.left + to.width
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
addHorizontalFadeRamp(builder, startX, targetIsChoice ? endX : endX - step, y, step, startDistance)
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, y)
|
||||
else addCell(builder, { x: endX, y, arrowDirection: leftToRight ? "right" : "left" })
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
||||
addLabel(builder, labelX, Math.max(0, y - metrics.height), transition.label)
|
||||
}
|
||||
|
||||
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from: bounds, transition } = builder.route
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
|
||||
const bottomY = bounds.top + bounds.height - 1
|
||||
const railY = bottomY + 2
|
||||
const targetX = Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3)))
|
||||
|
||||
addBottomDeparture(builder, bounds, sourceX)
|
||||
addCell(builder, { x: sourceX, y: bottomY + 1, char: "│", fadeDistance: 1 })
|
||||
addCell(builder, { x: sourceX, y: railY, char: "╰" })
|
||||
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
|
||||
addCell(builder, { x: targetX, y: railY, char: "╯" })
|
||||
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
|
||||
if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
|
||||
}
|
||||
|
||||
function outsideBottomY(bounds: BoxBounds): number {
|
||||
return bounds.top + bounds.height
|
||||
}
|
||||
|
||||
function outsideTopY(bounds: BoxBounds): number {
|
||||
return bounds.top - 1
|
||||
}
|
||||
|
||||
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "bottom-feedback" | "bottom-parallel" }
|
||||
>
|
||||
const sourceX = from.centerX
|
||||
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
|
||||
const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
|
||||
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : targetX
|
||||
const sourceBottomY = outsideBottomY(from)
|
||||
const targetBottomY = outsideBottomY(to)
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
|
||||
addBottomDeparture(builder, from, sourceX)
|
||||
addVerticalFadeRamp(builder, sourceX, sourceBottomY, railY - 1, 1, startDistance)
|
||||
addCell(builder, { x: sourceX, y: railY, char: sourceX > railTargetX ? "╯" : "╰" })
|
||||
if (sourceX !== railTargetX) {
|
||||
const horizontalStep = sourceX < railTargetX ? 1 : -1
|
||||
for (let x = sourceX + horizontalStep; x !== railTargetX; x += horizontalStep) {
|
||||
addCell(builder, { x, y: railY, char: "─" })
|
||||
}
|
||||
}
|
||||
addCell(builder, { x: railTargetX, y: railY, char: sourceX > railTargetX ? "╰" : "╯" })
|
||||
for (let y = railY - 1; y > targetBottomY; y--) addCell(builder, { x: railTargetX, y, char: "│" })
|
||||
if (railTargetX !== targetX) {
|
||||
addCell(builder, { x: railTargetX, y: targetBottomY, char: railTargetX < targetX ? "╭" : "╮" })
|
||||
const horizontalStep = railTargetX < targetX ? 1 : -1
|
||||
for (let x = railTargetX + horizontalStep; x !== targetX; x += horizontalStep) {
|
||||
addCell(builder, { x, y: targetBottomY, char: "─" })
|
||||
}
|
||||
}
|
||||
addCell(builder, { x: targetX, y: targetBottomY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "up" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
|
||||
const labelX =
|
||||
metrics.width <= horizontalRoom
|
||||
? Math.min(sourceX, railTargetX) + Math.max(1, Math.floor((Math.abs(sourceX - railTargetX) - metrics.width) / 2))
|
||||
: railTargetX + 2
|
||||
addLabel(builder, labelX, Math.max(0, railY - metrics.height), transition.label)
|
||||
}
|
||||
|
||||
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "top-feedback" }
|
||||
>
|
||||
const sourceX = from.centerX
|
||||
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + to.width - 2 : to.left + 1) : to.centerX
|
||||
const sourceTopY = outsideTopY(from)
|
||||
const targetTopY = outsideTopY(to)
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
|
||||
addTopDeparture(builder, from, sourceX)
|
||||
addVerticalFadeRamp(builder, sourceX, sourceTopY, railY + 1, -1, startDistance)
|
||||
addCell(builder, { x: sourceX, y: railY, char: sourceX > targetX ? "╮" : "╭" })
|
||||
if (sourceX !== targetX) {
|
||||
const horizontalStep = sourceX < targetX ? 1 : -1
|
||||
for (let x = sourceX + horizontalStep; x !== targetX; x += horizontalStep)
|
||||
addCell(builder, { x, y: railY, char: "─" })
|
||||
}
|
||||
addCell(builder, { x: targetX, y: railY, char: sourceX > targetX ? "╭" : "╮" })
|
||||
for (let y = railY + 1; y < targetTopY; y++) addCell(builder, { x: targetX, y, char: "│" })
|
||||
addCell(builder, { x: targetX, y: targetTopY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "down" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
const horizontalRoom = Math.abs(sourceX - targetX) - 2
|
||||
const labelX =
|
||||
metrics.width <= horizontalRoom
|
||||
? Math.min(sourceX, targetX) + Math.max(1, Math.floor((Math.abs(sourceX - targetX) - metrics.width) / 2))
|
||||
: targetX + 2
|
||||
addLabel(builder, labelX, railY - metrics.height, transition.label)
|
||||
}
|
||||
|
||||
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, transition, railX } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "side-parallel" }
|
||||
>
|
||||
const startX = from.left + from.width
|
||||
const endX = to.left + to.width
|
||||
const startY = from.centerY
|
||||
const endY = to.centerY
|
||||
const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
|
||||
addRightDeparture(builder, from)
|
||||
addHorizontalFadeRamp(builder, startX, railX - 1, startY, 1, startDistance)
|
||||
addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
|
||||
for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
|
||||
addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
|
||||
for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
|
||||
addCell(builder, { x: endX, y: endY, ...(targetIsChoice ? { char: "─" } : { arrowDirection: "left" }) })
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) addLabel(builder, railX + 2, Math.min(startY, endY) + 1, transition.label)
|
||||
}
|
||||
|
||||
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
|
||||
if (bounds.width <= 2) return bounds.centerX
|
||||
return Math.max(bounds.left + 1, Math.min(bounds.left + bounds.width - 2, preferredX))
|
||||
}
|
||||
|
||||
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, transition, targetIsChoice, hasReverse, offsetConnector } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "vertical-elbow" }
|
||||
>
|
||||
const topToBottom = from.centerY < to.centerY
|
||||
const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
|
||||
const startX = innerConnectorX(from, from.centerX + offset)
|
||||
const endX = innerConnectorX(to, to.centerX + offset)
|
||||
const startY = topToBottom ? from.top + from.height : from.top - 1
|
||||
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
||||
const verticalStep = topToBottom ? 1 : -1
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
|
||||
if (topToBottom) addBottomDeparture(builder, from, startX)
|
||||
else addTopDeparture(builder, from, startX)
|
||||
const bendY =
|
||||
startX === endX ? endY : topToBottom ? endY - verticalStep * Math.min(2, Math.abs(endY - startY) - 1) : startY
|
||||
const targetApproachLength = Math.abs(endY - bendY)
|
||||
const hasTargetApproach = targetApproachLength > 0
|
||||
if (startY !== bendY) addVerticalFadeRamp(builder, startX, startY, bendY - verticalStep, verticalStep, startDistance)
|
||||
if (startX !== endX) {
|
||||
const horizontalStep = startX < endX ? 1 : -1
|
||||
addCell(builder, {
|
||||
x: startX,
|
||||
y: bendY,
|
||||
char: topToBottom ? (startX < endX ? "╰" : "╯") : startX < endX ? "╭" : "╮",
|
||||
})
|
||||
for (let x = startX + horizontalStep; x !== endX; x += horizontalStep) addCell(builder, { x, y: bendY, char: "─" })
|
||||
if (hasTargetApproach) {
|
||||
addCell(builder, {
|
||||
x: endX,
|
||||
y: bendY,
|
||||
char: topToBottom ? (startX < endX ? "╮" : "╭") : startX < endX ? "╯" : "╰",
|
||||
})
|
||||
for (let distance = 1; distance < targetApproachLength; distance++) {
|
||||
addCell(builder, { x: endX, y: bendY + verticalStep * distance, char: "│" })
|
||||
}
|
||||
}
|
||||
}
|
||||
const targetChar = targetIsChoice ? (hasTargetApproach || startX === endX ? "│" : topToBottom ? "┬" : "┴") : undefined
|
||||
addCell(builder, {
|
||||
x: endX,
|
||||
y: endY,
|
||||
...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
|
||||
})
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (!transition.label) return
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
if (topToBottom) {
|
||||
const leftLabelX = startX - metrics.width - 2
|
||||
const labelX = hasReverse || endX < startX ? (leftLabelX >= 0 ? leftLabelX : startX + 4) : startX + 2
|
||||
addLabel(
|
||||
builder,
|
||||
labelX,
|
||||
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.min(startY + 1, endY),
|
||||
transition.label,
|
||||
)
|
||||
} else {
|
||||
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
||||
addLabel(
|
||||
builder,
|
||||
startX === endX ? startX + 3 : labelX,
|
||||
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.max(0, startY),
|
||||
transition.label,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, transition, targetIsChoice } = builder.route
|
||||
const topToBottom = from.centerY <= to.centerY
|
||||
const x = from.centerX
|
||||
const startY = topToBottom ? from.top + from.height : from.top - 1
|
||||
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
||||
const step = topToBottom ? 1 : -1
|
||||
const startDistance = from.width <= 1 || from.height <= 1 ? 0 : 1
|
||||
|
||||
if (topToBottom) addBottomDeparture(builder, from, x)
|
||||
else addTopDeparture(builder, from, x)
|
||||
if (startY !== endY) addVerticalFadeRamp(builder, x, startY, endY - step, step, startDistance)
|
||||
addCell(builder, {
|
||||
x,
|
||||
y: endY,
|
||||
...(targetIsChoice ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
|
||||
})
|
||||
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
|
||||
}
|
||||
|
||||
function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): StateTransitionRenderPlan {
|
||||
const builder: StateTransitionRenderBuilder = { route, cells: [], path: [] }
|
||||
switch (route.kind) {
|
||||
case "self":
|
||||
addSelfTransition(builder)
|
||||
break
|
||||
case "horizontal-forward":
|
||||
addHorizontalForward(builder)
|
||||
break
|
||||
case "bottom-feedback":
|
||||
case "bottom-parallel":
|
||||
addBottomLaneTransition(builder)
|
||||
break
|
||||
case "top-feedback":
|
||||
addTopFeedbackTransition(builder)
|
||||
break
|
||||
case "vertical-elbow":
|
||||
addVerticalElbowTransition(builder)
|
||||
break
|
||||
case "vertical":
|
||||
addVerticalTransition(builder)
|
||||
break
|
||||
case "side-parallel":
|
||||
addSideParallelTransition(builder)
|
||||
break
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
export function createStateTransitionRenderPlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
feedbackTopY?: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(
|
||||
createStateTransitionRenderPlan,
|
||||
)
|
||||
}
|
||||
|
||||
function connectionDirection(from: StateTransitionPathPoint, to: StateTransitionPathPoint): DiagramDirection {
|
||||
const deltaX = to[0] - from[0]
|
||||
const deltaY = to[1] - from[1]
|
||||
if (Math.abs(deltaX) >= Math.abs(deltaY) && deltaX !== 0) return deltaX > 0 ? "right" : "left"
|
||||
if (deltaY !== 0) return deltaY > 0 ? "down" : "up"
|
||||
return "right"
|
||||
}
|
||||
|
||||
export function createStateTransitionJunctionPlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
renderPlans: readonly StateTransitionRenderPlan[],
|
||||
): StateTransitionJunctionPlan[] {
|
||||
return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
|
||||
const kind =
|
||||
state.kind === "choice" ? "choice" : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
|
||||
if (!kind) return []
|
||||
const stateBounds = bounds.get(state.id)
|
||||
if (!stateBounds) return []
|
||||
|
||||
const connections = new Set<DiagramDirection>()
|
||||
const transitions: StateVisibleTransition[] = []
|
||||
for (const transition of diagram.transitions) {
|
||||
const renderPlan = renderPlans.find((plan) => plan.route.transition === transition)
|
||||
let connected = false
|
||||
if (transition.to === state.id) {
|
||||
const junction = renderPlan?.path.at(-1)
|
||||
const neighbor = renderPlan?.path.at(-2)
|
||||
if (junction && neighbor) connections.add(connectionDirection(junction, neighbor))
|
||||
connected = true
|
||||
}
|
||||
if (transition.from === state.id) {
|
||||
const neighbor = renderPlan?.path[0]
|
||||
if (neighbor) connections.add(connectionDirection([stateBounds.left, stateBounds.top], neighbor))
|
||||
connected = true
|
||||
}
|
||||
if (connected) transitions.push(transition)
|
||||
}
|
||||
|
||||
return [{ state, bounds: stateBounds, connections, transitions, kind }]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { diagramCellColorKey, mappedDiagramColor } from "../core/color/map.js"
|
||||
import {
|
||||
ansiFg,
|
||||
blendColor,
|
||||
createAnsiRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramFadeStep,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
import type {
|
||||
ActiveTransitionFadeStyle,
|
||||
BaseStateCellStyle,
|
||||
FadeSourceStyle,
|
||||
StateCellStyle,
|
||||
TransitionFadeStyle,
|
||||
} from "./types.js"
|
||||
|
||||
export type StateStyleColors = Required<Record<BaseStateCellStyle, RGBA>> &
|
||||
Required<Record<TransitionFadeStyle, RGBA>> &
|
||||
Required<Record<ActiveTransitionFadeStyle, RGBA>>
|
||||
|
||||
interface TransitionFadeInfo {
|
||||
step: DiagramFadeStep
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
state: [228, 239, 232],
|
||||
activeState: [221, 255, 246],
|
||||
composite: [111, 138, 126],
|
||||
transition: [134, 225, 200],
|
||||
activeTransition: [221, 255, 246],
|
||||
label: [134, 225, 200],
|
||||
noteBorder: [141, 169, 155],
|
||||
noteText: [215, 229, 221],
|
||||
noteConnector: [141, 169, 155],
|
||||
start: [134, 225, 200],
|
||||
end: [230, 177, 126],
|
||||
choice: [134, 225, 200],
|
||||
} as const satisfies Record<BaseStateCellStyle, DiagramRgb>
|
||||
const FADE_STEPS = DIAGRAM_FADE_STEPS
|
||||
const FADE_SOURCE_STYLES = [
|
||||
"state",
|
||||
"activeState",
|
||||
"composite",
|
||||
"start",
|
||||
"end",
|
||||
"choice",
|
||||
] as const satisfies readonly FadeSourceStyle[]
|
||||
const TRANSITION_FADE_STYLES = createTransitionFadeStyles()
|
||||
const ACTIVE_TRANSITION_FADE_STYLES = createActiveTransitionFadeStyles()
|
||||
const ACTIVE_TRANSITION_STYLES = new Set<StateCellStyle>([
|
||||
"activeTransition",
|
||||
...Object.values(ACTIVE_TRANSITION_FADE_STYLES).flat(),
|
||||
])
|
||||
const TRANSITION_FADE_STYLES_SET = new Set<StateCellStyle>([
|
||||
...Object.values(TRANSITION_FADE_STYLES).flat(),
|
||||
...Object.values(ACTIVE_TRANSITION_FADE_STYLES).flat(),
|
||||
])
|
||||
const TRANSITION_FADE_INFOS: ReadonlyMap<StateCellStyle, TransitionFadeInfo> = new Map(
|
||||
FADE_SOURCE_STYLES.flatMap((source) =>
|
||||
FADE_STEPS.flatMap(
|
||||
(step): Array<[StateCellStyle, TransitionFadeInfo]> => [
|
||||
[`${source}TransitionFade${step}` as StateCellStyle, { step, active: false }],
|
||||
[`${source}ActiveTransitionFade${step}` as StateCellStyle, { step, active: true }],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
let defaultAnsiTheme: Required<Record<StateCellStyle, string>> | undefined
|
||||
|
||||
function stateDefaultAnsiTheme(): Required<Record<StateCellStyle, string>> {
|
||||
defaultAnsiTheme ??= {
|
||||
state: ansiFg(DEFAULT_THEME_RGB.state),
|
||||
activeState: ansiFg(DEFAULT_THEME_RGB.activeState),
|
||||
composite: ansiFg(DEFAULT_THEME_RGB.composite),
|
||||
transition: ansiFg(DEFAULT_THEME_RGB.transition),
|
||||
activeTransition: ansiFg(DEFAULT_THEME_RGB.activeTransition),
|
||||
label: ansiFg(DEFAULT_THEME_RGB.label),
|
||||
noteBorder: ansiFg(DEFAULT_THEME_RGB.noteBorder),
|
||||
noteText: ansiFg(DEFAULT_THEME_RGB.noteText),
|
||||
noteConnector: ansiFg(DEFAULT_THEME_RGB.noteConnector),
|
||||
start: ansiFg(DEFAULT_THEME_RGB.start),
|
||||
end: ansiFg(DEFAULT_THEME_RGB.end),
|
||||
choice: ansiFg(DEFAULT_THEME_RGB.choice),
|
||||
...createAnsiFadeTheme("state", DEFAULT_THEME_RGB.state, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiFadeTheme("activeState", DEFAULT_THEME_RGB.activeState, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiFadeTheme("composite", DEFAULT_THEME_RGB.composite, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiFadeTheme("start", DEFAULT_THEME_RGB.start, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiFadeTheme("end", DEFAULT_THEME_RGB.end, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiFadeTheme("choice", DEFAULT_THEME_RGB.choice, DEFAULT_THEME_RGB.transition),
|
||||
...createAnsiActiveTransitionFadeTheme("state", DEFAULT_THEME_RGB.state, DEFAULT_THEME_RGB.activeTransition),
|
||||
...createAnsiActiveTransitionFadeTheme(
|
||||
"activeState",
|
||||
DEFAULT_THEME_RGB.activeState,
|
||||
DEFAULT_THEME_RGB.activeTransition,
|
||||
),
|
||||
...createAnsiActiveTransitionFadeTheme(
|
||||
"composite",
|
||||
DEFAULT_THEME_RGB.composite,
|
||||
DEFAULT_THEME_RGB.activeTransition,
|
||||
),
|
||||
...createAnsiActiveTransitionFadeTheme("start", DEFAULT_THEME_RGB.start, DEFAULT_THEME_RGB.activeTransition),
|
||||
...createAnsiActiveTransitionFadeTheme("end", DEFAULT_THEME_RGB.end, DEFAULT_THEME_RGB.activeTransition),
|
||||
...createAnsiActiveTransitionFadeTheme("choice", DEFAULT_THEME_RGB.choice, DEFAULT_THEME_RGB.activeTransition),
|
||||
}
|
||||
return defaultAnsiTheme
|
||||
}
|
||||
|
||||
export function resolveStateAnsiTheme(
|
||||
theme: Partial<Record<StateCellStyle, string>> = {},
|
||||
): Record<StateCellStyle, string> {
|
||||
return { ...stateDefaultAnsiTheme(), ...theme }
|
||||
}
|
||||
|
||||
function createTransitionFadeStyles(): Record<FadeSourceStyle, readonly TransitionFadeStyle[]> {
|
||||
const styles = {} as Record<FadeSourceStyle, readonly TransitionFadeStyle[]>
|
||||
for (const source of FADE_SOURCE_STYLES) {
|
||||
styles[source] = numberedStyleKeys(`${source}TransitionFade`, FADE_STEPS)
|
||||
}
|
||||
return styles
|
||||
}
|
||||
|
||||
function createActiveTransitionFadeStyles(): Record<FadeSourceStyle, readonly ActiveTransitionFadeStyle[]> {
|
||||
const styles = {} as Record<FadeSourceStyle, readonly ActiveTransitionFadeStyle[]>
|
||||
for (const source of FADE_SOURCE_STYLES) {
|
||||
styles[source] = numberedStyleKeys(`${source}ActiveTransitionFade`, FADE_STEPS)
|
||||
}
|
||||
return styles
|
||||
}
|
||||
|
||||
function createAnsiFadeTheme(
|
||||
source: FadeSourceStyle,
|
||||
from: DiagramRgb,
|
||||
to: DiagramRgb,
|
||||
): Record<TransitionFadeStyle, string> {
|
||||
return createAnsiRampTheme(TRANSITION_FADE_STYLES[source], from, to) as Record<TransitionFadeStyle, string>
|
||||
}
|
||||
|
||||
function createAnsiActiveTransitionFadeTheme(
|
||||
source: FadeSourceStyle,
|
||||
from: DiagramRgb,
|
||||
to: DiagramRgb,
|
||||
): Record<ActiveTransitionFadeStyle, string> {
|
||||
return createAnsiRampTheme(ACTIVE_TRANSITION_FADE_STYLES[source], from, to) as Record<
|
||||
ActiveTransitionFadeStyle,
|
||||
string
|
||||
>
|
||||
}
|
||||
|
||||
export function stateDiagramStateColorKey(stateId: string, level: number): string {
|
||||
return diagramCellColorKey(stateId, level)
|
||||
}
|
||||
|
||||
function stateMappedColor(
|
||||
colors: ReadonlyMap<string, RGBA> | undefined,
|
||||
stateId: string | undefined,
|
||||
): RGBA | undefined {
|
||||
return mappedDiagramColor(colors, stateId)
|
||||
}
|
||||
|
||||
function transitionFadeInfo(style: StateCellStyle | undefined): TransitionFadeInfo | undefined {
|
||||
return style ? TRANSITION_FADE_INFOS.get(style) : undefined
|
||||
}
|
||||
|
||||
function assignStateFadeColors<Style extends StateCellStyle>(
|
||||
target: Partial<Record<Style, RGBA>>,
|
||||
styles: readonly Style[],
|
||||
from: RGBA,
|
||||
to: RGBA,
|
||||
): void {
|
||||
for (const [index, style] of styles.entries()) {
|
||||
target[style] = blendColor(from, to, (index + 1) / (styles.length + 1))
|
||||
}
|
||||
}
|
||||
|
||||
export function stateStyleColor(
|
||||
style: StateCellStyle | undefined,
|
||||
colors: StateStyleColors,
|
||||
stateColors?: ReadonlyMap<string, RGBA>,
|
||||
stateId?: string,
|
||||
): RGBA | undefined {
|
||||
const stateColor = stateMappedColor(stateColors, stateId)
|
||||
if (!stateColor) return style ? colors[style] : undefined
|
||||
|
||||
const fadeInfo = transitionFadeInfo(style)
|
||||
if (fadeInfo) {
|
||||
return blendColor(
|
||||
stateColor,
|
||||
fadeInfo.active ? colors.activeTransition : colors.transition,
|
||||
fadeInfo.step / (FADE_STEPS.length + 1),
|
||||
)
|
||||
}
|
||||
return stateColor
|
||||
}
|
||||
|
||||
export function stateStyleBgColor(
|
||||
stateBgColors: ReadonlyMap<string, RGBA> | undefined,
|
||||
stateId: string | undefined,
|
||||
): RGBA | undefined {
|
||||
return stateMappedColor(stateBgColors, stateId)
|
||||
}
|
||||
|
||||
export function resolveStateStyleColors(
|
||||
colors: Partial<Record<StateCellStyle, RGBA | undefined>> = {},
|
||||
): StateStyleColors {
|
||||
const state = colors.state ?? rgba(DEFAULT_THEME_RGB.state)
|
||||
const composite = colors.composite ?? rgba(DEFAULT_THEME_RGB.composite)
|
||||
const transition = colors.transition ?? rgba(DEFAULT_THEME_RGB.transition)
|
||||
const activeTransition = colors.activeTransition ?? rgba(DEFAULT_THEME_RGB.activeTransition)
|
||||
const activeState = colors.activeState ?? rgba(DEFAULT_THEME_RGB.activeState)
|
||||
const start = colors.start ?? rgba(DEFAULT_THEME_RGB.start)
|
||||
const end = colors.end ?? rgba(DEFAULT_THEME_RGB.end)
|
||||
const choice = colors.choice ?? transition
|
||||
const noteBorder = colors.noteBorder ?? rgba(DEFAULT_THEME_RGB.noteBorder)
|
||||
const noteText = colors.noteText ?? rgba(DEFAULT_THEME_RGB.noteText)
|
||||
const noteConnector = colors.noteConnector ?? noteBorder
|
||||
|
||||
const sourceColors = {
|
||||
state,
|
||||
activeState,
|
||||
composite,
|
||||
start,
|
||||
end,
|
||||
choice,
|
||||
} satisfies Record<FadeSourceStyle, RGBA>
|
||||
const styleColors: Partial<Record<StateCellStyle, RGBA>> = {
|
||||
state,
|
||||
activeState,
|
||||
composite,
|
||||
transition,
|
||||
activeTransition,
|
||||
label: colors.label ?? transition,
|
||||
noteBorder,
|
||||
noteText,
|
||||
noteConnector,
|
||||
start,
|
||||
end,
|
||||
choice,
|
||||
}
|
||||
|
||||
for (const source of FADE_SOURCE_STYLES) {
|
||||
assignStateFadeColors(styleColors, TRANSITION_FADE_STYLES[source], sourceColors[source], transition)
|
||||
assignStateFadeColors(styleColors, ACTIVE_TRANSITION_FADE_STYLES[source], sourceColors[source], activeTransition)
|
||||
}
|
||||
return styleColors as StateStyleColors
|
||||
}
|
||||
|
||||
export function isStateActiveTransitionStyle(style: StateCellStyle | undefined): boolean {
|
||||
return style ? ACTIVE_TRANSITION_STYLES.has(style) : false
|
||||
}
|
||||
|
||||
export function isStateTransitionFadeStyle(style: StateCellStyle | undefined): boolean {
|
||||
return style ? TRANSITION_FADE_STYLES_SET.has(style) : false
|
||||
}
|
||||
|
||||
export function stateTransitionFadeStyle(
|
||||
source: FadeSourceStyle,
|
||||
active: boolean,
|
||||
distance: number,
|
||||
fadeFromSource: boolean,
|
||||
): StateCellStyle {
|
||||
if (active) {
|
||||
if (!fadeFromSource) return "activeTransition"
|
||||
if (distance <= 0) return `${source}ActiveTransitionFade1` as ActiveTransitionFadeStyle
|
||||
if (distance >= FADE_STEPS.length) return "activeTransition"
|
||||
return `${source}ActiveTransitionFade${distance + 1}` as ActiveTransitionFadeStyle
|
||||
}
|
||||
if (distance <= 0) return `${source}TransitionFade1` as TransitionFadeStyle
|
||||
if (distance >= FADE_STEPS.length) return "transition"
|
||||
return `${source}TransitionFade${distance + 1}` as TransitionFadeStyle
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
|
||||
import type { DiagramColorMapInput } from "../core/color/map.js"
|
||||
import type { DiagramFadeStep } from "../core/color/style.js"
|
||||
|
||||
export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
|
||||
export type StateDiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface StateDiagramState {
|
||||
id: string
|
||||
label: string
|
||||
kind: "state" | "start" | "end" | "choice"
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface StateDiagramTransition {
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface StateDiagramActiveTransition {
|
||||
from: string
|
||||
to: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface StateDiagramCompositeState {
|
||||
id: string
|
||||
label: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface StateDiagramNote {
|
||||
target: string
|
||||
position: "left" | "right"
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export type StateDiagramActiveTransitionSelection =
|
||||
| StateDiagramActiveTransition
|
||||
| readonly StateDiagramActiveTransition[]
|
||||
export type StateDiagramStateColors = DiagramColorMapInput
|
||||
|
||||
export interface StateDiagram {
|
||||
direction: StateDiagramDirection
|
||||
states: StateDiagramState[]
|
||||
transitions: StateDiagramTransition[]
|
||||
composites: StateDiagramCompositeState[]
|
||||
notes: StateDiagramNote[]
|
||||
}
|
||||
|
||||
export interface StateDiagramRenderOptions {
|
||||
direction?: StateDiagramDirection
|
||||
borderStyle?: BorderStyle
|
||||
arrowHeadStyle?: StateDiagramArrowHeadStyle
|
||||
minStateGap?: number
|
||||
activeState?: string
|
||||
activeTransition?: StateDiagramActiveTransitionSelection
|
||||
}
|
||||
|
||||
export interface StateDiagramAnsiOptions extends StateDiagramRenderOptions {
|
||||
theme?: StateDiagramAnsiTheme
|
||||
}
|
||||
|
||||
export interface StateDiagramOptions extends TextBufferOptions, StateDiagramRenderOptions {
|
||||
content?: string
|
||||
stateColor?: ColorInput
|
||||
activeStateColor?: ColorInput
|
||||
compositeColor?: ColorInput
|
||||
transitionColor?: ColorInput
|
||||
labelColor?: ColorInput
|
||||
noteBorderColor?: ColorInput
|
||||
noteTextColor?: ColorInput
|
||||
noteConnectorColor?: ColorInput
|
||||
startColor?: ColorInput
|
||||
endColor?: ColorInput
|
||||
choiceColor?: ColorInput
|
||||
activeTransitionColor?: ColorInput
|
||||
stateColors?: StateDiagramStateColors
|
||||
stateBgColors?: StateDiagramStateColors
|
||||
}
|
||||
|
||||
export type StateDiagramAnsiTheme = Partial<Record<StateCellStyle, string>>
|
||||
|
||||
export type FadeSourceStyle = "state" | "activeState" | "composite" | "start" | "end" | "choice"
|
||||
export type TransitionFadeStyle = `${FadeSourceStyle}TransitionFade${DiagramFadeStep}`
|
||||
export type ActiveTransitionFadeStyle = `${FadeSourceStyle}ActiveTransitionFade${DiagramFadeStep}`
|
||||
export type BaseStateCellStyle =
|
||||
| "state"
|
||||
| "activeState"
|
||||
| "composite"
|
||||
| "transition"
|
||||
| "activeTransition"
|
||||
| "label"
|
||||
| "noteBorder"
|
||||
| "noteText"
|
||||
| "noteConnector"
|
||||
| "start"
|
||||
| "end"
|
||||
| "choice"
|
||||
export type StateCellStyle = BaseStateCellStyle | TransitionFadeStyle | ActiveTransitionFadeStyle
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
describe("prepareVisibleStateDiagram", () => {
|
||||
test("collapses composite marker transitions while preserving their source path", () => {
|
||||
const parsed = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
[*] --> Authenticated: login
|
||||
state Authenticated {
|
||||
[*] --> Idle
|
||||
Idle --> Editing: open
|
||||
Editing --> [*]: save
|
||||
}
|
||||
Authenticated --> [*]: logout`)
|
||||
|
||||
const visible = prepareVisibleStateDiagram(parsed)
|
||||
const entry = visible.transitions.find((transition) => transition.from === "__start")
|
||||
const exit = visible.transitions.find((transition) => transition.to === "__end")
|
||||
|
||||
expect(visible.states.some((state) => state.id === "Authenticated.__start")).toBe(false)
|
||||
expect(visible.states.some((state) => state.id === "Authenticated.__end")).toBe(false)
|
||||
expect(entry).toMatchObject({ from: "__start", to: "Idle", label: "login" })
|
||||
expect(entry?.sourceTransitions).toEqual([
|
||||
{ from: "__start", to: "Authenticated.__start", label: "login" },
|
||||
{ from: "Authenticated.__start", to: "Idle", label: "" },
|
||||
])
|
||||
expect(exit).toMatchObject({ from: "Editing", to: "__end", label: "save" })
|
||||
expect(exit?.sourceTransitions).toEqual([
|
||||
{ from: "Editing", to: "Authenticated.__end", label: "save" },
|
||||
{ from: "Authenticated.__end", to: "__end", label: "logout" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
||||
|
||||
export interface StateVisibleTransition extends StateDiagramTransition {
|
||||
sourceTransitions?: readonly StateDiagramTransition[]
|
||||
}
|
||||
|
||||
export interface StateVisibleDiagram extends Omit<StateDiagram, "transitions"> {
|
||||
transitions: StateVisibleTransition[]
|
||||
}
|
||||
|
||||
export function isHiddenCompositeMarker(state: StateDiagramState | undefined): boolean {
|
||||
return Boolean(state?.parentId && (state.kind === "start" || state.kind === "end"))
|
||||
}
|
||||
|
||||
function sourceTransitionsOf(transition: StateVisibleTransition): readonly StateDiagramTransition[] {
|
||||
return transition.sourceTransitions ?? [transition]
|
||||
}
|
||||
|
||||
function composeTransitionLabel(incoming: StateDiagramTransition, outgoing: StateDiagramTransition): string {
|
||||
return incoming.label || outgoing.label
|
||||
}
|
||||
|
||||
function collapseHiddenCompositeMarkerTransitionsOnce(
|
||||
transitions: readonly StateVisibleTransition[],
|
||||
statesById: ReadonlyMap<string, StateDiagramState>,
|
||||
): { transitions: StateVisibleTransition[]; changed: boolean } {
|
||||
const hiddenMarkers = new Set(
|
||||
[...statesById.values()].filter((state) => isHiddenCompositeMarker(state)).map((state) => state.id),
|
||||
)
|
||||
if (hiddenMarkers.size === 0) return { transitions: [...transitions], changed: false }
|
||||
|
||||
const skipped = new Set<StateVisibleTransition>()
|
||||
const collapsed: StateVisibleTransition[] = []
|
||||
let changed = false
|
||||
|
||||
for (const markerId of hiddenMarkers) {
|
||||
const incoming = transitions.filter((transition) => transition.to === markerId && transition.from !== markerId)
|
||||
const outgoing = transitions.filter((transition) => transition.from === markerId && transition.to !== markerId)
|
||||
if (incoming.length === 0 || outgoing.length === 0) continue
|
||||
|
||||
changed = true
|
||||
for (const incomingTransition of incoming) {
|
||||
skipped.add(incomingTransition)
|
||||
for (const outgoingTransition of outgoing) {
|
||||
skipped.add(outgoingTransition)
|
||||
collapsed.push({
|
||||
from: incomingTransition.from,
|
||||
to: outgoingTransition.to,
|
||||
label: composeTransitionLabel(incomingTransition, outgoingTransition),
|
||||
sourceTransitions: [...sourceTransitionsOf(incomingTransition), ...sourceTransitionsOf(outgoingTransition)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transitions: [...transitions.filter((transition) => !skipped.has(transition)), ...collapsed],
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
let transitions: StateVisibleTransition[] = diagram.transitions.map((transition) => ({
|
||||
...transition,
|
||||
sourceTransitions: [transition],
|
||||
}))
|
||||
|
||||
while (true) {
|
||||
const result = collapseHiddenCompositeMarkerTransitionsOnce(transitions, statesById)
|
||||
transitions = result.transitions
|
||||
if (!result.changed) return transitions
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareVisibleStateDiagram(diagram: StateDiagram): StateVisibleDiagram {
|
||||
const transitions = collapseHiddenCompositeMarkerTransitions(diagram)
|
||||
const referencedHiddenMarkers = new Set<string>()
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
for (const transition of transitions) {
|
||||
const from = statesById.get(transition.from)
|
||||
const to = statesById.get(transition.to)
|
||||
if (from && isHiddenCompositeMarker(from)) referencedHiddenMarkers.add(from.id)
|
||||
if (to && isHiddenCompositeMarker(to)) referencedHiddenMarkers.add(to.id)
|
||||
}
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
states: diagram.states.filter((state) => !isHiddenCompositeMarker(state) || referencedHiddenMarkers.has(state.id)),
|
||||
transitions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Flowchart, MermaidSyntaxError, Sequence, State, render } from "../index.js"
|
||||
|
||||
describe("parser diagnostics", () => {
|
||||
test("ignores flowchart presentation directives that do not change terminal structure", () => {
|
||||
const diagram = Flowchart.parse(`flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
classDef highlight fill:#fff
|
||||
class A highlight
|
||||
style B fill:#000
|
||||
linkStyle 0 stroke:#fff`)
|
||||
|
||||
expect(diagram.nodes.map((node) => node.id)).toEqual(["A", "B"])
|
||||
expect(diagram.edges).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reports unsupported structural flowchart statements with source location", () => {
|
||||
expect(() =>
|
||||
Flowchart.parse(`flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
A --- B`),
|
||||
).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --- B"')
|
||||
})
|
||||
|
||||
test("exposes structured syntax errors through top-level rendering", () => {
|
||||
try {
|
||||
render(
|
||||
`sequenceDiagram
|
||||
A->>B: request
|
||||
opt retry`,
|
||||
{ color: false },
|
||||
)
|
||||
throw new Error("expected render to reject unsupported syntax")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MermaidSyntaxError)
|
||||
if (!(error instanceof MermaidSyntaxError)) return
|
||||
expect(error.kind).toBe("sequence")
|
||||
expect(error.lineNumber).toBe(3)
|
||||
expect(error.sourceLine).toBe("opt retry")
|
||||
}
|
||||
})
|
||||
|
||||
test("reports unclosed state constructs at their opening line", () => {
|
||||
expect(() =>
|
||||
State.parse(`stateDiagram-v2
|
||||
state Running {
|
||||
[*] --> Ready`),
|
||||
).toThrow('Unclosed composite state; expected "}" in state diagram at line 2: "state Running {"')
|
||||
})
|
||||
|
||||
test("reports unsupported state statements", () => {
|
||||
expect(() => State.parse(`stateDiagram-v2\n hide empty description`)).toThrow(
|
||||
'Unsupported syntax in state diagram at line 2: "hide empty description"',
|
||||
)
|
||||
})
|
||||
|
||||
test("reports malformed sequence block endings", () => {
|
||||
expect(() =>
|
||||
Sequence.parse(`sequenceDiagram
|
||||
end`),
|
||||
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
|
||||
})
|
||||
|
||||
test("does not attach else through an unclosed nested sequence block", () => {
|
||||
expect(() =>
|
||||
Sequence.parse(`sequenceDiagram
|
||||
alt available
|
||||
loop retry
|
||||
else fallback`),
|
||||
).toThrow('Unexpected "else" without an open "alt" block in sequence diagram at line 4: "else fallback"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect } from "bun:test"
|
||||
|
||||
function normalizeDiagram(value: string): string {
|
||||
const lines = value.replace(/\r\n/g, "\n").split("\n")
|
||||
while (lines[0]?.trim() === "") lines.shift()
|
||||
while (lines[lines.length - 1]?.trim() === "") lines.pop()
|
||||
const indentation = lines
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.reduce((min, line) => Math.min(min, line.match(/^\s*/)?.[0].length ?? 0), Number.POSITIVE_INFINITY)
|
||||
const trimBy = Number.isFinite(indentation) ? indentation : 0
|
||||
return lines.map((line) => line.slice(trimBy)).join("\n")
|
||||
}
|
||||
|
||||
export function expectDiagram(value: string): {
|
||||
toEqualDiagram(expected: string): void
|
||||
toContainInOrder(...needles: string[]): void
|
||||
} {
|
||||
return {
|
||||
toEqualDiagram(expected) {
|
||||
expect(value).toBe(normalizeDiagram(expected))
|
||||
},
|
||||
toContainInOrder(...needles) {
|
||||
let offset = -1
|
||||
for (const needle of needles) {
|
||||
const searchSpace = value.slice(offset + 1)
|
||||
expect(searchSpace).toContain(needle)
|
||||
const nextOffset = value.indexOf(needle, offset + 1)
|
||||
offset = nextOffset
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createMermaidMarkdownRenderer } from "../index.js"
|
||||
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({
|
||||
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
|
||||
})
|
||||
let treeSitterClient: TreeSitterClient
|
||||
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const dataPath = join(tmpdir(), "merman-markdown-test-data")
|
||||
await mkdir(dataPath, { recursive: true })
|
||||
treeSitterClient = new TreeSitterClient({ dataPath })
|
||||
await treeSitterClient.initialize()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await treeSitterClient.destroy()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
renderer?.destroy()
|
||||
renderer = undefined
|
||||
})
|
||||
|
||||
async function renderMarkdown(
|
||||
markdown: MarkdownRenderable,
|
||||
renderOnce: () => Promise<void>,
|
||||
timeoutMs = 2_000,
|
||||
): Promise<void> {
|
||||
const hasPendingHighlights = (): boolean => {
|
||||
const children = [...markdown.getChildren()]
|
||||
while (children.length > 0) {
|
||||
const child = children.pop()!
|
||||
if (child instanceof CodeRenderable && child.isHighlighting) return true
|
||||
children.push(...child.getChildren())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
await renderOnce()
|
||||
while (hasPendingHighlights() && Date.now() - startedAt < timeoutMs) {
|
||||
await Bun.sleep(10)
|
||||
await renderOnce()
|
||||
}
|
||||
if (hasPendingHighlights()) throw new Error("Timed out waiting for Markdown highlights")
|
||||
await renderOnce()
|
||||
}
|
||||
|
||||
test("renders a Mermaid flowchart fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).toContain("Done")
|
||||
expect(frame).not.toContain("flowchart LR")
|
||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||
})
|
||||
|
||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-normalized-mermaid",
|
||||
content: `\`\`\`MERMAID title=checkout
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).not.toContain("flowchart LR")
|
||||
})
|
||||
|
||||
test("keeps surrounding Markdown content visible around a Mermaid diagram", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-with-mermaid",
|
||||
content: `Before
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\`
|
||||
|
||||
After`,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Before")
|
||||
expect(frame).toContain("Start")
|
||||
expect(frame).toContain("Done")
|
||||
expect(frame).toContain("After")
|
||||
})
|
||||
|
||||
test("renders an incomplete Mermaid fence as ordinary code", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 10 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-incomplete-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A -->
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
expect(captureCharFrame()).toContain("flowchart LR")
|
||||
})
|
||||
|
||||
test("renders a Mermaid sequence fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-sequence",
|
||||
content: `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
Alice->>Bob: Hello
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Alice")
|
||||
expect(frame).toContain("Bob")
|
||||
expect(frame).not.toContain("sequenceDiagram")
|
||||
})
|
||||
|
||||
test("wraps wide Mermaid diagrams in a horizontal viewport", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 40, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-wide-sequence",
|
||||
content: `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant A as Alpha participant
|
||||
participant B as Beta participant
|
||||
participant C as Gamma participant
|
||||
A->>B: first
|
||||
B->>C: second
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer, {
|
||||
compact: true,
|
||||
colors: { primary: "#ff0000" },
|
||||
}),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||
expect(diagram.scrollX).toBe(0)
|
||||
|
||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
expect(diagram.hasSelection()).toBe(false)
|
||||
})
|
||||
|
||||
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const { renderOnce, captureCharFrame } = testRenderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-state",
|
||||
content: `\`\`\`mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, renderOnce)
|
||||
|
||||
const frame = captureCharFrame()
|
||||
expect(frame).toContain("Idle")
|
||||
expect(frame).not.toContain("stateDiagram-v2")
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/client"
|
||||
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
|
||||
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { CliRenderer, KeyEvent, MarkdownOptions, Renderable } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { Store } from "solid-js/store"
|
||||
|
||||
@@ -406,6 +406,9 @@ export interface Context {
|
||||
readonly data: Data
|
||||
readonly attention: Attention
|
||||
readonly theme: ResolvedTheme
|
||||
readonly markdown: {
|
||||
registerCodeBlockRenderer(language: string, render: NonNullable<MarkdownOptions["renderNode"]>): () => void
|
||||
}
|
||||
readonly keymap: Keymap
|
||||
readonly storage: Storage
|
||||
readonly ui: UI
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { infoStringToFiletype, type MarkdownOptions } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
@@ -26,10 +27,11 @@ export type Dispose = () => Promise<void>
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots", name: string): boolean
|
||||
has(kind: "routes" | "slots" | "markdown", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, slot: Slot): void
|
||||
remove(kind: "routes" | "slots", name: string): void
|
||||
set(kind: "markdown", name: string, render: NonNullable<MarkdownOptions["renderNode"]>): void
|
||||
remove(kind: "routes" | "slots" | "markdown", name: string): void
|
||||
active(): boolean
|
||||
}
|
||||
|
||||
@@ -81,7 +83,7 @@ export function createPluginContext(input: {
|
||||
}
|
||||
// Unregistering after deactivation is a no-op: deactivate already resets
|
||||
// the registration's routes and slots wholesale.
|
||||
const registration = (kind: "routes" | "slots", name: string) => {
|
||||
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -105,6 +107,17 @@ export function createPluginContext(input: {
|
||||
get theme() {
|
||||
return host.themes.currentTokens()
|
||||
},
|
||||
markdown: {
|
||||
registerCodeBlockRenderer(language, render) {
|
||||
const name = infoStringToFiletype(language)
|
||||
if (!name) throw new Error("Markdown code-block language is required")
|
||||
if (input.registry.has("markdown", name)) {
|
||||
throw new Error(`Markdown code-block renderer already registered: ${name}`)
|
||||
}
|
||||
input.registry.set("markdown", name, render)
|
||||
return registration("markdown", name)
|
||||
},
|
||||
},
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: host.keymap.dispatch,
|
||||
|
||||
@@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Plugins from "../feature-plugins/system/plugins"
|
||||
import Storybook from "../feature-plugins/system/storybook"
|
||||
import Merman from "@opencode-ai/merman/plugin"
|
||||
|
||||
export const builtins = [
|
||||
HomeFooter,
|
||||
@@ -16,6 +17,7 @@ export const builtins = [
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Merman,
|
||||
// The storybook is a development tool; keep its route and palette commands out of
|
||||
// normal launches and register it only for OPENCODE_STORY runs.
|
||||
...(process.env.OPENCODE_STORY ? [Storybook] : []),
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -35,7 +46,10 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly slot: <Name extends SlotName>(
|
||||
name: Name,
|
||||
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly markdown: () => MarkdownOptions["renderNode"]
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
@@ -49,6 +63,7 @@ type Registration = {
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, Slot>
|
||||
markdown: Record<string, NonNullable<MarkdownOptions["renderNode"]>>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
|
||||
@@ -57,6 +72,17 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function combineMarkdownRenderers(
|
||||
sources: ReadonlyArray<Readonly<Record<string, NonNullable<MarkdownOptions["renderNode"]>>>>,
|
||||
): MarkdownOptions["renderNode"] {
|
||||
const renderers = new Map<string, NonNullable<MarkdownOptions["renderNode"]>>()
|
||||
for (const source of sources) {
|
||||
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
|
||||
}
|
||||
if (renderers.size === 0) return undefined
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
@@ -67,6 +93,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
states: [] as ReadonlyArray<State>,
|
||||
registrations: {} as Record<string, Registration>,
|
||||
})
|
||||
const markdown = createMemo(() =>
|
||||
combineMarkdownRenderers(
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
registration.active ? [registration.markdown] : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const activate = async (id: string) => {
|
||||
const item = store.registrations[id]
|
||||
@@ -75,6 +108,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
batch(() => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
@@ -85,8 +119,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
owned,
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
|
||||
setStore("registrations", id, kind, name, () => value),
|
||||
set: (
|
||||
kind: "routes" | "slots" | "markdown",
|
||||
name: string,
|
||||
value: Page | Slot | NonNullable<MarkdownOptions["renderNode"]>,
|
||||
) => setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
"registrations",
|
||||
@@ -101,6 +138,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
throw error
|
||||
})
|
||||
if (cleanup) owned.push(async () => cleanup())
|
||||
@@ -129,6 +167,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
if (store.registrations[id]) {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
}
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
@@ -193,7 +232,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
const desired = new Map<string, Desired>()
|
||||
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
for (const plugin of builtins)
|
||||
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
for (const entry of entries) {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
@@ -256,7 +296,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
// generation is a no-op, so spurious watch events cost nothing.
|
||||
const currentIds = Object.keys(store.registrations)
|
||||
const desiredIds = [...desired.keys()]
|
||||
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
|
||||
const structural =
|
||||
currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
|
||||
if (structural) {
|
||||
await Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
@@ -342,7 +383,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
for (const state of states)
|
||||
if (
|
||||
state.status === "failed" &&
|
||||
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
|
||||
!store.states.some(
|
||||
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
|
||||
)
|
||||
)
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
||||
setStore("states", reconcileStore(states))
|
||||
@@ -390,7 +433,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
registered: () =>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({
|
||||
id,
|
||||
source: plugin.source,
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) => {
|
||||
@@ -405,6 +452,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
slotItems.set(render, item)
|
||||
return [item]
|
||||
}),
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
// toggle mid-reload cannot mix registrations across generations.
|
||||
activate: (id) => enqueue(() => activate(id)),
|
||||
@@ -468,6 +516,7 @@ function toRegistration(item: Desired): Registration {
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
markdown: {},
|
||||
cleanups: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
@@ -1739,6 +1740,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
const status = () => props.message.status
|
||||
const cancelled = () => props.message.status === "failed" && props.message.error.type === "aborted"
|
||||
const text = () =>
|
||||
@@ -1779,6 +1781,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
renderNode={plugins.markdown()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -2186,6 +2189,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
@@ -2198,6 +2202,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
renderNode={plugins.markdown()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SyntaxStyle, TextRenderable, type MarkdownOptions, type RenderNodeContext } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { combineMarkdownRenderers } from "../src/plugin/context"
|
||||
|
||||
const code = (language: string) => ({ type: "code" as const, lang: language, text: "content", raw: "" })
|
||||
const context: RenderNodeContext = {
|
||||
syntaxStyle: SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }),
|
||||
conceal: false,
|
||||
concealCode: false,
|
||||
defaultRender: () => null,
|
||||
}
|
||||
|
||||
test("dispatches Markdown code blocks by normalized language", async () => {
|
||||
const { renderer } = await createTestRenderer({ width: 20, height: 4 })
|
||||
const expected = new TextRenderable(renderer, { content: "expected" })
|
||||
const render = (() => expected) satisfies NonNullable<MarkdownOptions["renderNode"]>
|
||||
const combined = combineMarkdownRenderers([{ mermaid: render }])!
|
||||
|
||||
expect(combined(code("mermaid title=example"), context)).toBe(expected)
|
||||
expect(combined(code("typescript"), context)).toBeUndefined()
|
||||
renderer.destroy()
|
||||
})
|
||||
|
||||
test("later Markdown renderer registrations take precedence", async () => {
|
||||
const { renderer } = await createTestRenderer({ width: 20, height: 4 })
|
||||
const first = new TextRenderable(renderer, { content: "first" })
|
||||
const second = new TextRenderable(renderer, { content: "second" })
|
||||
const combined = combineMarkdownRenderers([{ mermaid: () => first }, { mermaid: () => second }])!
|
||||
|
||||
expect(combined(code("mermaid"), context)).toBe(second)
|
||||
expect(combineMarkdownRenderers([])).toBeUndefined()
|
||||
renderer.destroy()
|
||||
})
|
||||
Reference in New Issue
Block a user