@zombie-mermaid/ascii-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +5392 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
- package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
- package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
- package/src/__tests__/ascii-canvas-write.test.ts +132 -0
- package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
- package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
- package/src/__tests__/ascii-cjk-width.test.ts +150 -0
- package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
- package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
- package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
- package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
- package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
- package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
- package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
- package/src/__tests__/ascii-class-padding.test.ts +104 -0
- package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
- package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
- package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
- package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
- package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
- package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
- package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
- package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
- package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
- package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
- package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
- package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
- package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
- package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
- package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
- package/src/__tests__/ascii-edge-styles.test.ts +149 -0
- package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
- package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
- package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
- package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
- package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
- package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
- package/src/__tests__/ascii-er-padding.test.ts +114 -0
- package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
- package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
- package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
- package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
- package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
- package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
- package/src/__tests__/ascii-lane-search.test.ts +207 -0
- package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
- package/src/__tests__/ascii-multiline.test.ts +288 -0
- package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
- package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
- package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
- package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
- package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
- package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
- package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
- package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
- package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
- package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
- package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
- package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
- package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
- package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
- package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
- package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
- package/src/__tests__/ascii-shapes-special.test.ts +344 -0
- package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
- package/src/__tests__/ascii-shapes-state.test.ts +224 -0
- package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
- package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
- package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
- package/src/__tests__/ascii-territory-unit.test.ts +219 -0
- package/src/__tests__/ascii-validate.test.ts +220 -0
- package/src/__tests__/ascii.test.ts +325 -0
- package/src/__tests__/class-arrow-directions.test.ts +505 -0
- package/src/__tests__/draw-lines.test.ts +93 -0
- package/src/__tests__/edge-cell-styles.test.ts +278 -0
- package/src/__tests__/grid-occupancy.test.ts +240 -0
- package/src/__tests__/helpers/ascii-form.ts +142 -0
- package/src/__tests__/helpers/terminal-display-width.ts +74 -0
- package/src/__tests__/pathfinder.test.ts +239 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
- package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
- package/src/__tests__/testdata/ascii/comments.txt +23 -0
- package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
- package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
- package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
- package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
- package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
- package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
- package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
- package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
- package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/ascii/single_node.txt +8 -0
- package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
- package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
- package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
- package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
- package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
- package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
- package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
- package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
- package/src/__tests__/testdata/unicode/comments.txt +23 -0
- package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
- package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
- package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
- package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
- package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
- package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/unicode/single_node.txt +8 -0
- package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
- package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
- package/src/__tests__/xychart-ascii.test.ts +376 -0
- package/src/ansi.ts +490 -0
- package/src/canvas.ts +757 -0
- package/src/class-diagram.ts +2001 -0
- package/src/converter.ts +446 -0
- package/src/coords.ts +58 -0
- package/src/display-width.ts +151 -0
- package/src/draw-arrows.ts +593 -0
- package/src/draw-boxes.ts +267 -0
- package/src/draw-bundles.ts +611 -0
- package/src/draw-lines.ts +174 -0
- package/src/draw-subgraphs.ts +108 -0
- package/src/draw.ts +350 -0
- package/src/edge-bundling.ts +435 -0
- package/src/edge-cell-styles.ts +209 -0
- package/src/edge-routing.ts +1070 -0
- package/src/er-diagram.ts +1488 -0
- package/src/flowchart.ts +94 -0
- package/src/grid-occupancy.ts +234 -0
- package/src/grid.ts +1309 -0
- package/src/hyperlinks.ts +248 -0
- package/src/index.ts +163 -0
- package/src/lane-search.ts +68 -0
- package/src/multiline-utils.ts +82 -0
- package/src/pathfinder.ts +448 -0
- package/src/registry.ts +88 -0
- package/src/sequence.ts +1318 -0
- package/src/shapes/circle.ts +31 -0
- package/src/shapes/corners.ts +273 -0
- package/src/shapes/diamond.ts +31 -0
- package/src/shapes/hexagon.ts +35 -0
- package/src/shapes/index.ts +123 -0
- package/src/shapes/rectangle.ts +199 -0
- package/src/shapes/rounded.ts +31 -0
- package/src/shapes/special.ts +360 -0
- package/src/shapes/stadium.ts +122 -0
- package/src/shapes/state.ts +204 -0
- package/src/shapes/types.ts +78 -0
- package/src/territory.ts +136 -0
- package/src/types.ts +454 -0
- package/src/validate.ts +189 -0
- package/src/xychart.ts +1085 -0
package/src/sequence.ts
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — sequence diagrams
|
|
3
|
+
//
|
|
4
|
+
// Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout.
|
|
5
|
+
// Each actor occupies a column with a vertical lifeline; messages are horizontal
|
|
6
|
+
// arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups.
|
|
7
|
+
//
|
|
8
|
+
// Layout is fundamentally different from flowcharts — no grid or A* pathfinding.
|
|
9
|
+
// Instead: actors → columns, messages → rows, all positioned linearly.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
13
|
+
import type { Block } from '@zombie-mermaid/mermaid-parser'
|
|
14
|
+
import type { AsciiConfig, CharRole, AsciiTheme, ColorMode } from './types.ts'
|
|
15
|
+
import {
|
|
16
|
+
mkCanvas,
|
|
17
|
+
mkRoleCanvas,
|
|
18
|
+
canvasToString,
|
|
19
|
+
getCanvasSize,
|
|
20
|
+
increaseSize,
|
|
21
|
+
increaseRoleCanvasSize,
|
|
22
|
+
write,
|
|
23
|
+
} from './canvas.ts'
|
|
24
|
+
import { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts'
|
|
25
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
26
|
+
import {
|
|
27
|
+
displayWidth,
|
|
28
|
+
toDisplayCells,
|
|
29
|
+
WIDE_CHAR_PLACEHOLDER,
|
|
30
|
+
} from './display-width.ts'
|
|
31
|
+
import type { Message } from '@zombie-mermaid/mermaid-parser'
|
|
32
|
+
import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
|
|
33
|
+
|
|
34
|
+
// Width of a self-message's loop glyphs (├──┐ / ◀──┘), excluding the label.
|
|
35
|
+
// Shared between the drawing pass and the block-wall extent calculation so
|
|
36
|
+
// self-arrows inside alt/loop/opt blocks don't get clipped by the wall.
|
|
37
|
+
const SELF_LOOP_WIDTH = 4
|
|
38
|
+
|
|
39
|
+
// Small stick-figure glyph drawn above an `actor`-kind participant's label
|
|
40
|
+
// (inside the same bordered box a `participant` gets), so mermaid's two
|
|
41
|
+
// declaration keywords stay visually distinct in ASCII output the way real
|
|
42
|
+
// mermaid.js's SVG renderer distinguishes them (a circle-person icon vs. a
|
|
43
|
+
// plain box) — see issue #449. Plain ASCII already, so — unlike H/V/TL/etc.
|
|
44
|
+
// above — there's no separate useAscii/unicode variant to pick between.
|
|
45
|
+
const ACTOR_GLYPH_LINES = ['O', '/|\\', '/ \\']
|
|
46
|
+
const ACTOR_GLYPH_WIDTH = 3
|
|
47
|
+
|
|
48
|
+
// Horizontal clearance between a block's (loop/alt/opt/par/etc.) side wall
|
|
49
|
+
// and the lifelines its own messages touch. Shared by every block type —
|
|
50
|
+
// there is no per-type wall calculation, so this constant is the single
|
|
51
|
+
// source of truth for that spacing (see BLOCK_WALL_MARGIN's use below for
|
|
52
|
+
// why a *second*, independent use of it also guards against an untouched
|
|
53
|
+
// lifeline).
|
|
54
|
+
const BLOCK_WALL_MARGIN = 4
|
|
55
|
+
|
|
56
|
+
// Effective width of a self-message's loop, including room for an
|
|
57
|
+
// autonumber badge drawn at the start of the top arm when active. The badge
|
|
58
|
+
// digits replace the leading dashes rather than adding a second arrowhead,
|
|
59
|
+
// so the loop only needs to widen by the badge's own digit count — one
|
|
60
|
+
// extra column per digit — to keep the corner glyphs (┐/┘) intact.
|
|
61
|
+
// Shared by the drawing pass, the canvas-width pass, and the block-wall
|
|
62
|
+
// extent calculation so all three agree on how much room a numbered
|
|
63
|
+
// self-message actually needs.
|
|
64
|
+
function selfLoopWidth(msg: Message): number {
|
|
65
|
+
return msg.seqNumber === undefined
|
|
66
|
+
? SELF_LOOP_WIDTH
|
|
67
|
+
: SELF_LOOP_WIDTH + String(msg.seqNumber).length
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Render a Mermaid sequence diagram to ASCII/Unicode text.
|
|
72
|
+
*
|
|
73
|
+
* Pipeline: parse → layout (columns + rows) → draw onto canvas → string.
|
|
74
|
+
*/
|
|
75
|
+
export function renderSequenceAscii(
|
|
76
|
+
text: string,
|
|
77
|
+
config: AsciiConfig,
|
|
78
|
+
colorMode?: ColorMode,
|
|
79
|
+
theme?: AsciiTheme,
|
|
80
|
+
): string {
|
|
81
|
+
const lines = splitStatements(text)
|
|
82
|
+
const diagram = parseSequenceDiagram(lines)
|
|
83
|
+
|
|
84
|
+
if (diagram.actors.length === 0) return ''
|
|
85
|
+
|
|
86
|
+
const useAscii = config.useAscii
|
|
87
|
+
|
|
88
|
+
// Box-drawing characters
|
|
89
|
+
const H = useAscii ? '-' : '─'
|
|
90
|
+
const V = useAscii ? '|' : '│'
|
|
91
|
+
const TL = useAscii ? '+' : '┌'
|
|
92
|
+
const TR = useAscii ? '+' : '┐'
|
|
93
|
+
const BL = useAscii ? '+' : '└'
|
|
94
|
+
const BR = useAscii ? '+' : '┘'
|
|
95
|
+
const JT = useAscii ? '+' : '┬' // top junction on lifeline
|
|
96
|
+
const JB = useAscii ? '+' : '┴' // bottom junction on lifeline
|
|
97
|
+
const JL = useAscii ? '+' : '├' // left junction
|
|
98
|
+
const JR = useAscii ? '+' : '┤' // right junction
|
|
99
|
+
|
|
100
|
+
// ---- LAYOUT: compute lifeline X positions ----
|
|
101
|
+
|
|
102
|
+
const actorIdx = new Map<string, number>()
|
|
103
|
+
diagram.actors.forEach((a, i) => actorIdx.set(a.id, i))
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Look up an actor's column index. The parser's `ensureActor` (see
|
|
107
|
+
* sequence/parser.ts) guarantees every message endpoint has a
|
|
108
|
+
* corresponding actor entry, so this is always found for real parsed
|
|
109
|
+
* input — but that guarantee lives in a different module/function, so
|
|
110
|
+
* narrow it here explicitly rather than trusting it silently across the
|
|
111
|
+
* boundary.
|
|
112
|
+
*/
|
|
113
|
+
function actorIndexOf(id: string): number {
|
|
114
|
+
const idx = actorIdx.get(id)
|
|
115
|
+
if (idx === undefined) {
|
|
116
|
+
/* v8 ignore next */
|
|
117
|
+
throw new Error(`Sequence diagram: unknown actor "${id}"`)
|
|
118
|
+
}
|
|
119
|
+
return idx
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Widest line among a block's header ("alt [label]") and every divider
|
|
124
|
+
* ("[else label]") — the minimum wall width the block's own text needs,
|
|
125
|
+
* independent of how far its messages' lifelines happen to span.
|
|
126
|
+
*/
|
|
127
|
+
function maxBlockLabelWidth(block: Block): number {
|
|
128
|
+
const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type
|
|
129
|
+
let width = maxLineWidth(hdrLabel)
|
|
130
|
+
for (const divider of block.dividers) {
|
|
131
|
+
if (divider.label) {
|
|
132
|
+
width = Math.max(width, maxLineWidth(`[${divider.label}]`))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return width
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clamped: a negative boxBorderPadding would otherwise produce a negative
|
|
139
|
+
// actor/note box width for a short label (see issue #343's CodeRabbit
|
|
140
|
+
// review — the same class of bug fixed in draw-boxes.ts's
|
|
141
|
+
// measureMultiBox/drawMultiBox for class/ER boxes).
|
|
142
|
+
const boxPad = Math.max(0, config.boxBorderPadding)
|
|
143
|
+
// `maxLineWidth`/`lineCount` account for the label only; add the
|
|
144
|
+
// stick-figure glyph's own footprint (see ACTOR_GLYPH_LINES below) for
|
|
145
|
+
// actor-kind participants so box sizing matches what drawActorBox draws.
|
|
146
|
+
const actorContentWidth = (a: {
|
|
147
|
+
label: string
|
|
148
|
+
type: 'participant' | 'actor'
|
|
149
|
+
}) =>
|
|
150
|
+
Math.max(maxLineWidth(a.label), a.type === 'actor' ? ACTOR_GLYPH_WIDTH : 0)
|
|
151
|
+
const actorContentHeight = (a: {
|
|
152
|
+
label: string
|
|
153
|
+
type: 'participant' | 'actor'
|
|
154
|
+
}) => lineCount(a.label) + (a.type === 'actor' ? ACTOR_GLYPH_LINES.length : 0)
|
|
155
|
+
// Use max line width for multi-line actor labels
|
|
156
|
+
const actorBoxWidths = diagram.actors.map(
|
|
157
|
+
(a) => actorContentWidth(a) + 2 * boxPad + 2,
|
|
158
|
+
)
|
|
159
|
+
const halfBox = actorBoxWidths.map((w) => Math.ceil(w / 2))
|
|
160
|
+
// Calculate actor box heights based on number of lines in label
|
|
161
|
+
const actorBoxHeights = diagram.actors.map((a) => actorContentHeight(a) + 2) // content lines + top/bottom border
|
|
162
|
+
const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning
|
|
163
|
+
// Left border column of each actor's box — drawActorBox derives the same
|
|
164
|
+
// `left` from the lifeline centre, so a creating arrow can stop just short
|
|
165
|
+
// of it (see "DRAW: messages" below).
|
|
166
|
+
const actorBoxLeft = (i: number, cx: number) =>
|
|
167
|
+
cx - Math.floor(actorBoxWidths[i]! / 2)
|
|
168
|
+
|
|
169
|
+
// ---- Participant lifecycle (create / destroy) ----
|
|
170
|
+
//
|
|
171
|
+
// A `create participant X` box is drawn centred on the row of the message
|
|
172
|
+
// that creates it, in place of a header box; a `destroy X` lifeline stops
|
|
173
|
+
// one row under the destroying arrow with a cross glyph, and gets no
|
|
174
|
+
// footer box. Mermaid draws the created box centred on the message line
|
|
175
|
+
// and ends the destroyed lifeline with a cross at that line; the ASCII
|
|
176
|
+
// form keeps the arrowhead intact by putting the cross on the row below
|
|
177
|
+
// — a cross *on* the arrow row would replace the arrowhead and read as a
|
|
178
|
+
// lost message (`-x`) instead.
|
|
179
|
+
//
|
|
180
|
+
// Rows above the arrow taken by the created box, and rows below it. For
|
|
181
|
+
// the default 3-row box that's one row each side of the arrow row.
|
|
182
|
+
const createdBoxAbove = Math.floor((actorBoxH - 1) / 2)
|
|
183
|
+
const createdBoxBelow = actorBoxH - 1 - createdBoxAbove
|
|
184
|
+
// Message index → index of the actor it creates (recipient) / destroys.
|
|
185
|
+
const createdByMsg = new Map<number, number>()
|
|
186
|
+
const destroyedByMsg = new Map<number, number>()
|
|
187
|
+
diagram.actors.forEach((a, i) => {
|
|
188
|
+
if (a.createdAt !== undefined) createdByMsg.set(a.createdAt, i)
|
|
189
|
+
if (a.destroyedAt !== undefined) destroyedByMsg.set(a.destroyedAt, i)
|
|
190
|
+
})
|
|
191
|
+
// Filled in by the vertical layout pass: actor index → top row of its
|
|
192
|
+
// created box / row of its destroy cross.
|
|
193
|
+
const createdBoxTop = new Map<number, number>()
|
|
194
|
+
const destroyRow = new Map<number, number>()
|
|
195
|
+
|
|
196
|
+
// ---- Participant groups (box … end) ----
|
|
197
|
+
//
|
|
198
|
+
// Each non-empty box becomes a labelled bracket around its members' header
|
|
199
|
+
// boxes (and an unlabelled one around their footer boxes), one column
|
|
200
|
+
// outside the outermost member's box on each side and one row above and
|
|
201
|
+
// below the actor-box rows:
|
|
202
|
+
//
|
|
203
|
+
// ┌─ Label ───────────────────┐
|
|
204
|
+
// │ ┌───────┐ ┌──────┐ │
|
|
205
|
+
// │ │ Alice │ │ John │ │
|
|
206
|
+
// │ └───┬───┘ └───┬──┘ │
|
|
207
|
+
// └─────┼───────────────┼─────┘
|
|
208
|
+
// │ │
|
|
209
|
+
//
|
|
210
|
+
// The bracket spans from the leftmost member to the rightmost, so a
|
|
211
|
+
// participant declared between two members sits visually inside, as in
|
|
212
|
+
// Mermaid. Colour is not representable here and is ignored. Running the
|
|
213
|
+
// side walls the full height of the diagram (as Mermaid's background
|
|
214
|
+
// rect does) would cut through every message crossing a group boundary,
|
|
215
|
+
// so the group is shown at the header and footer only.
|
|
216
|
+
const boxSpans = diagram.boxes
|
|
217
|
+
.filter((b) => b.actorIds.length > 0)
|
|
218
|
+
.map((b) => {
|
|
219
|
+
const idxs = b.actorIds.map(actorIndexOf)
|
|
220
|
+
return { label: b.label, lo: Math.min(...idxs), hi: Math.max(...idxs) }
|
|
221
|
+
})
|
|
222
|
+
const hasBoxes = boxSpans.length > 0
|
|
223
|
+
// Actor index → index into boxSpans whose span contains it, or -1.
|
|
224
|
+
const boxSpanOf = diagram.actors.map((_, i) =>
|
|
225
|
+
boxSpans.findIndex((s) => s.lo <= i && i <= s.hi),
|
|
226
|
+
)
|
|
227
|
+
// Rows the header bracket adds above the actor boxes (its top border) —
|
|
228
|
+
// the same count is added below them for its bottom border, and the
|
|
229
|
+
// footer bracket mirrors both.
|
|
230
|
+
const bracketRows = hasBoxes ? 1 : 0
|
|
231
|
+
// Columns between a member box's border and the bracket wall.
|
|
232
|
+
const BRACKET_GAP = 1
|
|
233
|
+
// Minimum bracket width for a label: `┌─ Label ─┐` needs the corner, a
|
|
234
|
+
// dash, a space, the label, a space, a dash, and the closing corner.
|
|
235
|
+
const bracketLabelWidth = (label: string) =>
|
|
236
|
+
label === '' ? 0 : maxLineWidth(label) + 6
|
|
237
|
+
|
|
238
|
+
// Compute minimum gap between adjacent lifelines based on message labels.
|
|
239
|
+
// For messages spanning multiple actors, distribute the required width across gaps.
|
|
240
|
+
const adjMaxWidth: number[] = new Array(
|
|
241
|
+
Math.max(diagram.actors.length - 1, 0),
|
|
242
|
+
).fill(0)
|
|
243
|
+
|
|
244
|
+
for (const msg of diagram.messages) {
|
|
245
|
+
const fi = actorIndexOf(msg.from)
|
|
246
|
+
const ti = actorIndexOf(msg.to)
|
|
247
|
+
if (fi === ti) continue // self-messages don't affect spacing
|
|
248
|
+
const lo = Math.min(fi, ti)
|
|
249
|
+
const hi = Math.max(fi, ti)
|
|
250
|
+
// Required gap per span = (max line width + arrow decorations) / number of gaps
|
|
251
|
+
const needed = maxLineWidth(msg.label) + 4
|
|
252
|
+
const numGaps = hi - lo
|
|
253
|
+
const perGap = Math.ceil(needed / numGaps)
|
|
254
|
+
for (let g = lo; g < hi; g++) {
|
|
255
|
+
adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Width (including border + padding, matching the box drawn in the
|
|
260
|
+
// vertical-layout pass below) of a note anchored to an actor. Computed up
|
|
261
|
+
// front, before lifeline x-positions are finalized, so `left`/`right`
|
|
262
|
+
// notes can participate in gap sizing the same way message labels already
|
|
263
|
+
// do (issue #953 case C) instead of only being checked against a layout
|
|
264
|
+
// that's already fixed.
|
|
265
|
+
function noteBoxWidth(note: (typeof diagram.notes)[number]): number {
|
|
266
|
+
const nLines = splitLines(note.text)
|
|
267
|
+
return Math.max(...nLines.map((l) => displayWidth(l))) + 2 + 2 * boxPad
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Per-actor widest 'left'/'right' note anchored to it. A `left` note on
|
|
271
|
+
// actor i draws into the gap to its left (between actor i-1 and i, or —
|
|
272
|
+
// for the leftmost actor — the canvas's own left margin); a `right` note
|
|
273
|
+
// draws into the gap to its right (between actor i and i+1). Both need to
|
|
274
|
+
// be reserved as part of layout, not clamped after the fact (issue #953
|
|
275
|
+
// cases A-C: notes previously only ever collided with whatever gap
|
|
276
|
+
// message-label sizing happened to leave).
|
|
277
|
+
const leftNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
|
|
278
|
+
const rightNoteWidth: number[] = new Array(diagram.actors.length).fill(0)
|
|
279
|
+
// A single-actor `over` note draws centered on that actor's own lifeline
|
|
280
|
+
// (see the note-x calculation below): `Math.floor(w / 2)` columns land to
|
|
281
|
+
// its left, the rest to its right. Neither half was reserved during gap
|
|
282
|
+
// sizing before this fix, so a note wider than the actor's own column
|
|
283
|
+
// silently spilled into whichever neighbour was closer (issue #992) — the
|
|
284
|
+
// same problem left/rightNoteWidth above already solved for `left`/
|
|
285
|
+
// `right` notes, just for the two halves of a centered one instead. A
|
|
286
|
+
// multi-actor `over` note (`Note over Alice,Bob`) is excluded on purpose:
|
|
287
|
+
// spanning both actors' columns is its intended shape, not a collision.
|
|
288
|
+
const overNoteHalfLeft: number[] = new Array(diagram.actors.length).fill(0)
|
|
289
|
+
const overNoteHalfRight: number[] = new Array(diagram.actors.length).fill(0)
|
|
290
|
+
for (const note of diagram.notes) {
|
|
291
|
+
if (note.position === 'left' || note.position === 'right') {
|
|
292
|
+
const aIdx = actorIndexOf(note.actorIds[0]!)
|
|
293
|
+
const w = noteBoxWidth(note)
|
|
294
|
+
if (note.position === 'left') {
|
|
295
|
+
leftNoteWidth[aIdx] = Math.max(leftNoteWidth[aIdx]!, w)
|
|
296
|
+
} else {
|
|
297
|
+
rightNoteWidth[aIdx] = Math.max(rightNoteWidth[aIdx]!, w)
|
|
298
|
+
}
|
|
299
|
+
} else if (note.position === 'over' && note.actorIds.length === 1) {
|
|
300
|
+
const aIdx = actorIndexOf(note.actorIds[0]!)
|
|
301
|
+
const w = noteBoxWidth(note)
|
|
302
|
+
const halfLeft = Math.floor(w / 2)
|
|
303
|
+
const halfRight = w - halfLeft
|
|
304
|
+
overNoteHalfLeft[aIdx] = Math.max(overNoteHalfLeft[aIdx]!, halfLeft)
|
|
305
|
+
overNoteHalfRight[aIdx] = Math.max(overNoteHalfRight[aIdx]!, halfRight)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Compute lifeline x-positions (greedy left-to-right).
|
|
310
|
+
// See paddingOffset's doc comment (types.ts) for why this is an offset
|
|
311
|
+
// from the paddingX default rather than the raw config value.
|
|
312
|
+
const minLifelineGap = paddingOffset(
|
|
313
|
+
config.paddingX,
|
|
314
|
+
DEFAULT_PADDING_X,
|
|
315
|
+
10,
|
|
316
|
+
4,
|
|
317
|
+
)
|
|
318
|
+
// A bracketed first participant needs room for the wall to its left; two
|
|
319
|
+
// adjacent participants in different groups (or one grouped, one not)
|
|
320
|
+
// need room for the wall(s) between their boxes, plus a blank column on
|
|
321
|
+
// each side of every wall. Same-group neighbours have no wall between.
|
|
322
|
+
//
|
|
323
|
+
// A `left` note on the leftmost actor has no left neighbour to reserve a
|
|
324
|
+
// gap against — it draws into the canvas's own left margin instead, so
|
|
325
|
+
// that margin (the initial lifeline position itself) must be at least
|
|
326
|
+
// wide enough for it: `nx = llX[0] - nWidth - 1 >= 0` (see the note-x
|
|
327
|
+
// calculation below) requires `llX[0] >= nWidth + 1` (issue #953 case A —
|
|
328
|
+
// previously `nx` was clamped to 0 instead, forcing the note onto the
|
|
329
|
+
// lifeline it's supposed to sit beside).
|
|
330
|
+
const llX: number[] = [
|
|
331
|
+
Math.max(
|
|
332
|
+
halfBox[0]! + (boxSpanOf[0] === -1 ? 0 : BRACKET_GAP + 1),
|
|
333
|
+
leftNoteWidth[0]! + 1,
|
|
334
|
+
// A single-actor `over` note on the leftmost actor has no left
|
|
335
|
+
// neighbour either — same reasoning as the `left`-note case just
|
|
336
|
+
// above, but for the note's own left half (`nx = llX[0] -
|
|
337
|
+
// overNoteHalfLeft[0]` must stay >= 0; issue #992).
|
|
338
|
+
overNoteHalfLeft[0]!,
|
|
339
|
+
),
|
|
340
|
+
]
|
|
341
|
+
for (let i = 1; i < diagram.actors.length; i++) {
|
|
342
|
+
const prevSpan = boxSpanOf[i - 1]!
|
|
343
|
+
const thisSpan = boxSpanOf[i]!
|
|
344
|
+
const walls =
|
|
345
|
+
prevSpan === thisSpan
|
|
346
|
+
? 0
|
|
347
|
+
: (prevSpan === -1 ? 0 : 1) + (thisSpan === -1 ? 0 : 1)
|
|
348
|
+
// The base constraint already leaves two blank columns between boxes;
|
|
349
|
+
// each wall needs one column of its own plus one more blank beside it.
|
|
350
|
+
const wallExtra = walls === 0 ? 0 : walls * (BRACKET_GAP + 1) - 1
|
|
351
|
+
// A `right` note on actor i-1, or a `left` note on actor i, draws into
|
|
352
|
+
// this gap — reserve room for whichever is wider (issue #953 cases B/C)
|
|
353
|
+
// so a wide note doesn't overflow into the neighbouring lifeline. The
|
|
354
|
+
// `+2` mirrors adjMaxWidth's own margin below and matches the 1-column
|
|
355
|
+
// clearance the note-x calculation already leaves on the lifeline side.
|
|
356
|
+
// A single-actor `over` note on either actor reaches into this same gap
|
|
357
|
+
// from its own side (its right half from i-1, its left half from i) —
|
|
358
|
+
// issue #992, folded into the same max() rather than summed, matching
|
|
359
|
+
// how the left/right pair above is already handled.
|
|
360
|
+
const noteGapNeed =
|
|
361
|
+
Math.max(
|
|
362
|
+
rightNoteWidth[i - 1]!,
|
|
363
|
+
leftNoteWidth[i]!,
|
|
364
|
+
overNoteHalfRight[i - 1]!,
|
|
365
|
+
overNoteHalfLeft[i]!,
|
|
366
|
+
) + 2
|
|
367
|
+
const gap = Math.max(
|
|
368
|
+
halfBox[i - 1]! + halfBox[i]! + 2 + wallExtra,
|
|
369
|
+
adjMaxWidth[i - 1]! + 2,
|
|
370
|
+
noteGapNeed,
|
|
371
|
+
minLifelineGap,
|
|
372
|
+
)
|
|
373
|
+
llX[i] = llX[i - 1]! + gap
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Bracket wall columns, from live lifeline positions (later passes only
|
|
377
|
+
// ever shift lifelines right, so these stay valid if read at draw time).
|
|
378
|
+
const bracketLeft = (span: { lo: number }) =>
|
|
379
|
+
actorBoxLeft(span.lo, llX[span.lo]!) - BRACKET_GAP - 1
|
|
380
|
+
const bracketRight = (span: { label: string; lo: number; hi: number }) =>
|
|
381
|
+
Math.max(
|
|
382
|
+
actorBoxLeft(span.hi, llX[span.hi]!) +
|
|
383
|
+
actorBoxWidths[span.hi]! -
|
|
384
|
+
1 +
|
|
385
|
+
BRACKET_GAP +
|
|
386
|
+
1,
|
|
387
|
+
bracketLeft(span) + bracketLabelWidth(span.label) - 1,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
// A label wider than its members' span widens the bracket to the right;
|
|
391
|
+
// push everything after the group out of the way, the same way the block-
|
|
392
|
+
// label pass below does for loop/alt headers.
|
|
393
|
+
for (const span of boxSpans) {
|
|
394
|
+
if (span.hi + 1 >= diagram.actors.length) continue
|
|
395
|
+
const nextLeft = actorBoxLeft(span.hi + 1, llX[span.hi + 1]!)
|
|
396
|
+
const nextWall = boxSpanOf[span.hi + 1] === -1 ? 0 : BRACKET_GAP + 1
|
|
397
|
+
const needed = bracketRight(span) + BRACKET_GAP + 1 + nextWall
|
|
398
|
+
const shift = needed - nextLeft
|
|
399
|
+
if (shift > 0) {
|
|
400
|
+
const shifted = llX.slice(span.hi + 1).map((x) => x + shift)
|
|
401
|
+
llX.splice(span.hi + 1, shifted.length, ...shifted)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// A block's wall only needs to reach as far right as the lifelines its
|
|
406
|
+
// own messages touch (see the identical minLX/maxLX calc in the block
|
|
407
|
+
// drawing pass below) — but the header/divider LABEL can need more room
|
|
408
|
+
// than that. If an uninvolved participant's lifeline already sits at or
|
|
409
|
+
// past that label-driven width, drawing the wall there lands on a
|
|
410
|
+
// different column than the lifeline (which was already placed) and
|
|
411
|
+
// just leaves that lifeline's "│" sitting inside the block, right next
|
|
412
|
+
// to the block's own wall — visually indistinguishable from the block
|
|
413
|
+
// enclosing a participant it has nothing to do with (#387, on top of
|
|
414
|
+
// #352's original fix). Push every lifeline after the block's rightmost
|
|
415
|
+
// participant out of the way before anything downstream depends on
|
|
416
|
+
// these positions.
|
|
417
|
+
//
|
|
418
|
+
// This runs independently of, and before, the draw-time pull-back added
|
|
419
|
+
// for #353 (below, in the "DRAW: blocks" pass): that pull-back only ever
|
|
420
|
+
// shrinks a block's *natural* (message-driven) extent away from an
|
|
421
|
+
// untouched lifeline it would otherwise land on — it has no notion of
|
|
422
|
+
// the label-driven width computed here. Without this pass shifting the
|
|
423
|
+
// lifeline out of the way first, a long label would push bRight straight
|
|
424
|
+
// back past whatever #353's pull-back had just cleared, re-enclosing it.
|
|
425
|
+
// This pass's own estimate below (naturalRight/labelRight, using
|
|
426
|
+
// BLOCK_WALL_MARGIN unclamped by any pull-back) is always >= whatever
|
|
427
|
+
// the draw-time pass can ultimately produce, so it shifts at least far
|
|
428
|
+
// enough — never more precisely, but never insufficiently either.
|
|
429
|
+
for (const block of diagram.blocks) {
|
|
430
|
+
let loIdx = -1
|
|
431
|
+
let hiIdx = -1
|
|
432
|
+
let minLX = Number.POSITIVE_INFINITY
|
|
433
|
+
let maxLX = -1
|
|
434
|
+
for (let m = block.startIndex; m <= block.endIndex; m++) {
|
|
435
|
+
if (m >= diagram.messages.length) break
|
|
436
|
+
const msg = diagram.messages[m]!
|
|
437
|
+
const f = actorIndexOf(msg.from)
|
|
438
|
+
const t = actorIndexOf(msg.to)
|
|
439
|
+
loIdx = loIdx === -1 ? Math.min(f, t) : Math.min(loIdx, f, t)
|
|
440
|
+
hiIdx = Math.max(hiIdx, f, t)
|
|
441
|
+
minLX = Math.min(minLX, llX[Math.min(f, t)]!)
|
|
442
|
+
maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)
|
|
443
|
+
if (f === t) {
|
|
444
|
+
const selfRight =
|
|
445
|
+
llX[f]! + SELF_LOOP_WIDTH + 2 + maxLineWidth(msg.label)
|
|
446
|
+
maxLX = Math.max(maxLX, selfRight)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// An empty block, or one whose rightmost participant is already the
|
|
450
|
+
// last actor, has nothing after it that could be swallowed.
|
|
451
|
+
if (hiIdx === -1 || hiIdx + 1 >= diagram.actors.length) continue
|
|
452
|
+
|
|
453
|
+
const bLeft = Math.max(0, minLX - BLOCK_WALL_MARGIN)
|
|
454
|
+
const naturalRight = maxLX + BLOCK_WALL_MARGIN
|
|
455
|
+
const labelRight = bLeft + 1 + maxBlockLabelWidth(block)
|
|
456
|
+
const bRight = Math.max(naturalRight, labelRight)
|
|
457
|
+
|
|
458
|
+
const nextLL = llX[hiIdx + 1]!
|
|
459
|
+
const shift = bRight + 2 - nextLL
|
|
460
|
+
if (shift > 0) {
|
|
461
|
+
// Shift every lifeline from hiIdx+1 onward by `shift`, in place —
|
|
462
|
+
// llX's identity has to survive this pass (later blocks in this same
|
|
463
|
+
// loop, and everything downstream, read positions back out of it) so
|
|
464
|
+
// this can't rebuild the array under a new binding. Deriving the
|
|
465
|
+
// shifted values with .map() and writing them back via splice (same
|
|
466
|
+
// start/length, so it's a pure overwrite) avoids the index-reassignment
|
|
467
|
+
// loop without changing what ends up in llX.
|
|
468
|
+
const shifted = llX.slice(hiIdx + 1).map((x) => x + shift)
|
|
469
|
+
llX.splice(hiIdx + 1, shifted.length, ...shifted)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---- LAYOUT: compute vertical positions for messages ----
|
|
474
|
+
|
|
475
|
+
// For each message index, track the y where its arrow is drawn.
|
|
476
|
+
// Also track block start/end y positions and divider y positions.
|
|
477
|
+
const msgArrowY: number[] = []
|
|
478
|
+
const msgLabelY: number[] = []
|
|
479
|
+
const blockStartY = new Map<number, number>()
|
|
480
|
+
const blockEndY = new Map<number, number>()
|
|
481
|
+
const divYMap = new Map<string, number>() // "blockIdx:divIdx" → y
|
|
482
|
+
const notePositions: Array<{
|
|
483
|
+
x: number
|
|
484
|
+
y: number
|
|
485
|
+
width: number
|
|
486
|
+
height: number
|
|
487
|
+
lines: string[]
|
|
488
|
+
}> = []
|
|
489
|
+
|
|
490
|
+
// Start right below the header boxes — and below the group bracket's
|
|
491
|
+
// bottom border when there is one (its top border row sits above them).
|
|
492
|
+
const headerBoxTop = bracketRows
|
|
493
|
+
const headerBottom = headerBoxTop + actorBoxH // first row after the boxes
|
|
494
|
+
let curY = headerBottom + bracketRows
|
|
495
|
+
|
|
496
|
+
// rowGap: the blank rows around messages, notes, and blocks. See
|
|
497
|
+
// paddingOffset's doc comment (types.ts) for why this is an offset from
|
|
498
|
+
// the paddingY default rather than the raw config value. Floored at 0 (not
|
|
499
|
+
// 1) since these are single-row gaps, not a whole box — collapsing a gap
|
|
500
|
+
// to 0 rows is still a valid, readable layout, just a tight one.
|
|
501
|
+
const rowGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 1, 0)
|
|
502
|
+
|
|
503
|
+
// Below rowGap's own floor of 0, three specific gaps still need at least
|
|
504
|
+
// one row: the row right after a divider (or the message row drawn there
|
|
505
|
+
// would land on the divider's own row and overwrite it), the row right
|
|
506
|
+
// after a block's closing border (same reasoning against whatever comes
|
|
507
|
+
// next), and the row before the footer (the footer's top border is drawn
|
|
508
|
+
// *before* messages/arrows in the draw pass — see "DRAW: actor header +
|
|
509
|
+
// footer boxes" below — so a message landing on the same row as the
|
|
510
|
+
// footer would draw its arrow through the footer's border). Everywhere
|
|
511
|
+
// else (blank row before a message, gap before a note, blank row before a
|
|
512
|
+
// block header) is genuinely optional spacing with no such collision risk,
|
|
513
|
+
// so those keep using rowGap directly. See issue #343's CodeRabbit review.
|
|
514
|
+
const minSeparatorGap = Math.max(rowGap, 1)
|
|
515
|
+
|
|
516
|
+
// Pre-message notes: afterIndex === -1 — position before message loop
|
|
517
|
+
for (const note of diagram.notes) {
|
|
518
|
+
if (note.afterIndex !== -1) continue
|
|
519
|
+
curY += rowGap // gap before note
|
|
520
|
+
const nLines = splitLines(note.text)
|
|
521
|
+
const nWidth = noteBoxWidth(note)
|
|
522
|
+
const nHeight = nLines.length + 2
|
|
523
|
+
|
|
524
|
+
const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
|
|
525
|
+
let nx: number
|
|
526
|
+
if (note.position === 'left') {
|
|
527
|
+
nx = llX[aIdx]! - nWidth - 1
|
|
528
|
+
} else if (note.position === 'right') {
|
|
529
|
+
nx = llX[aIdx]! + 2
|
|
530
|
+
} else {
|
|
531
|
+
// 'over'
|
|
532
|
+
if (note.actorIds.length >= 2) {
|
|
533
|
+
const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx
|
|
534
|
+
nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2)
|
|
535
|
+
} else {
|
|
536
|
+
nx = llX[aIdx]! - Math.floor(nWidth / 2)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
nx = Math.max(0, nx)
|
|
540
|
+
|
|
541
|
+
notePositions.push({
|
|
542
|
+
x: nx,
|
|
543
|
+
y: curY,
|
|
544
|
+
width: nWidth,
|
|
545
|
+
height: nHeight,
|
|
546
|
+
lines: nLines,
|
|
547
|
+
})
|
|
548
|
+
curY += nHeight
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
for (let m = 0; m < diagram.messages.length; m++) {
|
|
552
|
+
// Block openings at this message
|
|
553
|
+
for (let b = 0; b < diagram.blocks.length; b++) {
|
|
554
|
+
if (diagram.blocks[b]!.startIndex === m) {
|
|
555
|
+
curY += rowGap + 1 // blank rows + 1 fixed header row
|
|
556
|
+
blockStartY.set(b, curY - 1)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Dividers at this message index
|
|
561
|
+
for (let b = 0; b < diagram.blocks.length; b++) {
|
|
562
|
+
for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) {
|
|
563
|
+
if (diagram.blocks[b]!.dividers[d]!.index === m) {
|
|
564
|
+
curY += rowGap
|
|
565
|
+
divYMap.set(`${b}:${d}`, curY)
|
|
566
|
+
curY += minSeparatorGap
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
curY += rowGap // blank row before message
|
|
572
|
+
|
|
573
|
+
const msg = diagram.messages[m]!
|
|
574
|
+
const isSelf = msg.from === msg.to
|
|
575
|
+
|
|
576
|
+
// Calculate height needed for multi-line message labels
|
|
577
|
+
const msgLineCount = lineCount(msg.label)
|
|
578
|
+
|
|
579
|
+
// A self-message can't sensibly create its own recipient (the loop
|
|
580
|
+
// glyphs would sit where the box goes), so that degenerate case keeps
|
|
581
|
+
// the header box instead.
|
|
582
|
+
const createdIdx = isSelf ? undefined : createdByMsg.get(m)
|
|
583
|
+
const destroyedIdx = destroyedByMsg.get(m)
|
|
584
|
+
|
|
585
|
+
if (isSelf) {
|
|
586
|
+
// Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm
|
|
587
|
+
msgLabelY[m] = curY + 1
|
|
588
|
+
msgArrowY[m] = curY
|
|
589
|
+
curY += 2 + msgLineCount // top-arm + label lines + bottom-arm
|
|
590
|
+
} else {
|
|
591
|
+
// Normal message: label row(s), then — for a creating message — the
|
|
592
|
+
// rows of the created box above the arrow, then the arrow row, then
|
|
593
|
+
// the created box's rows below it.
|
|
594
|
+
const boxAbove = createdIdx === undefined ? 0 : createdBoxAbove
|
|
595
|
+
const boxBelow = createdIdx === undefined ? 0 : createdBoxBelow
|
|
596
|
+
msgLabelY[m] = curY
|
|
597
|
+
msgArrowY[m] = curY + msgLineCount + boxAbove
|
|
598
|
+
curY += msgLineCount + boxAbove + 1 + boxBelow
|
|
599
|
+
if (createdIdx !== undefined) {
|
|
600
|
+
createdBoxTop.set(createdIdx, msgArrowY[m]! - boxAbove)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// One reserved row under the arrow (under the bottom arm, for a
|
|
605
|
+
// self-message) for the destroy cross, so it never lands on whatever
|
|
606
|
+
// comes next when rowGap is 0.
|
|
607
|
+
if (destroyedIdx !== undefined) {
|
|
608
|
+
destroyRow.set(destroyedIdx, curY)
|
|
609
|
+
curY += 1
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Notes after this message
|
|
613
|
+
for (let n = 0; n < diagram.notes.length; n++) {
|
|
614
|
+
if (diagram.notes[n]!.afterIndex === m) {
|
|
615
|
+
curY += rowGap
|
|
616
|
+
const note = diagram.notes[n]!
|
|
617
|
+
const nLines = splitLines(note.text)
|
|
618
|
+
const nWidth = noteBoxWidth(note)
|
|
619
|
+
const nHeight = nLines.length + 2
|
|
620
|
+
|
|
621
|
+
// Determine x position based on note.position
|
|
622
|
+
const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0
|
|
623
|
+
let nx: number
|
|
624
|
+
if (note.position === 'left') {
|
|
625
|
+
nx = llX[aIdx]! - nWidth - 1
|
|
626
|
+
} else if (note.position === 'right') {
|
|
627
|
+
nx = llX[aIdx]! + 2
|
|
628
|
+
} else {
|
|
629
|
+
// 'over' — center over actor(s)
|
|
630
|
+
if (note.actorIds.length >= 2) {
|
|
631
|
+
const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx
|
|
632
|
+
nx =
|
|
633
|
+
Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) -
|
|
634
|
+
Math.floor(nWidth / 2)
|
|
635
|
+
} else {
|
|
636
|
+
nx = llX[aIdx]! - Math.floor(nWidth / 2)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
nx = Math.max(0, nx)
|
|
640
|
+
|
|
641
|
+
notePositions.push({
|
|
642
|
+
x: nx,
|
|
643
|
+
y: curY,
|
|
644
|
+
width: nWidth,
|
|
645
|
+
height: nHeight,
|
|
646
|
+
lines: nLines,
|
|
647
|
+
})
|
|
648
|
+
curY += nHeight
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Block closings after this message
|
|
653
|
+
for (let b = 0; b < diagram.blocks.length; b++) {
|
|
654
|
+
if (diagram.blocks[b]!.endIndex === m) {
|
|
655
|
+
curY += rowGap
|
|
656
|
+
blockEndY.set(b, curY)
|
|
657
|
+
curY += minSeparatorGap
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
curY += minSeparatorGap // gap before footer (mandatory — see minSeparatorGap)
|
|
663
|
+
// With brackets, footerY is the footer bracket's top border row and the
|
|
664
|
+
// footer boxes start one row under it; otherwise it is the boxes' own top.
|
|
665
|
+
const footerY = curY
|
|
666
|
+
const footerBoxTop = footerY + bracketRows
|
|
667
|
+
const totalH = footerBoxTop + actorBoxH + bracketRows
|
|
668
|
+
|
|
669
|
+
// Total canvas width
|
|
670
|
+
const lastLL = llX[llX.length - 1] ?? 0
|
|
671
|
+
const lastHalf = halfBox[halfBox.length - 1] ?? 0
|
|
672
|
+
let totalW = lastLL + lastHalf + 2
|
|
673
|
+
for (const span of boxSpans) {
|
|
674
|
+
totalW = Math.max(totalW, bracketRight(span) + 2)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Ensure canvas is wide enough for self-message labels and notes
|
|
678
|
+
for (let m = 0; m < diagram.messages.length; m++) {
|
|
679
|
+
const msg = diagram.messages[m]!
|
|
680
|
+
if (msg.from === msg.to) {
|
|
681
|
+
const fi = actorIndexOf(msg.from)
|
|
682
|
+
const selfRight =
|
|
683
|
+
llX[fi]! + selfLoopWidth(msg) + 2 + 2 + maxLineWidth(msg.label)
|
|
684
|
+
totalW = Math.max(totalW, selfRight + 1)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const np of notePositions) {
|
|
688
|
+
totalW = Math.max(totalW, np.x + np.width + 1)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const canvas = mkCanvas(totalW, totalH - 1)
|
|
692
|
+
const rc = mkRoleCanvas(totalW, totalH - 1)
|
|
693
|
+
|
|
694
|
+
/** Set a character on the canvas and track its role. */
|
|
695
|
+
function setC(x: number, y: number, ch: string, role: CharRole): void {
|
|
696
|
+
write(canvas, x, y, ch, { role, roleCanvas: rc })
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Write a line of text starting at grid cell (x, y), one grid cell per
|
|
701
|
+
* terminal column rather than one grid cell per JS code point.
|
|
702
|
+
*
|
|
703
|
+
* A CJK/kana/hangul/fullwidth-form/emoji grapheme renders as TWO terminal
|
|
704
|
+
* columns but is a single JS character — writing it into a single grid
|
|
705
|
+
* cell (as a naive `for (let i = 0; i < line.length; i++)` loop does)
|
|
706
|
+
* under-reserves a column for every wide character, so unrelated content
|
|
707
|
+
* (box borders, adjacent lifelines) drawn later at a fixed grid index no
|
|
708
|
+
* longer lines up with what a real terminal actually renders for this
|
|
709
|
+
* row (issue #334). `toDisplayCells` (display-width.ts) splits `text`
|
|
710
|
+
* into one entry per terminal column — a wide grapheme followed by an
|
|
711
|
+
* empty placeholder entry — so writing one cell per entry keeps this
|
|
712
|
+
* row's grid indices in step with its rendered terminal columns, the
|
|
713
|
+
* same approach `drawText` (canvas.ts) already uses for flowchart/class/
|
|
714
|
+
* ER diagram boxes.
|
|
715
|
+
*
|
|
716
|
+
* `exclusiveMaxX`, when given, additionally skips any cell at or past
|
|
717
|
+
* that grid index — matching call sites that previously bounded their
|
|
718
|
+
* own manual write loop with `x < totalW` (a one-column margin short of
|
|
719
|
+
* `setC`'s own canvas-edge clipping). `setC` still clips to the actual
|
|
720
|
+
* canvas bounds regardless, so omitting it just falls back to that.
|
|
721
|
+
*/
|
|
722
|
+
function writeTextCells(
|
|
723
|
+
x: number,
|
|
724
|
+
y: number,
|
|
725
|
+
text: string,
|
|
726
|
+
role: CharRole,
|
|
727
|
+
exclusiveMaxX?: number,
|
|
728
|
+
): void {
|
|
729
|
+
const cells = toDisplayCells(text)
|
|
730
|
+
for (let i = 0; i < cells.length; i++) {
|
|
731
|
+
const cx = x + i
|
|
732
|
+
if (exclusiveMaxX !== undefined && cx >= exclusiveMaxX) break
|
|
733
|
+
// A wide grapheme's glyph cell is always immediately followed by its
|
|
734
|
+
// placeholder cell (toDisplayCells' pairing). Writing the glyph
|
|
735
|
+
// without room for that placeholder would leave its second terminal
|
|
736
|
+
// column unreserved even though the glyph still renders across two
|
|
737
|
+
// columns — reintroducing this file's own under-reservation bug
|
|
738
|
+
// right at the clip boundary instead of over the whole string. Stop
|
|
739
|
+
// one cell earlier instead of splitting the pair.
|
|
740
|
+
const isWideGlyphStart = cells[i + 1] === WIDE_CHAR_PLACEHOLDER
|
|
741
|
+
if (
|
|
742
|
+
isWideGlyphStart &&
|
|
743
|
+
exclusiveMaxX !== undefined &&
|
|
744
|
+
cx + 1 >= exclusiveMaxX
|
|
745
|
+
) {
|
|
746
|
+
break
|
|
747
|
+
}
|
|
748
|
+
setC(cx, y, cells[i]!, role)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ----
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Draws a bordered, centered actor box directly onto the shared sequence
|
|
756
|
+
* canvas. This intentionally does NOT go through the shared `drawMultiBox`
|
|
757
|
+
* primitive (src/ascii/draw-boxes.ts), for two concrete reasons:
|
|
758
|
+
*
|
|
759
|
+
* 1. Coordinate system: `drawMultiBox` returns a standalone canvas rooted
|
|
760
|
+
* at (0, 0), meant to be measured and then copied onto a caller's
|
|
761
|
+
* canvas (as class-diagram.ts and er-diagram.ts do). Actor boxes are
|
|
762
|
+
* positioned by a lifeline's center x-coordinate (`cx`), and are drawn
|
|
763
|
+
* twice per actor (header at y=0, footer at y=footerY) directly via the
|
|
764
|
+
* closure-captured `setC`, alongside unrelated lifeline/junction
|
|
765
|
+
* drawing that shares the same canvas and role-tracking.
|
|
766
|
+
* 2. Sizing/alignment semantics differ, not just coordinates: `drawMultiBox`
|
|
767
|
+
* sizes each section by raw `.length` and left-aligns its content with
|
|
768
|
+
* fixed padding. Actor labels are centered and sized via
|
|
769
|
+
* `maxLineWidth` (multiline-utils.ts), which is deliberately
|
|
770
|
+
* display-width-aware (not `.length`) so wide CJK/emoji labels get a
|
|
771
|
+
* correctly-sized box. Routing actor boxes through `drawMultiBox` as it
|
|
772
|
+
* stands would silently narrow boxes for wide-character actor labels —
|
|
773
|
+
* reintroducing the exact bug `maxLineWidth` exists to avoid — and
|
|
774
|
+
* changing `drawMultiBox` itself to be display-width-aware and
|
|
775
|
+
* center-aligned would alter class/ER diagram box sizing, which must
|
|
776
|
+
* stay untouched.
|
|
777
|
+
*
|
|
778
|
+
* For an `actor`-kind participant, `ACTOR_GLYPH_LINES` (a small stick
|
|
779
|
+
* figure) is prepended to the label lines, inside the same box — the
|
|
780
|
+
* box's width/height precompute above (`actorContentWidth`/
|
|
781
|
+
* `actorContentHeight`) already reserves room for it so this stays a
|
|
782
|
+
* pure drawing step with no additional sizing math.
|
|
783
|
+
*
|
|
784
|
+
* `boxH` is always the diagram-wide `actorBoxH` (the tallest box among
|
|
785
|
+
* *all* actors), not this actor's own content height — every box must
|
|
786
|
+
* bottom out at the same row so the lifeline/junction drawn below it
|
|
787
|
+
* (which unconditionally starts at row `actorBoxH`, see the "DRAW:
|
|
788
|
+
* lifelines" section below) connects directly to the box's bottom
|
|
789
|
+
* border instead of leaving a gap. A participant box shorter than an
|
|
790
|
+
* adjacent actor's stick-figure box gets blank rows appended above its
|
|
791
|
+
* bottom border to make up the difference.
|
|
792
|
+
*/
|
|
793
|
+
function drawActorBox(
|
|
794
|
+
cx: number,
|
|
795
|
+
topY: number,
|
|
796
|
+
label: string,
|
|
797
|
+
actorType: 'participant' | 'actor',
|
|
798
|
+
boxH: number,
|
|
799
|
+
): void {
|
|
800
|
+
const glyphLines = actorType === 'actor' ? ACTOR_GLYPH_LINES : []
|
|
801
|
+
const lines = [...glyphLines, ...splitLines(label)]
|
|
802
|
+
const maxW = Math.max(
|
|
803
|
+
maxLineWidth(label),
|
|
804
|
+
glyphLines.length > 0 ? ACTOR_GLYPH_WIDTH : 0,
|
|
805
|
+
)
|
|
806
|
+
const w = maxW + 2 * boxPad + 2
|
|
807
|
+
const h = boxH
|
|
808
|
+
const contentRows = h - 2
|
|
809
|
+
const left = cx - Math.floor(w / 2)
|
|
810
|
+
|
|
811
|
+
// Top border
|
|
812
|
+
setC(left, topY, TL, 'border')
|
|
813
|
+
for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border')
|
|
814
|
+
setC(left + w - 1, topY, TR, 'border')
|
|
815
|
+
|
|
816
|
+
// Content rows (top-aligned; horizontally centered). Any rows beyond
|
|
817
|
+
// `lines.length` (up to `contentRows`) stay blank padding — see the
|
|
818
|
+
// `boxH` doc note above.
|
|
819
|
+
for (let i = 0; i < contentRows; i++) {
|
|
820
|
+
const row = topY + 1 + i
|
|
821
|
+
setC(left, row, V, 'border')
|
|
822
|
+
setC(left + w - 1, row, V, 'border')
|
|
823
|
+
const line = lines[i]
|
|
824
|
+
if (line === undefined) continue
|
|
825
|
+
// Center this line within the box. Centering offset and cell-writing
|
|
826
|
+
// both use display width (terminal columns), not `.length` (JS
|
|
827
|
+
// code units) — a code-unit-based offset would under-center CJK
|
|
828
|
+
// labels, and writing one grid cell per code unit (rather than per
|
|
829
|
+
// terminal column) would under-reserve columns for wide glyphs,
|
|
830
|
+
// both contributing to issue #334's border/content misalignment.
|
|
831
|
+
const ls = left + 1 + boxPad + Math.floor((maxW - displayWidth(line)) / 2)
|
|
832
|
+
writeTextCells(ls, row, line, 'text')
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Bottom border
|
|
836
|
+
const bottomY = topY + h - 1
|
|
837
|
+
setC(left, bottomY, BL, 'border')
|
|
838
|
+
for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border')
|
|
839
|
+
setC(left + w - 1, bottomY, BR, 'border')
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ---- DRAW: lifelines ----
|
|
843
|
+
|
|
844
|
+
// A created participant's lifeline starts under its mid-diagram box; a
|
|
845
|
+
// destroyed one's ends at its cross row (drawn in the messages pass, so
|
|
846
|
+
// the cross wins over the `│` painted here).
|
|
847
|
+
const lifelineTop = (i: number) => {
|
|
848
|
+
const boxTop = createdBoxTop.get(i)
|
|
849
|
+
return boxTop === undefined ? headerBottom : boxTop + actorBoxH
|
|
850
|
+
}
|
|
851
|
+
const lifelineBottom = (i: number) => destroyRow.get(i) ?? footerY
|
|
852
|
+
|
|
853
|
+
for (let i = 0; i < diagram.actors.length; i++) {
|
|
854
|
+
const x = llX[i]!
|
|
855
|
+
for (let y = lifelineTop(i); y <= lifelineBottom(i); y++) {
|
|
856
|
+
setC(x, y, V, 'line')
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ---- DRAW: participant-group brackets (box … end) ----
|
|
861
|
+
|
|
862
|
+
const CROSS = useAscii ? '+' : '┼'
|
|
863
|
+
/**
|
|
864
|
+
* One bracket: top border (with the label, if any), side walls down the
|
|
865
|
+
* actor-box rows, bottom border. Lifelines that pass through a border row
|
|
866
|
+
* get a crossing glyph; a created (header) or destroyed (footer) member's
|
|
867
|
+
* lifeline doesn't reach that row, so its column keeps the plain border.
|
|
868
|
+
*/
|
|
869
|
+
function drawBracket(
|
|
870
|
+
span: { label: string; lo: number; hi: number },
|
|
871
|
+
topRow: number,
|
|
872
|
+
label: string,
|
|
873
|
+
): void {
|
|
874
|
+
const left = bracketLeft(span)
|
|
875
|
+
const right = bracketRight(span)
|
|
876
|
+
const bottomRow = topRow + actorBoxH + 1
|
|
877
|
+
setC(left, topRow, TL, 'border')
|
|
878
|
+
setC(right, topRow, TR, 'border')
|
|
879
|
+
setC(left, bottomRow, BL, 'border')
|
|
880
|
+
setC(right, bottomRow, BR, 'border')
|
|
881
|
+
for (let x = left + 1; x < right; x++) {
|
|
882
|
+
setC(x, topRow, H, 'border')
|
|
883
|
+
setC(x, bottomRow, H, 'border')
|
|
884
|
+
}
|
|
885
|
+
for (let y = topRow + 1; y < bottomRow; y++) {
|
|
886
|
+
setC(left, y, V, 'border')
|
|
887
|
+
setC(right, y, V, 'border')
|
|
888
|
+
}
|
|
889
|
+
if (label !== '') {
|
|
890
|
+
// `┌─ Label ─…`: keep the first dash, then the label with a space on
|
|
891
|
+
// each side. Multi-line labels take the first line only — the border
|
|
892
|
+
// is a single row.
|
|
893
|
+
writeTextCells(left + 2, topRow, ` ${splitLines(label)[0]!} `, 'text')
|
|
894
|
+
}
|
|
895
|
+
for (let i = span.lo; i <= span.hi; i++) {
|
|
896
|
+
const x = llX[i]!
|
|
897
|
+
if (lifelineTop(i) <= topRow && topRow <= lifelineBottom(i)) {
|
|
898
|
+
setC(x, topRow, CROSS, 'junction')
|
|
899
|
+
}
|
|
900
|
+
if (lifelineTop(i) <= bottomRow && bottomRow <= lifelineBottom(i)) {
|
|
901
|
+
setC(x, bottomRow, CROSS, 'junction')
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
for (const span of boxSpans) {
|
|
906
|
+
drawBracket(span, 0, span.label)
|
|
907
|
+
drawBracket(span, footerY, '')
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ---- DRAW: actor header + footer boxes (drawn over lifelines) ----
|
|
911
|
+
|
|
912
|
+
for (let i = 0; i < diagram.actors.length; i++) {
|
|
913
|
+
const actor = diagram.actors[i]!
|
|
914
|
+
// Created: the box sits on its creating message's row, not in the
|
|
915
|
+
// header. Destroyed: no footer box — the lifeline already ended.
|
|
916
|
+
const headerTop = createdBoxTop.get(i) ?? headerBoxTop
|
|
917
|
+
const destroyed = destroyRow.has(i)
|
|
918
|
+
drawActorBox(llX[i]!, headerTop, actor.label, actor.type, actorBoxH)
|
|
919
|
+
if (!destroyed) {
|
|
920
|
+
drawActorBox(llX[i]!, footerBoxTop, actor.label, actor.type, actorBoxH)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Lifeline junctions on box borders (Unicode only)
|
|
924
|
+
if (!useAscii) {
|
|
925
|
+
setC(llX[i]!, headerTop + actorBoxH - 1, JT, 'junction')
|
|
926
|
+
if (!destroyed) setC(llX[i]!, footerBoxTop, JB, 'junction')
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ---- DRAW: messages ----
|
|
931
|
+
|
|
932
|
+
for (let m = 0; m < diagram.messages.length; m++) {
|
|
933
|
+
const msg = diagram.messages[m]!
|
|
934
|
+
const fi = actorIndexOf(msg.from)
|
|
935
|
+
const ti = actorIndexOf(msg.to)
|
|
936
|
+
const fromX = llX[fi]!
|
|
937
|
+
const toX = llX[ti]!
|
|
938
|
+
const isSelf = fi === ti
|
|
939
|
+
const isDashed = msg.lineStyle === 'dashed'
|
|
940
|
+
const isFilled = msg.arrowHead === 'filled'
|
|
941
|
+
// "lost message" (-x/--x): a distinct cross terminator, not the plain
|
|
942
|
+
// filled arrowhead it otherwise shares with ->>/-->>. Direction-
|
|
943
|
+
// independent, unlike the arrow glyphs below — see issue #330.
|
|
944
|
+
const isLost = msg.isLost === true
|
|
945
|
+
const lostChar = useAscii ? 'x' : '✕'
|
|
946
|
+
|
|
947
|
+
// Arrow line character (solid vs dashed)
|
|
948
|
+
const lineChar = isDashed ? (useAscii ? '.' : '╌') : H
|
|
949
|
+
|
|
950
|
+
if (isSelf) {
|
|
951
|
+
// Self-message: 3-row loop to the right of the lifeline
|
|
952
|
+
// ├──┐ (row 0 = msgArrowY)
|
|
953
|
+
// │ │ Label (row 1)
|
|
954
|
+
// │◄─┘ (row 2)
|
|
955
|
+
//
|
|
956
|
+
// The loop is only SELF_LOOP_WIDTH (4) columns wide by default, with no
|
|
957
|
+
// spare room for a second arrowhead without corrupting the loop's
|
|
958
|
+
// corner glyphs — so a bidirectional self-message still parses and
|
|
959
|
+
// draws a single arrowhead only. An autonumbered self-message widens
|
|
960
|
+
// the loop (see selfLoopWidth) so the badge digits fit at the start
|
|
961
|
+
// of the top arm without touching the corner.
|
|
962
|
+
const y0 = msgArrowY[m]!
|
|
963
|
+
const loopW = selfLoopWidth(msg)
|
|
964
|
+
const numStr =
|
|
965
|
+
msg.seqNumber === undefined ? undefined : String(msg.seqNumber)
|
|
966
|
+
// Split the label on <br/>-normalized newlines so multi-line self-arrow
|
|
967
|
+
// labels get one row each instead of dumping a literal \n mid-row.
|
|
968
|
+
const msgLines = splitLines(msg.label)
|
|
969
|
+
|
|
970
|
+
// Row 0: start junction + [autonumber badge] + horizontal + top-right
|
|
971
|
+
// corner. The badge, when present, replaces the leading dashes rather
|
|
972
|
+
// than sitting alongside them — mirroring how the normal-message
|
|
973
|
+
// badge overwrites the start of its arrow line.
|
|
974
|
+
setC(fromX, y0, JL, 'junction')
|
|
975
|
+
let dashStart = fromX + 1
|
|
976
|
+
if (numStr !== undefined) {
|
|
977
|
+
for (let i = 0; i < numStr.length; i++)
|
|
978
|
+
setC(fromX + 1 + i, y0, numStr[i]!, 'text')
|
|
979
|
+
dashStart = fromX + 1 + numStr.length
|
|
980
|
+
}
|
|
981
|
+
for (let x = dashStart; x < fromX + loopW; x++)
|
|
982
|
+
setC(x, y0, lineChar, 'line')
|
|
983
|
+
setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner')
|
|
984
|
+
|
|
985
|
+
// Label rows: vertical on right side + one line of label text each
|
|
986
|
+
const labelX = fromX + loopW + 2
|
|
987
|
+
for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {
|
|
988
|
+
const rowY = y0 + 1 + lineIdx
|
|
989
|
+
setC(fromX + loopW, rowY, V, 'line')
|
|
990
|
+
writeTextCells(labelX, rowY, msgLines[lineIdx]!, 'text', totalW)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Bottom row: arrow-back + horizontal + bottom-right corner
|
|
994
|
+
const bottomY = y0 + 1 + msgLines.length
|
|
995
|
+
const arrowChar = isLost
|
|
996
|
+
? lostChar
|
|
997
|
+
: isFilled
|
|
998
|
+
? useAscii
|
|
999
|
+
? '<'
|
|
1000
|
+
: '◀'
|
|
1001
|
+
: useAscii
|
|
1002
|
+
? '<'
|
|
1003
|
+
: '◁'
|
|
1004
|
+
setC(fromX, bottomY, arrowChar, 'arrow')
|
|
1005
|
+
for (let x = fromX + 1; x < fromX + loopW; x++)
|
|
1006
|
+
setC(x, bottomY, lineChar, 'line')
|
|
1007
|
+
setC(fromX + loopW, bottomY, useAscii ? '+' : '┘', 'corner')
|
|
1008
|
+
} else {
|
|
1009
|
+
// Normal message: label on row above, arrow on row below
|
|
1010
|
+
const labelY = msgLabelY[m]!
|
|
1011
|
+
const arrowY = msgArrowY[m]!
|
|
1012
|
+
const leftToRight = fromX < toX
|
|
1013
|
+
|
|
1014
|
+
// A creating message's arrow stops at the created box's near border
|
|
1015
|
+
// instead of the lifeline centre (the box occupies this row — see the
|
|
1016
|
+
// lifecycle comment near createdBoxAbove). `headX` is where the
|
|
1017
|
+
// arrowhead goes; the line runs from the sender up to it.
|
|
1018
|
+
const createsRecipient = createdByMsg.get(m) === ti
|
|
1019
|
+
let headX = toX
|
|
1020
|
+
if (createsRecipient) {
|
|
1021
|
+
const boxLeft = actorBoxLeft(ti, toX)
|
|
1022
|
+
const boxRight = boxLeft + actorBoxWidths[ti]! - 1
|
|
1023
|
+
headX = leftToRight ? boxLeft - 1 : boxRight + 1
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Draw label centered between the two lifelines (supports multi-line)
|
|
1027
|
+
const midX = Math.floor((fromX + toX) / 2)
|
|
1028
|
+
const msgLines = splitLines(msg.label)
|
|
1029
|
+
|
|
1030
|
+
for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {
|
|
1031
|
+
const line = msgLines[lineIdx]!
|
|
1032
|
+
// Center on display width, not `.length` — see writeTextCells' doc
|
|
1033
|
+
// comment for why a code-unit-based offset under-centers CJK labels.
|
|
1034
|
+
const labelStart = midX - Math.floor(displayWidth(line) / 2)
|
|
1035
|
+
const y = labelY + lineIdx
|
|
1036
|
+
writeTextCells(labelStart, y, line, 'text', totalW)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Draw arrow line
|
|
1040
|
+
if (leftToRight) {
|
|
1041
|
+
for (let x = fromX + 1; x < headX; x++)
|
|
1042
|
+
setC(x, arrowY, lineChar, 'line')
|
|
1043
|
+
// Arrowhead at destination
|
|
1044
|
+
const ah = isLost
|
|
1045
|
+
? lostChar
|
|
1046
|
+
: isFilled
|
|
1047
|
+
? useAscii
|
|
1048
|
+
? '>'
|
|
1049
|
+
: '▶'
|
|
1050
|
+
: useAscii
|
|
1051
|
+
? '>'
|
|
1052
|
+
: '▷'
|
|
1053
|
+
setC(headX, arrowY, ah, 'arrow')
|
|
1054
|
+
// Bidirectional (`<<->>` / `<<-->>`): mirror the arrowhead at the
|
|
1055
|
+
// departure end too. Both bidirectional tokens end in ">>" (see
|
|
1056
|
+
// parser.ts), so isFilled is always true here — no open-head
|
|
1057
|
+
// variant to branch on, unlike the one-way `ah` glyph above.
|
|
1058
|
+
if (msg.bidirectional) {
|
|
1059
|
+
const ahStart = useAscii ? '<' : '◀'
|
|
1060
|
+
setC(fromX, arrowY, ahStart, 'arrow')
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
for (let x = headX + 1; x < fromX; x++)
|
|
1064
|
+
setC(x, arrowY, lineChar, 'line')
|
|
1065
|
+
const ah = isLost
|
|
1066
|
+
? lostChar
|
|
1067
|
+
: isFilled
|
|
1068
|
+
? useAscii
|
|
1069
|
+
? '<'
|
|
1070
|
+
: '◀'
|
|
1071
|
+
: useAscii
|
|
1072
|
+
? '<'
|
|
1073
|
+
: '◁'
|
|
1074
|
+
setC(headX, arrowY, ah, 'arrow')
|
|
1075
|
+
if (msg.bidirectional) {
|
|
1076
|
+
const ahStart = useAscii ? '>' : '▶'
|
|
1077
|
+
setC(fromX, arrowY, ahStart, 'arrow')
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// autonumber badge: overwrite the start of the arrow line with the
|
|
1082
|
+
// sequence number, mirroring the small circled number the SVG
|
|
1083
|
+
// renderer draws over the start of the arrow (see renderer.ts's
|
|
1084
|
+
// renderSeqNumberBadge). Skipped if it wouldn't fit before the
|
|
1085
|
+
// opposite arrowhead — the minimum lifeline gap (10 cols) comfortably
|
|
1086
|
+
// fits typical 1-3 digit sequence numbers alongside short labels.
|
|
1087
|
+
if (msg.seqNumber !== undefined) {
|
|
1088
|
+
const numStr = String(msg.seqNumber)
|
|
1089
|
+
if (leftToRight) {
|
|
1090
|
+
const start = fromX + 1
|
|
1091
|
+
if (start + numStr.length < toX) {
|
|
1092
|
+
for (let i = 0; i < numStr.length; i++)
|
|
1093
|
+
setC(start + i, arrowY, numStr[i]!, 'text')
|
|
1094
|
+
}
|
|
1095
|
+
} else {
|
|
1096
|
+
const start = fromX - numStr.length
|
|
1097
|
+
if (start > headX) {
|
|
1098
|
+
for (let i = 0; i < numStr.length; i++)
|
|
1099
|
+
setC(start + i, arrowY, numStr[i]!, 'text')
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---- DRAW: destroy crosses ----
|
|
1107
|
+
|
|
1108
|
+
// Same glyph as a lost message's terminator, on the destroyed actor's
|
|
1109
|
+
// lifeline one row under the destroying arrow (see the lifecycle comment
|
|
1110
|
+
// near createdBoxAbove for why not on the arrow row itself).
|
|
1111
|
+
for (const [i, y] of destroyRow) {
|
|
1112
|
+
setC(llX[i]!, y, useAscii ? 'x' : '✕', 'arrow')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- DRAW: blocks (loop, alt, opt, par, etc.) ----
|
|
1116
|
+
|
|
1117
|
+
// Largest column index it's currently safe to write a block wall into.
|
|
1118
|
+
// Starts at the canvas's own right margin (mirrors the historical
|
|
1119
|
+
// `totalW - 1` clamp) and is pushed out via increaseSize/
|
|
1120
|
+
// increaseRoleCanvasSize below whenever a wall needs to grow past it.
|
|
1121
|
+
let blockCanvasMaxX = totalW - 1
|
|
1122
|
+
|
|
1123
|
+
for (let b = 0; b < diagram.blocks.length; b++) {
|
|
1124
|
+
const block = diagram.blocks[b]!
|
|
1125
|
+
const topY = blockStartY.get(b)
|
|
1126
|
+
const botY = blockEndY.get(b)
|
|
1127
|
+
if (topY === undefined || botY === undefined) continue
|
|
1128
|
+
|
|
1129
|
+
// Find the leftmost/rightmost lifelines involved in this block's messages
|
|
1130
|
+
let minLX = totalW
|
|
1131
|
+
let maxLX = 0
|
|
1132
|
+
for (let m = block.startIndex; m <= block.endIndex; m++) {
|
|
1133
|
+
if (m >= diagram.messages.length) break
|
|
1134
|
+
const msg = diagram.messages[m]!
|
|
1135
|
+
const f = actorIdx.get(msg.from) ?? 0
|
|
1136
|
+
const t = actorIdx.get(msg.to) ?? 0
|
|
1137
|
+
minLX = Math.min(minLX, llX[Math.min(f, t)]!)
|
|
1138
|
+
maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)
|
|
1139
|
+
// Self-arrows draw their loop glyphs (├──┐ … ◀──┘) and label further
|
|
1140
|
+
// right than the lifeline itself — account for that extent too, or a
|
|
1141
|
+
// long self-arrow label gets clipped by / drawn outside the wall.
|
|
1142
|
+
if (f === t) {
|
|
1143
|
+
const selfRight =
|
|
1144
|
+
llX[f]! + selfLoopWidth(msg) + 2 + maxLineWidth(msg.label)
|
|
1145
|
+
maxLX = Math.max(maxLX, selfRight)
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
let bLeft = minLX - BLOCK_WALL_MARGIN
|
|
1150
|
+
let bRight = maxLX + BLOCK_WALL_MARGIN
|
|
1151
|
+
|
|
1152
|
+
// minLX/maxLX (and therefore bLeft/bRight) only account for lifelines
|
|
1153
|
+
// *this block's own messages* touch. That leaves a gap: the fixed
|
|
1154
|
+
// margin above can coincidentally place a wall exactly on — or past —
|
|
1155
|
+
// a different, untouched lifeline's column (#353).
|
|
1156
|
+
//
|
|
1157
|
+
// The fix is to PULL the wall back short of that lifeline, not push it
|
|
1158
|
+
// past. Verified against real mermaid.js's own SVG output for this
|
|
1159
|
+
// exact diagram (see scripts/lib/real-mermaid.ts, the same engine
|
|
1160
|
+
// behind GitHub's own mermaid preview): `loop`/`opt` enclose `Database`
|
|
1161
|
+
// there because their own messages touch it directly, but `alt` —
|
|
1162
|
+
// which never messages `Database` — stops well short of it (124px of
|
|
1163
|
+
// real clearance, not a few px of overshoot), even though `loop`/`opt`
|
|
1164
|
+
// in the same diagram extend ~11px *past* Database's lifeline to
|
|
1165
|
+
// enclose it. Real mermaid never widens a block's wall to enclose a
|
|
1166
|
+
// lifeline its own messages don't touch; it only ever clears one it
|
|
1167
|
+
// was already going to reach. Applies to every block type alike, both
|
|
1168
|
+
// walls.
|
|
1169
|
+
let nextRightLL = Number.POSITIVE_INFINITY
|
|
1170
|
+
let nextLeftLL = Number.NEGATIVE_INFINITY
|
|
1171
|
+
for (const x of llX) {
|
|
1172
|
+
if (x > maxLX && x < nextRightLL) nextRightLL = x
|
|
1173
|
+
if (x < minLX && x > nextLeftLL) nextLeftLL = x
|
|
1174
|
+
}
|
|
1175
|
+
// Never pull back past maxLX/minLX themselves — those already include
|
|
1176
|
+
// the self-arrow extent computed above, and an untouched lifeline
|
|
1177
|
+
// sitting close enough behind one can otherwise pull bRight below the
|
|
1178
|
+
// self-arrow's own label, which the later block-border draw then
|
|
1179
|
+
// overwrites (the label silently loses characters — CodeRabbit caught
|
|
1180
|
+
// this on this exact fix). Clearing the untouched lifeline yields to
|
|
1181
|
+
// not clipping this block's own content when the two can't both fit.
|
|
1182
|
+
if (bRight >= nextRightLL)
|
|
1183
|
+
bRight = Math.max(maxLX, nextRightLL - BLOCK_WALL_MARGIN)
|
|
1184
|
+
if (bLeft <= nextLeftLL)
|
|
1185
|
+
bLeft = Math.min(minLX, nextLeftLL + BLOCK_WALL_MARGIN)
|
|
1186
|
+
|
|
1187
|
+
bLeft = Math.max(0, bLeft)
|
|
1188
|
+
if (bRight > blockCanvasMaxX) {
|
|
1189
|
+
increaseSize(canvas, bRight + 1, totalH - 1)
|
|
1190
|
+
increaseRoleCanvasSize(rc, bRight + 1, totalH - 1)
|
|
1191
|
+
blockCanvasMaxX = bRight
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Header ("alt [label]") and divider ("[else label]") text is drawn
|
|
1195
|
+
// starting at bLeft + 1 (see below), clipped to whatever bRight the
|
|
1196
|
+
// pull-back above produced. A wall sized purely from message spans
|
|
1197
|
+
// (even after that untouched-lifeline pull-back) has no relationship
|
|
1198
|
+
// to label length, so a long condition label was silently cut off
|
|
1199
|
+
// mid-word instead of the block widening to fit it (#352). Measure the
|
|
1200
|
+
// longest label among the header and every divider up front and widen
|
|
1201
|
+
// the wall — and the canvas itself, if the extra room isn't already
|
|
1202
|
+
// there — to fit it before any drawing happens.
|
|
1203
|
+
//
|
|
1204
|
+
// `bLeft` here MUST be the fully-resolved value above (post pull-back,
|
|
1205
|
+
// post clamp) — computing `neededRight` from an intermediate bLeft
|
|
1206
|
+
// would silently under-widen whenever minLX/the pull-back puts bLeft
|
|
1207
|
+
// below its margin (this repo's own #352 repro hits exactly that case:
|
|
1208
|
+
// `A` is actor 0, so minLX - BLOCK_WALL_MARGIN is negative and bLeft
|
|
1209
|
+
// only becomes 0 via the clamp above). Any participant this widening
|
|
1210
|
+
// would otherwise swallow was already pushed further right by the
|
|
1211
|
+
// lifeline-shifting layout pass earlier in this function, so growing
|
|
1212
|
+
// bRight here doesn't re-collide with whatever the pull-back above
|
|
1213
|
+
// just cleared.
|
|
1214
|
+
const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type
|
|
1215
|
+
const neededRight = bLeft + 1 + maxBlockLabelWidth(block)
|
|
1216
|
+
if (neededRight > bRight) {
|
|
1217
|
+
bRight = neededRight
|
|
1218
|
+
const [canvasMaxX] = getCanvasSize(canvas)
|
|
1219
|
+
if (bRight > canvasMaxX) {
|
|
1220
|
+
increaseSize(canvas, bRight, totalH - 1)
|
|
1221
|
+
increaseRoleCanvasSize(rc, bRight, totalH - 1)
|
|
1222
|
+
}
|
|
1223
|
+
totalW = Math.max(totalW, bRight + 1)
|
|
1224
|
+
blockCanvasMaxX = Math.max(blockCanvasMaxX, bRight)
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Top border with block type label
|
|
1228
|
+
setC(bLeft, topY, TL, 'border')
|
|
1229
|
+
for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border')
|
|
1230
|
+
setC(bRight, topY, TR, 'border')
|
|
1231
|
+
// Write block header label over the top border (supports multi-line)
|
|
1232
|
+
const hdrLines = splitLines(hdrLabel)
|
|
1233
|
+
|
|
1234
|
+
for (
|
|
1235
|
+
let lineIdx = 0;
|
|
1236
|
+
lineIdx < hdrLines.length && topY + lineIdx < botY;
|
|
1237
|
+
lineIdx++
|
|
1238
|
+
) {
|
|
1239
|
+
writeTextCells(
|
|
1240
|
+
bLeft + 1,
|
|
1241
|
+
topY + lineIdx,
|
|
1242
|
+
hdrLines[lineIdx]!,
|
|
1243
|
+
'text',
|
|
1244
|
+
bRight,
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Bottom border
|
|
1249
|
+
setC(bLeft, botY, BL, 'border')
|
|
1250
|
+
for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border')
|
|
1251
|
+
setC(bRight, botY, BR, 'border')
|
|
1252
|
+
|
|
1253
|
+
// Side borders
|
|
1254
|
+
for (let y = topY + 1; y < botY; y++) {
|
|
1255
|
+
setC(bLeft, y, V, 'border')
|
|
1256
|
+
setC(bRight, y, V, 'border')
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Dividers
|
|
1260
|
+
for (let d = 0; d < block.dividers.length; d++) {
|
|
1261
|
+
const dY = divYMap.get(`${b}:${d}`)
|
|
1262
|
+
if (dY === undefined) continue
|
|
1263
|
+
const dashChar = isDashedH()
|
|
1264
|
+
setC(bLeft, dY, JL, 'junction')
|
|
1265
|
+
for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line')
|
|
1266
|
+
setC(bRight, dY, JR, 'junction')
|
|
1267
|
+
// Divider label
|
|
1268
|
+
const dLabel = block.dividers[d]!.label
|
|
1269
|
+
if (dLabel) {
|
|
1270
|
+
const dStr = `[${dLabel}]`
|
|
1271
|
+
writeTextCells(bLeft + 1, dY, dStr, 'text', bRight)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ---- DRAW: notes ----
|
|
1277
|
+
|
|
1278
|
+
for (const np of notePositions) {
|
|
1279
|
+
// Ensure canvas is big enough
|
|
1280
|
+
increaseSize(canvas, np.x + np.width, np.y + np.height)
|
|
1281
|
+
increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height)
|
|
1282
|
+
// Top border
|
|
1283
|
+
setC(np.x, np.y, TL, 'border')
|
|
1284
|
+
for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border')
|
|
1285
|
+
setC(np.x + np.width - 1, np.y, TR, 'border')
|
|
1286
|
+
// Content rows
|
|
1287
|
+
for (let l = 0; l < np.lines.length; l++) {
|
|
1288
|
+
const ly = np.y + 1 + l
|
|
1289
|
+
setC(np.x, ly, V, 'border')
|
|
1290
|
+
setC(np.x + np.width - 1, ly, V, 'border')
|
|
1291
|
+
// Blank the full interior (content + padding columns) first — the
|
|
1292
|
+
// note is drawn over lifelines that were already painted down every
|
|
1293
|
+
// row in this span, and writeTextCells below only touches the exact
|
|
1294
|
+
// cells the text occupies. Whenever a note's computed width happens
|
|
1295
|
+
// to put its own padding column on top of a lifeline's x position
|
|
1296
|
+
// (e.g. "Note over A,B" wide enough to reach B's lifeline), that
|
|
1297
|
+
// untouched padding column lets the stale lifeline character leak
|
|
1298
|
+
// through as a doubled border glyph right next to the note's own
|
|
1299
|
+
// border.
|
|
1300
|
+
for (let x = np.x + 1; x < np.x + np.width - 1; x++) {
|
|
1301
|
+
setC(x, ly, ' ', 'text')
|
|
1302
|
+
}
|
|
1303
|
+
writeTextCells(np.x + 1 + boxPad, ly, np.lines[l]!, 'text')
|
|
1304
|
+
}
|
|
1305
|
+
// Bottom border
|
|
1306
|
+
const by = np.y + np.height - 1
|
|
1307
|
+
setC(np.x, by, BL, 'border')
|
|
1308
|
+
for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border')
|
|
1309
|
+
setC(np.x + np.width - 1, by, BR, 'border')
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
|
|
1313
|
+
|
|
1314
|
+
// ---- Helper: dashed horizontal character ----
|
|
1315
|
+
function isDashedH(): string {
|
|
1316
|
+
return useAscii ? '-' : '╌'
|
|
1317
|
+
}
|
|
1318
|
+
}
|