@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/canvas.ts
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — 2D text canvas
|
|
3
|
+
//
|
|
4
|
+
// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go.
|
|
5
|
+
// The canvas is a column-major 2D array of single-character strings.
|
|
6
|
+
// canvas[x][y] gives the character at column x, row y.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
Canvas,
|
|
11
|
+
DrawingCoord,
|
|
12
|
+
RoleCanvas,
|
|
13
|
+
CharRole,
|
|
14
|
+
AsciiTheme,
|
|
15
|
+
ColorMode,
|
|
16
|
+
} from './types.ts'
|
|
17
|
+
import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts'
|
|
18
|
+
import { toDisplayCells } from './display-width.ts'
|
|
19
|
+
import { joinWithLinks } from './hyperlinks.ts'
|
|
20
|
+
import type { LinkCanvas } from './hyperlinks.ts'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a blank canvas filled with spaces.
|
|
24
|
+
* Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2).
|
|
25
|
+
*/
|
|
26
|
+
export function mkCanvas(x: number, y: number): Canvas {
|
|
27
|
+
const canvas: Canvas = []
|
|
28
|
+
for (let i = 0; i <= x; i++) {
|
|
29
|
+
const col: string[] = []
|
|
30
|
+
for (let j = 0; j <= y; j++) {
|
|
31
|
+
col.push(' ')
|
|
32
|
+
}
|
|
33
|
+
canvas.push(col)
|
|
34
|
+
}
|
|
35
|
+
return canvas
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create a blank canvas with the same dimensions as the given canvas. */
|
|
39
|
+
export function copyCanvas(source: Canvas): Canvas {
|
|
40
|
+
const [maxX, maxY] = getCanvasSize(source)
|
|
41
|
+
return mkCanvas(maxX, maxY)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Role canvas creation and management
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a blank role canvas filled with nulls.
|
|
50
|
+
* Same dimensions as mkCanvas — column-major, roleCanvas[x][y].
|
|
51
|
+
*/
|
|
52
|
+
export function mkRoleCanvas(x: number, y: number): RoleCanvas {
|
|
53
|
+
const roleCanvas: RoleCanvas = []
|
|
54
|
+
for (let i = 0; i <= x; i++) {
|
|
55
|
+
const col: (CharRole | null)[] = []
|
|
56
|
+
for (let j = 0; j <= y; j++) {
|
|
57
|
+
col.push(null)
|
|
58
|
+
}
|
|
59
|
+
roleCanvas.push(col)
|
|
60
|
+
}
|
|
61
|
+
return roleCanvas
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Create a blank role canvas with the same dimensions as the given role canvas. */
|
|
65
|
+
export function copyRoleCanvas(source: RoleCanvas): RoleCanvas {
|
|
66
|
+
const maxX = source.length - 1
|
|
67
|
+
const maxY = (source[0]?.length ?? 1) - 1
|
|
68
|
+
return mkRoleCanvas(maxX, maxY)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Grow the role canvas to fit at least (newX, newY), preserving existing roles.
|
|
73
|
+
* Mutates the role canvas in place and returns it.
|
|
74
|
+
*/
|
|
75
|
+
export function increaseRoleCanvasSize(
|
|
76
|
+
roleCanvas: RoleCanvas,
|
|
77
|
+
newX: number,
|
|
78
|
+
newY: number,
|
|
79
|
+
): RoleCanvas {
|
|
80
|
+
const currX = roleCanvas.length - 1
|
|
81
|
+
const currY = (roleCanvas[0]?.length ?? 1) - 1
|
|
82
|
+
const targetX = Math.max(newX, currX)
|
|
83
|
+
const targetY = Math.max(newY, currY)
|
|
84
|
+
const grown = mkRoleCanvas(targetX, targetY)
|
|
85
|
+
for (let x = 0; x < grown.length; x++) {
|
|
86
|
+
for (let y = 0; y < (grown[0]?.length ?? 0); y++) {
|
|
87
|
+
if (x < roleCanvas.length && y < (roleCanvas[0]?.length ?? 0)) {
|
|
88
|
+
grown[x]![y] = roleCanvas[x]![y]!
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
roleCanvas.length = 0
|
|
93
|
+
roleCanvas.push(...grown)
|
|
94
|
+
return roleCanvas
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set a role at a specific coordinate.
|
|
99
|
+
* Expands the role canvas if necessary.
|
|
100
|
+
*/
|
|
101
|
+
export function setRole(
|
|
102
|
+
roleCanvas: RoleCanvas,
|
|
103
|
+
x: number,
|
|
104
|
+
y: number,
|
|
105
|
+
role: CharRole,
|
|
106
|
+
): void {
|
|
107
|
+
if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {
|
|
108
|
+
increaseRoleCanvasSize(roleCanvas, x, y)
|
|
109
|
+
}
|
|
110
|
+
roleCanvas[x]![y] = role
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Merge role canvases — same logic as mergeCanvases but for roles.
|
|
115
|
+
* Non-null roles in overlays overwrite null roles in base.
|
|
116
|
+
*/
|
|
117
|
+
export function mergeRoleCanvases(
|
|
118
|
+
base: RoleCanvas,
|
|
119
|
+
offset: DrawingCoord,
|
|
120
|
+
...overlays: RoleCanvas[]
|
|
121
|
+
): RoleCanvas {
|
|
122
|
+
let maxX = base.length - 1
|
|
123
|
+
let maxY = (base[0]?.length ?? 1) - 1
|
|
124
|
+
|
|
125
|
+
for (const overlay of overlays) {
|
|
126
|
+
const oX = overlay.length - 1
|
|
127
|
+
const oY = (overlay[0]?.length ?? 1) - 1
|
|
128
|
+
maxX = Math.max(maxX, oX + offset.x)
|
|
129
|
+
maxY = Math.max(maxY, oY + offset.y)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const merged = mkRoleCanvas(maxX, maxY)
|
|
133
|
+
|
|
134
|
+
// Copy base
|
|
135
|
+
for (let x = 0; x <= maxX; x++) {
|
|
136
|
+
for (let y = 0; y <= maxY; y++) {
|
|
137
|
+
if (x < base.length && y < (base[0]?.length ?? 0)) {
|
|
138
|
+
merged[x]![y] = base[x]![y]!
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Apply overlays
|
|
144
|
+
for (const overlay of overlays) {
|
|
145
|
+
for (let x = 0; x < overlay.length; x++) {
|
|
146
|
+
for (let y = 0; y < (overlay[0]?.length ?? 0); y++) {
|
|
147
|
+
const role = overlay[x]?.[y]
|
|
148
|
+
if (role !== null && role !== undefined) {
|
|
149
|
+
const mx = x + offset.x
|
|
150
|
+
const my = y + offset.y
|
|
151
|
+
merged[mx]![my] = role
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return merged
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Returns [maxX, maxY] — the highest valid indices in each dimension. */
|
|
161
|
+
export function getCanvasSize(canvas: Canvas): [number, number] {
|
|
162
|
+
return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Grow the canvas to fit at least (newX, newY), preserving existing content.
|
|
167
|
+
* Mutates the canvas in place and returns it.
|
|
168
|
+
*/
|
|
169
|
+
export function increaseSize(
|
|
170
|
+
canvas: Canvas,
|
|
171
|
+
newX: number,
|
|
172
|
+
newY: number,
|
|
173
|
+
): Canvas {
|
|
174
|
+
const [currX, currY] = getCanvasSize(canvas)
|
|
175
|
+
const targetX = Math.max(newX, currX)
|
|
176
|
+
const targetY = Math.max(newY, currY)
|
|
177
|
+
const grown = mkCanvas(targetX, targetY)
|
|
178
|
+
for (let x = 0; x < grown.length; x++) {
|
|
179
|
+
for (let y = 0; y < (grown[0]?.length ?? 0); y++) {
|
|
180
|
+
if (x < canvas.length && y < (canvas[0]?.length ?? 0)) {
|
|
181
|
+
grown[x]![y] = canvas[x]![y]!
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Mutate in place: splice old contents and replace with grown
|
|
186
|
+
canvas.length = 0
|
|
187
|
+
canvas.push(...grown)
|
|
188
|
+
return canvas
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Bounds-checked write to a single canvas cell.
|
|
193
|
+
*
|
|
194
|
+
* Sets the character at (x, y) if the coordinate falls within the canvas;
|
|
195
|
+
* out-of-range coordinates are silently clipped (a no-op) instead of
|
|
196
|
+
* mutating the canvas. This is the single write path drawing modules should
|
|
197
|
+
* use instead of indexing `canvas[x]![y] = ch` directly, which had three
|
|
198
|
+
* different out-of-range behaviors depending on axis — none of them a clean
|
|
199
|
+
* bounds check:
|
|
200
|
+
* - x out of range: threw, via the `canvas[x]!` non-null assertion.
|
|
201
|
+
* - y too large (but x in range): did *not* throw — JS silently
|
|
202
|
+
* ragged-extends that column's array. This was observable downstream:
|
|
203
|
+
* `mergeCanvases`/`canvasToString` would read the extended slot back as
|
|
204
|
+
* `undefined`, which `canvasToString` then concatenates into the output
|
|
205
|
+
* as the literal string `"undefined"`.
|
|
206
|
+
* - y negative (but x in range): silently set an own property keyed by
|
|
207
|
+
* the negative index (e.g. `arr[-1]`) rather than a real array element;
|
|
208
|
+
* rendering code never reads it back, so this was a silent no-op.
|
|
209
|
+
* `write()` normalizes all three cases to the same silent no-op. That is a
|
|
210
|
+
* behavior *change*, not a preservation of the prior status quo — callers
|
|
211
|
+
* migrating to `write()` need their own bounds check if they relied on the
|
|
212
|
+
* old per-axis behavior (see `drawSubgraphLabel` in `draw-subgraphs.ts`,
|
|
213
|
+
* which needs a tighter, exclusive bound than `write()`'s own clip to avoid
|
|
214
|
+
* overwriting a subgraph's border with an oversized title).
|
|
215
|
+
*
|
|
216
|
+
* When `roleTracking` is provided, the role is recorded via `setRole`
|
|
217
|
+
* alongside the character write (used by callers, like the sequence-diagram
|
|
218
|
+
* and xychart renderers, that track character roles inline rather than
|
|
219
|
+
* deriving them from the finished canvas afterward). `role` and
|
|
220
|
+
* `roleCanvas` are bundled into one parameter so a call can't pass one
|
|
221
|
+
* without the other — passing just a role with nowhere to record it would
|
|
222
|
+
* otherwise type-check while silently dropping the role, an error only
|
|
223
|
+
* visible in colorized/ANSI output.
|
|
224
|
+
*/
|
|
225
|
+
export function write(
|
|
226
|
+
canvas: Canvas,
|
|
227
|
+
x: number,
|
|
228
|
+
y: number,
|
|
229
|
+
ch: string,
|
|
230
|
+
roleTracking?: { role: CharRole; roleCanvas: RoleCanvas },
|
|
231
|
+
): void {
|
|
232
|
+
const [maxX, maxY] = getCanvasSize(canvas)
|
|
233
|
+
if (x < 0 || x > maxX || y < 0 || y > maxY) return
|
|
234
|
+
canvas[x]![y] = ch
|
|
235
|
+
if (roleTracking) {
|
|
236
|
+
setRole(roleTracking.roleCanvas, x, y, roleTracking.role)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Junction merging — Unicode box-drawing character compositing
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
/** All Unicode box-drawing characters that participate in junction merging. */
|
|
245
|
+
const JUNCTION_CHARS = new Set([
|
|
246
|
+
'─',
|
|
247
|
+
'│',
|
|
248
|
+
'┌',
|
|
249
|
+
'┐',
|
|
250
|
+
'└',
|
|
251
|
+
'┘',
|
|
252
|
+
'├',
|
|
253
|
+
'┤',
|
|
254
|
+
'┬',
|
|
255
|
+
'┴',
|
|
256
|
+
'┼',
|
|
257
|
+
'╴',
|
|
258
|
+
'╵',
|
|
259
|
+
'╶',
|
|
260
|
+
'╷',
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
export function isJunctionChar(c: string): boolean {
|
|
264
|
+
return JUNCTION_CHARS.has(c)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Check if a character is alphanumeric (part of a label). */
|
|
268
|
+
function isAlphanumeric(c: string): boolean {
|
|
269
|
+
return /^[a-zA-Z0-9]$/.test(c)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* When two junction characters overlap during canvas merging,
|
|
274
|
+
* resolve them to the correct combined junction.
|
|
275
|
+
* E.g., '─' overlapping '│' becomes '┼'.
|
|
276
|
+
*/
|
|
277
|
+
const JUNCTION_MAP: Record<string, Record<string, string>> = {
|
|
278
|
+
'─': {
|
|
279
|
+
'│': '┼',
|
|
280
|
+
'┌': '┬',
|
|
281
|
+
'┐': '┬',
|
|
282
|
+
'└': '┴',
|
|
283
|
+
'┘': '┴',
|
|
284
|
+
'├': '┼',
|
|
285
|
+
'┤': '┼',
|
|
286
|
+
'┬': '┬',
|
|
287
|
+
'┴': '┴',
|
|
288
|
+
},
|
|
289
|
+
'│': {
|
|
290
|
+
'─': '┼',
|
|
291
|
+
'┌': '├',
|
|
292
|
+
'┐': '┤',
|
|
293
|
+
'└': '├',
|
|
294
|
+
'┘': '┤',
|
|
295
|
+
'├': '├',
|
|
296
|
+
'┤': '┤',
|
|
297
|
+
'┬': '┼',
|
|
298
|
+
'┴': '┼',
|
|
299
|
+
},
|
|
300
|
+
'┌': {
|
|
301
|
+
'─': '┬',
|
|
302
|
+
'│': '├',
|
|
303
|
+
'┐': '┬',
|
|
304
|
+
'└': '├',
|
|
305
|
+
'┘': '┼',
|
|
306
|
+
'├': '├',
|
|
307
|
+
'┤': '┼',
|
|
308
|
+
'┬': '┬',
|
|
309
|
+
'┴': '┼',
|
|
310
|
+
},
|
|
311
|
+
'┐': {
|
|
312
|
+
'─': '┬',
|
|
313
|
+
'│': '┤',
|
|
314
|
+
'┌': '┬',
|
|
315
|
+
'└': '┼',
|
|
316
|
+
'┘': '┤',
|
|
317
|
+
'├': '┼',
|
|
318
|
+
'┤': '┤',
|
|
319
|
+
'┬': '┬',
|
|
320
|
+
'┴': '┼',
|
|
321
|
+
},
|
|
322
|
+
'└': {
|
|
323
|
+
'─': '┴',
|
|
324
|
+
'│': '├',
|
|
325
|
+
'┌': '├',
|
|
326
|
+
'┐': '┼',
|
|
327
|
+
'┘': '┴',
|
|
328
|
+
'├': '├',
|
|
329
|
+
'┤': '┼',
|
|
330
|
+
'┬': '┼',
|
|
331
|
+
'┴': '┴',
|
|
332
|
+
},
|
|
333
|
+
'┘': {
|
|
334
|
+
'─': '┴',
|
|
335
|
+
'│': '┤',
|
|
336
|
+
'┌': '┼',
|
|
337
|
+
'┐': '┤',
|
|
338
|
+
'└': '┴',
|
|
339
|
+
'├': '┼',
|
|
340
|
+
'┤': '┤',
|
|
341
|
+
'┬': '┼',
|
|
342
|
+
'┴': '┴',
|
|
343
|
+
},
|
|
344
|
+
'├': {
|
|
345
|
+
'─': '┼',
|
|
346
|
+
'│': '├',
|
|
347
|
+
'┌': '├',
|
|
348
|
+
'┐': '┼',
|
|
349
|
+
'└': '├',
|
|
350
|
+
'┘': '┼',
|
|
351
|
+
'┤': '┼',
|
|
352
|
+
'┬': '┼',
|
|
353
|
+
'┴': '┼',
|
|
354
|
+
},
|
|
355
|
+
'┤': {
|
|
356
|
+
'─': '┼',
|
|
357
|
+
'│': '┤',
|
|
358
|
+
'┌': '┼',
|
|
359
|
+
'┐': '┤',
|
|
360
|
+
'└': '┼',
|
|
361
|
+
'┘': '┤',
|
|
362
|
+
'├': '┼',
|
|
363
|
+
'┬': '┼',
|
|
364
|
+
'┴': '┼',
|
|
365
|
+
},
|
|
366
|
+
'┬': {
|
|
367
|
+
'─': '┬',
|
|
368
|
+
'│': '┼',
|
|
369
|
+
'┌': '┬',
|
|
370
|
+
'┐': '┬',
|
|
371
|
+
'└': '┼',
|
|
372
|
+
'┘': '┼',
|
|
373
|
+
'├': '┼',
|
|
374
|
+
'┤': '┼',
|
|
375
|
+
'┴': '┼',
|
|
376
|
+
},
|
|
377
|
+
'┴': {
|
|
378
|
+
'─': '┴',
|
|
379
|
+
'│': '┼',
|
|
380
|
+
'┌': '┼',
|
|
381
|
+
'┐': '┼',
|
|
382
|
+
'└': '┴',
|
|
383
|
+
'┘': '┴',
|
|
384
|
+
'├': '┼',
|
|
385
|
+
'┤': '┼',
|
|
386
|
+
'┬': '┼',
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function mergeJunctions(c1: string, c2: string): string {
|
|
391
|
+
return JUNCTION_MAP[c1]?.[c2] ?? c1
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// Canvas merging — composite multiple canvases with offset
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Blank out (replace with a space) any cell in a *later* canvas that a
|
|
400
|
+
* *earlier* one in `canvases` already painted a non-space character into.
|
|
401
|
+
* Canvases are otherwise untouched — same length/shape, same content at
|
|
402
|
+
* every cell no earlier canvas has already claimed.
|
|
403
|
+
*
|
|
404
|
+
* `mergeCanvases` itself always lets the *last* non-space overlay win a
|
|
405
|
+
* shared cell (see its own doc), which is the right default for most of
|
|
406
|
+
* `draw.ts`'s layers (an arrowhead correctly overwrites the corner beneath
|
|
407
|
+
* it, a later label wins a real collision, etc.) — but for the *line*
|
|
408
|
+
* layer specifically, "last edge processed wins" is arbitrary and can pick
|
|
409
|
+
* the wrong one: `edge-cell-styles.ts`'s module doc already documents
|
|
410
|
+
* "first claim wins" as this renderer's intended rule for a cell two
|
|
411
|
+
* differently-styled, unrelated edges both route through (that module
|
|
412
|
+
* tracks exactly this to *detect* the conflict and trigger a reroute
|
|
413
|
+
* around it — see grid.ts's `rerouteAroundStyleConflicts`), but drawing
|
|
414
|
+
* itself never consulted that rule: every edge's line canvas painted in
|
|
415
|
+
* `graph.edges` order and `mergeCanvases` let whichever one came *last*
|
|
416
|
+
* silently overwrite an earlier edge's own, differently-styled glyph. Most
|
|
417
|
+
* of the time rerouting already prevents the two from sharing a cell at
|
|
418
|
+
* all, so this is a no-op; when it can't (e.g. a same-source fan-out with
|
|
419
|
+
* 3+ distinct styles has no fully conflict-free route among the routing
|
|
420
|
+
* candidates available — see #1067, "All Edge Styles"), applying "first
|
|
421
|
+
* claim wins" here at the character level, immediately before the line
|
|
422
|
+
* layer is merged, keeps whichever edge got there first rendered
|
|
423
|
+
* consistently in its own style instead of visibly switching styles
|
|
424
|
+
* mid-route where a later sibling's paint won by sheer draw order.
|
|
425
|
+
*
|
|
426
|
+
* Scoped to the line layer only (`draw.ts`'s `lineCanvases`) — corners,
|
|
427
|
+
* arrowheads, box-start connectors and labels are composited in their own
|
|
428
|
+
* later `mergeCanvases` passes with their existing (correct, layer-specific)
|
|
429
|
+
* merge semantics, untouched by this function.
|
|
430
|
+
*
|
|
431
|
+
* A claimed cell only suppresses a *later* canvas's character when the two
|
|
432
|
+
* wouldn't otherwise combine into something meaningful — i.e. when they
|
|
433
|
+
* aren't both plain Unicode junction characters (`isJunctionChar`). Two
|
|
434
|
+
* *different* junction characters at the same cell are usually a genuine
|
|
435
|
+
* perpendicular crossing (one edge's `─`, another's `│`), which `drawLine`
|
|
436
|
+
* relies on `mergeCanvases`'s own junction-merge logic to combine into `┼`
|
|
437
|
+
* — blanket-suppressing by coordinate alone would silently turn that
|
|
438
|
+
* crossing into whichever edge happened to draw first. Mixed-style
|
|
439
|
+
* characters (dashed `┄`/`┆`, heavy `━`/`┃`) are never junction chars, so
|
|
440
|
+
* they still fall through to plain first-claim suppression — the exact
|
|
441
|
+
* #1067 "All Edge Styles" scenario this function exists for.
|
|
442
|
+
*/
|
|
443
|
+
export function firstClaimWins(canvases: readonly Canvas[]): Canvas[] {
|
|
444
|
+
const claimed = new Map<string, string>()
|
|
445
|
+
const result: Canvas[] = []
|
|
446
|
+
for (const canvas of canvases) {
|
|
447
|
+
const [maxX, maxY] = getCanvasSize(canvas)
|
|
448
|
+
// `copyCanvas` (despite its name) returns a *blank* canvas of the same
|
|
449
|
+
// size, not a clone of `canvas`'s content — see its own doc. Every cell
|
|
450
|
+
// must be written explicitly below, not just the ones this function
|
|
451
|
+
// blanks out.
|
|
452
|
+
const out = copyCanvas(canvas)
|
|
453
|
+
for (let x = 0; x <= maxX; x++) {
|
|
454
|
+
for (let y = 0; y <= maxY; y++) {
|
|
455
|
+
const c = canvas[x]?.[y]
|
|
456
|
+
if (c === undefined || c === ' ') continue
|
|
457
|
+
const key = `${x},${y}`
|
|
458
|
+
const existing = claimed.get(key)
|
|
459
|
+
if (existing !== undefined) {
|
|
460
|
+
// A repeat of the *same* character (a collinear duplicate, e.g.
|
|
461
|
+
// two edges both drawing '─' through a shared trunk cell) is
|
|
462
|
+
// still a first-claim suppression, not a crossing — `mergeJunctions`
|
|
463
|
+
// has no entry for a character merged with itself and would just
|
|
464
|
+
// fall back to it, so letting it through would be a harmless but
|
|
465
|
+
// pointless no-op; treating it as "still claimed" keeps the rule
|
|
466
|
+
// simple and matches "collinear overlap" from the case that
|
|
467
|
+
// actually needs a merge (two *different* junction characters).
|
|
468
|
+
const isCrossing =
|
|
469
|
+
existing !== c && isJunctionChar(existing) && isJunctionChar(c)
|
|
470
|
+
if (!isCrossing) continue
|
|
471
|
+
} else {
|
|
472
|
+
claimed.set(key, c)
|
|
473
|
+
}
|
|
474
|
+
out[x]![y] = c
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
result.push(out)
|
|
478
|
+
}
|
|
479
|
+
return result
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Merge overlay canvases onto a base canvas at the given offset.
|
|
484
|
+
* Non-space characters in overlays overwrite the base.
|
|
485
|
+
* When both characters are Unicode junction chars, they're merged intelligently.
|
|
486
|
+
*/
|
|
487
|
+
export function mergeCanvases(
|
|
488
|
+
base: Canvas,
|
|
489
|
+
offset: DrawingCoord,
|
|
490
|
+
useAscii: boolean,
|
|
491
|
+
...overlays: Canvas[]
|
|
492
|
+
): Canvas {
|
|
493
|
+
let [maxX, maxY] = getCanvasSize(base)
|
|
494
|
+
for (const overlay of overlays) {
|
|
495
|
+
const [oX, oY] = getCanvasSize(overlay)
|
|
496
|
+
maxX = Math.max(maxX, oX + offset.x)
|
|
497
|
+
maxY = Math.max(maxY, oY + offset.y)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const merged = mkCanvas(maxX, maxY)
|
|
501
|
+
|
|
502
|
+
// Copy base
|
|
503
|
+
for (let x = 0; x <= maxX; x++) {
|
|
504
|
+
for (let y = 0; y <= maxY; y++) {
|
|
505
|
+
if (x < base.length && y < (base[0]?.length ?? 0)) {
|
|
506
|
+
merged[x]![y] = base[x]![y]!
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Apply overlays
|
|
512
|
+
for (const overlay of overlays) {
|
|
513
|
+
for (let x = 0; x < overlay.length; x++) {
|
|
514
|
+
for (let y = 0; y < (overlay[0]?.length ?? 0); y++) {
|
|
515
|
+
const c = overlay[x]![y]!
|
|
516
|
+
if (c !== ' ') {
|
|
517
|
+
const mx = x + offset.x
|
|
518
|
+
const my = y + offset.y
|
|
519
|
+
const current = merged[mx]![my]!
|
|
520
|
+
if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
|
|
521
|
+
merged[mx]![my] = mergeJunctions(current, c)
|
|
522
|
+
} else if (isAlphanumeric(current) && isAlphanumeric(c)) {
|
|
523
|
+
// Don't overwrite existing label text with new label text
|
|
524
|
+
// This prevents label collisions (first label wins)
|
|
525
|
+
} else {
|
|
526
|
+
merged[mx]![my] = c
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return merged
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ============================================================================
|
|
537
|
+
// Canvas → string conversion
|
|
538
|
+
// ============================================================================
|
|
539
|
+
|
|
540
|
+
/** Options for converting canvas to string with optional coloring. */
|
|
541
|
+
export interface CanvasToStringOptions {
|
|
542
|
+
/** Role canvas for applying colors. If not provided, output is plain text. */
|
|
543
|
+
roleCanvas?: RoleCanvas
|
|
544
|
+
/** Color mode for terminal output. Default: 'none' */
|
|
545
|
+
colorMode?: ColorMode
|
|
546
|
+
/** Theme colors for ASCII output. Uses default theme if not provided. */
|
|
547
|
+
theme?: AsciiTheme
|
|
548
|
+
/**
|
|
549
|
+
* Link canvas (see hyperlinks.ts) — each run of cells carrying an href is
|
|
550
|
+
* wrapped in an OSC 8 terminal-hyperlink escape pair. Ignored in 'html'
|
|
551
|
+
* color mode, which is rendered by a browser, not a terminal.
|
|
552
|
+
*/
|
|
553
|
+
linkCanvas?: LinkCanvas
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Convert the canvas to a multi-line string (row by row, left to right).
|
|
558
|
+
* Optionally applies ANSI color codes based on character roles, and OSC 8
|
|
559
|
+
* hyperlink escapes based on the link canvas.
|
|
560
|
+
*/
|
|
561
|
+
export function canvasToString(
|
|
562
|
+
canvas: Canvas,
|
|
563
|
+
options?: CanvasToStringOptions,
|
|
564
|
+
): string {
|
|
565
|
+
const [maxX, maxY] = getCanvasSize(canvas)
|
|
566
|
+
const lines: string[] = []
|
|
567
|
+
|
|
568
|
+
const roleCanvas = options?.roleCanvas
|
|
569
|
+
const colorMode = options?.colorMode ?? 'none'
|
|
570
|
+
const theme = options?.theme ?? DEFAULT_ASCII_THEME
|
|
571
|
+
const linkCanvas = colorMode === 'html' ? undefined : options?.linkCanvas
|
|
572
|
+
|
|
573
|
+
for (let y = 0; y <= maxY; y++) {
|
|
574
|
+
if (colorMode === 'none' || !roleCanvas) {
|
|
575
|
+
// Plain text output — no colors
|
|
576
|
+
const chars: string[] = []
|
|
577
|
+
for (let x = 0; x <= maxX; x++) {
|
|
578
|
+
chars.push(canvas[x]![y]!)
|
|
579
|
+
}
|
|
580
|
+
if (linkCanvas) {
|
|
581
|
+
const links = chars.map((_, x) => linkCanvas[x]?.[y] ?? null)
|
|
582
|
+
lines.push(joinWithLinks(chars, links))
|
|
583
|
+
} else {
|
|
584
|
+
lines.push(chars.join(''))
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
// Colored output — collect chars, roles, and links for this row
|
|
588
|
+
const chars: string[] = []
|
|
589
|
+
const roles: (CharRole | null)[] = []
|
|
590
|
+
const links: (string | null)[] = []
|
|
591
|
+
for (let x = 0; x <= maxX; x++) {
|
|
592
|
+
chars.push(canvas[x]![y]!)
|
|
593
|
+
roles.push(roleCanvas[x]?.[y] ?? null)
|
|
594
|
+
links.push(linkCanvas?.[x]?.[y] ?? null)
|
|
595
|
+
}
|
|
596
|
+
lines.push(
|
|
597
|
+
colorizeLine(
|
|
598
|
+
chars,
|
|
599
|
+
roles,
|
|
600
|
+
theme,
|
|
601
|
+
colorMode,
|
|
602
|
+
linkCanvas ? links : undefined,
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return lines.join('\n')
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ============================================================================
|
|
612
|
+
// Canvas vertical flip — used for BT (bottom-to-top) direction support.
|
|
613
|
+
//
|
|
614
|
+
// The ASCII renderer lays out graphs top-down (TD). For BT direction, we
|
|
615
|
+
// flip the finished canvas vertically and remap directional characters so
|
|
616
|
+
// arrows point upward and corners are mirrored correctly.
|
|
617
|
+
// ============================================================================
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Characters that change meaning when the Y-axis is flipped.
|
|
621
|
+
* Symmetric characters (─, │, ├, ┤, ┼) are unchanged.
|
|
622
|
+
*/
|
|
623
|
+
const VERTICAL_FLIP_MAP: Record<string, string> = {
|
|
624
|
+
// Unicode arrows
|
|
625
|
+
'▲': '▼',
|
|
626
|
+
'▼': '▲',
|
|
627
|
+
// Diagonal arrowheads — U+2196–U+2199, not the filled triangles (◢◣◤◥),
|
|
628
|
+
// since JetBrains Mono NL has no glyph for those at all. See
|
|
629
|
+
// draw-arrows.ts's drawArrowHead and issue #1062.
|
|
630
|
+
'↖': '↙',
|
|
631
|
+
'↙': '↖',
|
|
632
|
+
'↗': '↘',
|
|
633
|
+
'↘': '↗',
|
|
634
|
+
// ASCII arrows
|
|
635
|
+
'^': 'v',
|
|
636
|
+
v: '^',
|
|
637
|
+
// Unicode corners
|
|
638
|
+
'┌': '└',
|
|
639
|
+
'└': '┌',
|
|
640
|
+
'┐': '┘',
|
|
641
|
+
'┘': '┐',
|
|
642
|
+
// Unicode junctions (T-pieces flip vertically)
|
|
643
|
+
'┬': '┴',
|
|
644
|
+
'┴': '┬',
|
|
645
|
+
// Box-start junctions (exit points from node boxes)
|
|
646
|
+
'╵': '╷',
|
|
647
|
+
'╷': '╵',
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Flip the canvas vertically (mirror across the horizontal center).
|
|
652
|
+
* Reverses row order within each column and remaps directional characters
|
|
653
|
+
* (arrows, corners, junctions) so they point the correct way after flip.
|
|
654
|
+
*
|
|
655
|
+
* Used to transform a TD-rendered canvas into BT output.
|
|
656
|
+
* Mutates the canvas in place and returns it.
|
|
657
|
+
*/
|
|
658
|
+
export function flipCanvasVertically(canvas: Canvas): Canvas {
|
|
659
|
+
// Reverse each column array (Y-axis flip in column-major layout)
|
|
660
|
+
for (const col of canvas) {
|
|
661
|
+
col.reverse()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Remap directional characters that change meaning after vertical flip
|
|
665
|
+
for (const col of canvas) {
|
|
666
|
+
for (const [y, ch] of col.entries()) {
|
|
667
|
+
const flipped = VERTICAL_FLIP_MAP[ch]
|
|
668
|
+
if (flipped) col[y] = flipped
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return canvas
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Flip the role canvas vertically to match flipCanvasVertically.
|
|
677
|
+
* Mutates the role canvas in place and returns it.
|
|
678
|
+
*/
|
|
679
|
+
export function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas {
|
|
680
|
+
for (const col of roleCanvas) {
|
|
681
|
+
col.reverse()
|
|
682
|
+
}
|
|
683
|
+
return roleCanvas
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Draw text string onto the canvas starting at the given coordinate.
|
|
688
|
+
* By default, preserves existing non-space characters (labels don't overwrite each other).
|
|
689
|
+
* Set forceOverwrite=true to always overwrite (for box content).
|
|
690
|
+
*
|
|
691
|
+
* Wide characters (CJK/kana/hangul/fullwidth-form/emoji — see
|
|
692
|
+
* `isWideChar`/`toDisplayCells` in `display-width.ts`) occupy two grid cells:
|
|
693
|
+
* the glyph itself followed by a placeholder cell. This keeps grid-cell
|
|
694
|
+
* count in sync with the two terminal columns the glyph actually renders as,
|
|
695
|
+
* so text drawn here lines up with widths computed via `displayWidth`.
|
|
696
|
+
*/
|
|
697
|
+
export function drawText(
|
|
698
|
+
canvas: Canvas,
|
|
699
|
+
start: DrawingCoord,
|
|
700
|
+
text: string,
|
|
701
|
+
forceOverwrite = false,
|
|
702
|
+
): void {
|
|
703
|
+
const cells = toDisplayCells(text)
|
|
704
|
+
increaseSize(canvas, start.x + cells.length, start.y)
|
|
705
|
+
for (const [i, cell] of cells.entries()) {
|
|
706
|
+
const x = start.x + i
|
|
707
|
+
const current = canvas[x]![start.y]!
|
|
708
|
+
// Only write if target is empty or we're forcing overwrite
|
|
709
|
+
if (forceOverwrite || current === ' ') {
|
|
710
|
+
canvas[x]![start.y] = cell
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Set the canvas size to fit all grid columns and rows.
|
|
717
|
+
* Called after layout to ensure the canvas covers the full drawing area.
|
|
718
|
+
*
|
|
719
|
+
* `offsetX`/`offsetY` must be the same drawing-coordinate offset
|
|
720
|
+
* `gridToDrawingCoord` adds to every point it computes (`graph.offsetX`/
|
|
721
|
+
* `offsetY`, set by `offsetDrawingForSubgraphs`) — omitting them once left
|
|
722
|
+
* the canvas exactly that much too narrow/short, silently clipping any
|
|
723
|
+
* edge line whose drawing coordinate landed in the unreserved margin (see
|
|
724
|
+
* `createMapping`'s call site for the full story).
|
|
725
|
+
*/
|
|
726
|
+
export function setCanvasSizeToGrid(
|
|
727
|
+
canvas: Canvas,
|
|
728
|
+
columnWidth: Map<number, number>,
|
|
729
|
+
rowHeight: Map<number, number>,
|
|
730
|
+
offsetX = 0,
|
|
731
|
+
offsetY = 0,
|
|
732
|
+
): void {
|
|
733
|
+
let maxX = offsetX
|
|
734
|
+
let maxY = offsetY
|
|
735
|
+
for (const w of columnWidth.values()) maxX += w
|
|
736
|
+
for (const h of rowHeight.values()) maxY += h
|
|
737
|
+
increaseSize(canvas, maxX - 1, maxY - 1)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Set the role canvas size to match the grid dimensions.
|
|
742
|
+
* Should be called alongside setCanvasSizeToGrid, with the same
|
|
743
|
+
* `offsetX`/`offsetY` — see that function's doc.
|
|
744
|
+
*/
|
|
745
|
+
export function setRoleCanvasSizeToGrid(
|
|
746
|
+
roleCanvas: RoleCanvas,
|
|
747
|
+
columnWidth: Map<number, number>,
|
|
748
|
+
rowHeight: Map<number, number>,
|
|
749
|
+
offsetX = 0,
|
|
750
|
+
offsetY = 0,
|
|
751
|
+
): void {
|
|
752
|
+
let maxX = offsetX
|
|
753
|
+
let maxY = offsetY
|
|
754
|
+
for (const w of columnWidth.values()) maxX += w
|
|
755
|
+
for (const h of rowHeight.values()) maxY += h
|
|
756
|
+
increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1)
|
|
757
|
+
}
|