@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/grid.ts
ADDED
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — grid-based layout
|
|
3
|
+
//
|
|
4
|
+
// Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go.
|
|
5
|
+
// Places nodes on a logical grid, computes column/row sizes,
|
|
6
|
+
// converts grid coordinates to character-level drawing coordinates,
|
|
7
|
+
// and handles subgraph bounding boxes.
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
GridCoord,
|
|
12
|
+
DrawingCoord,
|
|
13
|
+
Direction,
|
|
14
|
+
AsciiEdge,
|
|
15
|
+
AsciiGraph,
|
|
16
|
+
AsciiNode,
|
|
17
|
+
AsciiSubgraph,
|
|
18
|
+
} from './types.ts'
|
|
19
|
+
import { gridKey, requireGridCoord } from './types.ts'
|
|
20
|
+
import { setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas.ts'
|
|
21
|
+
import {
|
|
22
|
+
determinePath,
|
|
23
|
+
determineLabelLine,
|
|
24
|
+
assignParallelEdgeLanes,
|
|
25
|
+
} from './edge-routing.ts'
|
|
26
|
+
import { analyzeEdgeBundles, processBundles } from './edge-bundling.ts'
|
|
27
|
+
import { createPathBudget } from './pathfinder.ts'
|
|
28
|
+
import {
|
|
29
|
+
isBlockFree,
|
|
30
|
+
placeBlock,
|
|
31
|
+
cloneGrid,
|
|
32
|
+
NODE_BLOCK_SIZE,
|
|
33
|
+
type Grid,
|
|
34
|
+
} from './grid-occupancy.ts'
|
|
35
|
+
import {
|
|
36
|
+
createEdgeCellStyles,
|
|
37
|
+
claimPathCells,
|
|
38
|
+
findStyleConflict,
|
|
39
|
+
createEdgeCellOwners,
|
|
40
|
+
claimPathOwners,
|
|
41
|
+
findUnrelatedOverlap,
|
|
42
|
+
type EdgeCellStyles,
|
|
43
|
+
type EdgeCellOwners,
|
|
44
|
+
} from './edge-cell-styles.ts'
|
|
45
|
+
import { drawBox } from './draw.ts'
|
|
46
|
+
import { getShapeDimensions } from './shapes/index.ts'
|
|
47
|
+
import { splitLines } from './multiline-utils.ts'
|
|
48
|
+
import { displayWidth } from './display-width.ts'
|
|
49
|
+
|
|
50
|
+
// `requireGridCoord` is defined in types.ts (a pure predicate over
|
|
51
|
+
// AsciiNode with no grid-state dependency) and re-exported here so the two
|
|
52
|
+
// existing call sites that import it from this module (edge-routing.ts,
|
|
53
|
+
// edge-bundling.ts) don't need to change.
|
|
54
|
+
export { requireGridCoord }
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Grid coordinate → drawing coordinate conversion
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert a grid coordinate to a drawing (character) coordinate.
|
|
62
|
+
* Sums column widths up to the target column, and row heights up to the target row,
|
|
63
|
+
* then centers within the cell.
|
|
64
|
+
*/
|
|
65
|
+
export function gridToDrawingCoord(
|
|
66
|
+
graph: AsciiGraph,
|
|
67
|
+
c: GridCoord,
|
|
68
|
+
dir?: Direction,
|
|
69
|
+
): DrawingCoord {
|
|
70
|
+
const target: GridCoord = dir ? { x: c.x + dir.x, y: c.y + dir.y } : c
|
|
71
|
+
|
|
72
|
+
let x = 0
|
|
73
|
+
for (let col = 0; col < target.x; col++) {
|
|
74
|
+
x += graph.columnWidth.get(col) ?? 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let y = 0
|
|
78
|
+
for (let row = 0; row < target.y; row++) {
|
|
79
|
+
y += graph.rowHeight.get(row) ?? 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const colW = graph.columnWidth.get(target.x) ?? 0
|
|
83
|
+
const rowH = graph.rowHeight.get(target.y) ?? 0
|
|
84
|
+
return {
|
|
85
|
+
x: x + Math.floor(colW / 2) + graph.offsetX,
|
|
86
|
+
y: y + Math.floor(rowH / 2) + graph.offsetY,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Convert a path of grid coords to drawing coords. */
|
|
91
|
+
export function lineToDrawing(
|
|
92
|
+
graph: AsciiGraph,
|
|
93
|
+
line: GridCoord[],
|
|
94
|
+
): DrawingCoord[] {
|
|
95
|
+
return line.map((c) => gridToDrawingCoord(graph, c))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Node placement on the grid
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reserve a 3x3 block in the grid for a node.
|
|
104
|
+
* If the requested position is occupied, recursively shift by 4 grid units
|
|
105
|
+
* (in the perpendicular direction based on effective direction) until a free spot is found.
|
|
106
|
+
*
|
|
107
|
+
* @param effectiveDir - Optional direction override. If not provided, uses the node's
|
|
108
|
+
* effective direction (subgraph direction if in a subgraph with override,
|
|
109
|
+
* otherwise graph direction).
|
|
110
|
+
*/
|
|
111
|
+
export function reserveSpotInGrid(
|
|
112
|
+
graph: AsciiGraph,
|
|
113
|
+
node: AsciiNode,
|
|
114
|
+
requested: GridCoord,
|
|
115
|
+
effectiveDir?: 'LR' | 'TD',
|
|
116
|
+
): GridCoord {
|
|
117
|
+
// Determine direction for collision handling
|
|
118
|
+
const dir = effectiveDir ?? getEffectiveDirection(graph, node)
|
|
119
|
+
|
|
120
|
+
if (!isBlockFree(graph.grid, requested, NODE_BLOCK_SIZE)) {
|
|
121
|
+
// Collision — shift perpendicular to main flow direction
|
|
122
|
+
if (dir === 'LR') {
|
|
123
|
+
return reserveSpotInGrid(
|
|
124
|
+
graph,
|
|
125
|
+
node,
|
|
126
|
+
{ x: requested.x, y: requested.y + 4 },
|
|
127
|
+
dir,
|
|
128
|
+
)
|
|
129
|
+
} else {
|
|
130
|
+
return reserveSpotInGrid(
|
|
131
|
+
graph,
|
|
132
|
+
node,
|
|
133
|
+
{ x: requested.x + 4, y: requested.y },
|
|
134
|
+
dir,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
placeBlock(graph.grid, requested, NODE_BLOCK_SIZE)
|
|
140
|
+
|
|
141
|
+
node.gridCoord = requested
|
|
142
|
+
return requested
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// Column width / row height computation
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set column widths and row heights for a node's 3x3 grid block.
|
|
151
|
+
* Each node occupies 3 columns (border, content, border) and 3 rows.
|
|
152
|
+
* Uses shape-aware dimensions to properly size non-rectangular shapes.
|
|
153
|
+
*/
|
|
154
|
+
export function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void {
|
|
155
|
+
const gc = requireGridCoord(node)
|
|
156
|
+
const padding = graph.config.boxBorderPadding
|
|
157
|
+
|
|
158
|
+
// Get shape-aware dimensions
|
|
159
|
+
const shapeDims = getShapeDimensions(node.shape, node.displayLabel, {
|
|
160
|
+
useAscii: graph.config.useAscii,
|
|
161
|
+
padding,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Use shape-provided grid dimensions
|
|
165
|
+
const colWidths = shapeDims.gridColumns
|
|
166
|
+
const rowHeights = shapeDims.gridRows
|
|
167
|
+
|
|
168
|
+
for (let idx = 0; idx < colWidths.length; idx++) {
|
|
169
|
+
const xCoord = gc.x + idx
|
|
170
|
+
const current = graph.columnWidth.get(xCoord) ?? 0
|
|
171
|
+
graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (let idx = 0; idx < rowHeights.length; idx++) {
|
|
175
|
+
const yCoord = gc.y + idx
|
|
176
|
+
const current = graph.rowHeight.get(yCoord) ?? 0
|
|
177
|
+
graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Padding column/row before the node (spacing between nodes)
|
|
181
|
+
if (gc.x > 0) {
|
|
182
|
+
const current = graph.columnWidth.get(gc.x - 1) ?? 0
|
|
183
|
+
graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (gc.y > 0) {
|
|
187
|
+
let basePadding = graph.config.paddingY
|
|
188
|
+
// Extra vertical padding for nodes with incoming edges from outside their subgraph
|
|
189
|
+
if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) {
|
|
190
|
+
const subgraphOverhead = 4
|
|
191
|
+
basePadding += subgraphOverhead
|
|
192
|
+
}
|
|
193
|
+
const current = graph.rowHeight.get(gc.y - 1) ?? 0
|
|
194
|
+
graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Ensure grid has width/height entries for all cells along an edge path. */
|
|
199
|
+
export function increaseGridSizeForPath(
|
|
200
|
+
graph: AsciiGraph,
|
|
201
|
+
path: GridCoord[],
|
|
202
|
+
): void {
|
|
203
|
+
for (const c of path) {
|
|
204
|
+
if (!graph.columnWidth.has(c.x)) {
|
|
205
|
+
graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2))
|
|
206
|
+
}
|
|
207
|
+
if (!graph.rowHeight.has(c.y)) {
|
|
208
|
+
graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Cap on re-route attempts for a single edge's cross-style conflicts.
|
|
215
|
+
*
|
|
216
|
+
* `determinePath`'s A* attempts respect the blocked cells added below, but
|
|
217
|
+
* its Case-4 direct-fallback (used when both A* attempts fail outright)
|
|
218
|
+
* draws a straight line ignoring occupancy entirely — so a pathological
|
|
219
|
+
* layout where every route is blocked could keep "finding" the same
|
|
220
|
+
* conflict forever. This bounds that to a handful of tries; if a genuine
|
|
221
|
+
* conflict survives them, the edge keeps its last-routed (still
|
|
222
|
+
* overlapping) path rather than looping — the same "graceful degradation
|
|
223
|
+
* over a hard failure" the render-wide `PathBudget` already applies to A*
|
|
224
|
+
* itself.
|
|
225
|
+
*/
|
|
226
|
+
const MAX_STYLE_CONFLICT_REROUTES = 8
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* After `edge` has been routed, check whether its path crosses a cell
|
|
230
|
+
* already claimed by a *different*-style edge, or by an unrelated edge's
|
|
231
|
+
* run of cells long enough to read as one continuous connector (see
|
|
232
|
+
* edge-cell-styles.ts's two conflict checks) and, if so, re-route it around
|
|
233
|
+
* the conflicting cell(s).
|
|
234
|
+
*
|
|
235
|
+
* Works by temporarily adding the conflicting cell to `graph.grid` — the
|
|
236
|
+
* same occupancy map A* already treats node cells as blocked through — so
|
|
237
|
+
* `determinePath`'s A* search avoids it on the next attempt, then removing
|
|
238
|
+
* that temporary block again once this edge is done (it must not
|
|
239
|
+
* permanently block the cell for other, unrelated edges; only these two
|
|
240
|
+
* conflict shapes are meant to be avoided, not all future overlap).
|
|
241
|
+
*
|
|
242
|
+
* `nodeOnlyGrid` — not `graph.grid` — is what gets passed to
|
|
243
|
+
* `findStyleConflict`/`findUnrelatedOverlap`'s "is this cell node-owned, and
|
|
244
|
+
* therefore not a real conflict" check. This must be a separate,
|
|
245
|
+
* frozen-at-node-placement-time grid: `graph.grid` gets `add`ed to right
|
|
246
|
+
* below for A*'s benefit, and if the *conflict check* used that same live
|
|
247
|
+
* grid, a cell temporarily blocked on attempt 1 would look node-occupied on
|
|
248
|
+
* attempt 2 — so if A*'s direct-fallback (which ignores occupancy) routes
|
|
249
|
+
* right back through it, the loop would wrongly see "no conflict" and stop,
|
|
250
|
+
* leaving the two edges still overlapping there. See
|
|
251
|
+
* ascii-edge-cross-style-overlap.test.ts's regression test for this exact
|
|
252
|
+
* scenario.
|
|
253
|
+
*/
|
|
254
|
+
function rerouteAroundStyleConflicts(
|
|
255
|
+
graph: AsciiGraph,
|
|
256
|
+
edge: AsciiEdge,
|
|
257
|
+
cellStyles: EdgeCellStyles,
|
|
258
|
+
cellOwners: EdgeCellOwners,
|
|
259
|
+
nodeOnlyGrid: Grid,
|
|
260
|
+
): void {
|
|
261
|
+
const temporarilyBlocked: GridCoord[] = []
|
|
262
|
+
try {
|
|
263
|
+
for (let i = 0; i < MAX_STYLE_CONFLICT_REROUTES; i++) {
|
|
264
|
+
const conflict =
|
|
265
|
+
findStyleConflict(nodeOnlyGrid, cellStyles, edge.path, edge.style) ??
|
|
266
|
+
findUnrelatedOverlap(nodeOnlyGrid, cellOwners, edge.path, edge)
|
|
267
|
+
if (!conflict) return
|
|
268
|
+
graph.grid.add(gridKey(conflict))
|
|
269
|
+
temporarilyBlocked.push(conflict)
|
|
270
|
+
determinePath(graph, edge)
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
for (const cell of temporarilyBlocked) graph.grid.delete(gridKey(cell))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Subgraph helpers
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
function isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean {
|
|
282
|
+
return graph.subgraphs.some((sg) => sg.nodes.includes(node))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get the innermost subgraph that directly contains this node.
|
|
287
|
+
* Returns null if node is not in any subgraph.
|
|
288
|
+
*/
|
|
289
|
+
export function getNodeSubgraph(
|
|
290
|
+
graph: AsciiGraph,
|
|
291
|
+
node: AsciiNode,
|
|
292
|
+
): AsciiSubgraph | null {
|
|
293
|
+
// Find the innermost (most deeply nested) subgraph containing the node
|
|
294
|
+
let innermost: AsciiSubgraph | null = null
|
|
295
|
+
for (const sg of graph.subgraphs) {
|
|
296
|
+
if (sg.nodes.includes(node)) {
|
|
297
|
+
// Check if this subgraph is deeper (more nested) than current innermost
|
|
298
|
+
if (!innermost || isAncestorOrSelf(innermost, sg)) {
|
|
299
|
+
innermost = sg
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return innermost
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Check if `candidate` is the same as or an ancestor of `target`. */
|
|
307
|
+
function isAncestorOrSelf(
|
|
308
|
+
candidate: AsciiSubgraph,
|
|
309
|
+
target: AsciiSubgraph,
|
|
310
|
+
): boolean {
|
|
311
|
+
let current: AsciiSubgraph | null = target
|
|
312
|
+
while (current !== null) {
|
|
313
|
+
if (current === candidate) return true
|
|
314
|
+
current = current.parent
|
|
315
|
+
}
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get the outermost (top-level, unnested) subgraph ancestor for a node.
|
|
321
|
+
* Returns null if the node isn't in any subgraph.
|
|
322
|
+
*
|
|
323
|
+
* Used to group nodes for bounding-box-disjointness purposes: sibling
|
|
324
|
+
* subgraphs nested under different top-level subgraphs are unrelated and
|
|
325
|
+
* must never be allowed to overlap (#90), while a subgraph's own nested
|
|
326
|
+
* children are already folded into its box via calculateSubgraphBoundingBox.
|
|
327
|
+
*/
|
|
328
|
+
function getTopLevelSubgraph(
|
|
329
|
+
graph: AsciiGraph,
|
|
330
|
+
node: AsciiNode,
|
|
331
|
+
): AsciiSubgraph | null {
|
|
332
|
+
const sg = getNodeSubgraph(graph, node)
|
|
333
|
+
if (!sg) return null
|
|
334
|
+
let top = sg
|
|
335
|
+
while (top.parent) top = top.parent
|
|
336
|
+
return top
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Recursively collect every node belonging to a subgraph, including nodes in nested subgraphs. */
|
|
340
|
+
function collectSubgraphMembers(sg: AsciiSubgraph): AsciiNode[] {
|
|
341
|
+
const members = [...sg.nodes]
|
|
342
|
+
for (const child of sg.children) {
|
|
343
|
+
members.push(...collectSubgraphMembers(child))
|
|
344
|
+
}
|
|
345
|
+
return members
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Whether `to` is reachable from `from` by following outgoing edges (BFS). */
|
|
349
|
+
function isReachableViaEdges(
|
|
350
|
+
graph: AsciiGraph,
|
|
351
|
+
from: AsciiNode,
|
|
352
|
+
to: AsciiNode,
|
|
353
|
+
): boolean {
|
|
354
|
+
if (from === to) return true
|
|
355
|
+
const visited = new Set<AsciiNode>([from])
|
|
356
|
+
const queue: AsciiNode[] = [from]
|
|
357
|
+
while (queue.length > 0) {
|
|
358
|
+
const current = queue.shift()!
|
|
359
|
+
for (const child of getChildren(graph, current)) {
|
|
360
|
+
if (child === to) return true
|
|
361
|
+
if (!visited.has(child)) {
|
|
362
|
+
visited.add(child)
|
|
363
|
+
queue.push(child)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get the effective direction for a node's layout.
|
|
372
|
+
* Returns the subgraph's direction override if the node is in a subgraph with one,
|
|
373
|
+
* otherwise returns the graph-level direction.
|
|
374
|
+
*/
|
|
375
|
+
export function getEffectiveDirection(
|
|
376
|
+
graph: AsciiGraph,
|
|
377
|
+
node: AsciiNode,
|
|
378
|
+
): 'LR' | 'TD' {
|
|
379
|
+
const sg = getNodeSubgraph(graph, node)
|
|
380
|
+
if (sg?.direction) {
|
|
381
|
+
return sg.direction
|
|
382
|
+
}
|
|
383
|
+
return graph.config.graphDirection
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Check if a node has an incoming edge from outside its subgraph
|
|
388
|
+
* AND is the topmost such node in its subgraph.
|
|
389
|
+
* Used to add extra vertical padding for subgraph borders.
|
|
390
|
+
*/
|
|
391
|
+
function hasIncomingEdgeFromOutsideSubgraph(
|
|
392
|
+
graph: AsciiGraph,
|
|
393
|
+
node: AsciiNode,
|
|
394
|
+
): boolean {
|
|
395
|
+
const nodeSg = getNodeSubgraph(graph, node)
|
|
396
|
+
if (!nodeSg) return false
|
|
397
|
+
|
|
398
|
+
let hasExternalEdge = false
|
|
399
|
+
for (const edge of graph.edges) {
|
|
400
|
+
if (edge.to === node) {
|
|
401
|
+
const sourceSg = getNodeSubgraph(graph, edge.from)
|
|
402
|
+
if (sourceSg !== nodeSg) {
|
|
403
|
+
hasExternalEdge = true
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!hasExternalEdge) return false
|
|
410
|
+
|
|
411
|
+
// Only return true for the topmost node with an external incoming edge
|
|
412
|
+
const nodeY = requireGridCoord(node).y
|
|
413
|
+
for (const otherNode of nodeSg.nodes) {
|
|
414
|
+
if (otherNode === node || !otherNode.gridCoord) continue
|
|
415
|
+
let otherHasExternal = false
|
|
416
|
+
for (const edge of graph.edges) {
|
|
417
|
+
if (edge.to === otherNode) {
|
|
418
|
+
const sourceSg = getNodeSubgraph(graph, edge.from)
|
|
419
|
+
if (sourceSg !== nodeSg) {
|
|
420
|
+
otherHasExternal = true
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (otherHasExternal && otherNode.gridCoord.y < nodeY) {
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return true
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ============================================================================
|
|
434
|
+
// Subgraph bounding boxes
|
|
435
|
+
// ============================================================================
|
|
436
|
+
|
|
437
|
+
function calculateSubgraphBoundingBox(
|
|
438
|
+
graph: AsciiGraph,
|
|
439
|
+
sg: AsciiSubgraph,
|
|
440
|
+
): void {
|
|
441
|
+
if (sg.nodes.length === 0) return
|
|
442
|
+
|
|
443
|
+
let minX = 1_000_000
|
|
444
|
+
let minY = 1_000_000
|
|
445
|
+
let maxX = -1_000_000
|
|
446
|
+
let maxY = -1_000_000
|
|
447
|
+
|
|
448
|
+
// Include children's bounding boxes
|
|
449
|
+
for (const child of sg.children) {
|
|
450
|
+
calculateSubgraphBoundingBox(graph, child)
|
|
451
|
+
if (child.nodes.length > 0) {
|
|
452
|
+
minX = Math.min(minX, child.minX)
|
|
453
|
+
minY = Math.min(minY, child.minY)
|
|
454
|
+
maxX = Math.max(maxX, child.maxX)
|
|
455
|
+
maxY = Math.max(maxY, child.maxY)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Include node positions
|
|
460
|
+
for (const node of sg.nodes) {
|
|
461
|
+
if (!node.drawingCoord || !node.drawing) continue
|
|
462
|
+
const nodeMinX = node.drawingCoord.x
|
|
463
|
+
const nodeMinY = node.drawingCoord.y
|
|
464
|
+
const nodeMaxX = nodeMinX + node.drawing.length - 1
|
|
465
|
+
const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1
|
|
466
|
+
minX = Math.min(minX, nodeMinX)
|
|
467
|
+
minY = Math.min(minY, nodeMinY)
|
|
468
|
+
maxX = Math.max(maxX, nodeMaxX)
|
|
469
|
+
maxY = Math.max(maxY, nodeMaxY)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const subgraphPadding = 2
|
|
473
|
+
const subgraphLabelSpace = 2
|
|
474
|
+
sg.minX = minX - subgraphPadding
|
|
475
|
+
sg.minY = minY - subgraphPadding - subgraphLabelSpace
|
|
476
|
+
sg.maxX = maxX + subgraphPadding
|
|
477
|
+
sg.maxY = maxY + subgraphPadding
|
|
478
|
+
|
|
479
|
+
// Widen the box, if necessary, so the subgraph's own cluster label fits
|
|
480
|
+
// without truncation. `drawSubgraphLabel` (draw-subgraphs.ts) writes the
|
|
481
|
+
// label into the interior columns `1..width-1` (width = maxX - minX), so
|
|
482
|
+
// the box needs `width >= labelWidth + 1` to avoid clipping the label
|
|
483
|
+
// against its own right border. Widening symmetrically (splitting any
|
|
484
|
+
// extra columns across both sides) keeps the child nodes visually
|
|
485
|
+
// centered inside the enlarged box rather than skewing it to one side.
|
|
486
|
+
const labelWidth = Math.max(
|
|
487
|
+
0,
|
|
488
|
+
...splitLines(sg.name).map((line) => displayWidth(line)),
|
|
489
|
+
)
|
|
490
|
+
const currentWidth = sg.maxX - sg.minX
|
|
491
|
+
const requiredWidth = labelWidth + 1
|
|
492
|
+
if (requiredWidth > currentWidth) {
|
|
493
|
+
const extra = requiredWidth - currentWidth
|
|
494
|
+
const extraLeft = Math.floor(extra / 2)
|
|
495
|
+
const extraRight = extra - extraLeft
|
|
496
|
+
sg.minX -= extraLeft
|
|
497
|
+
sg.maxX += extraRight
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Ensure non-overlapping root subgraphs have minimum spacing. */
|
|
502
|
+
function ensureSubgraphSpacing(graph: AsciiGraph): void {
|
|
503
|
+
const minSpacing = 1
|
|
504
|
+
const rootSubgraphs = graph.subgraphs.filter(
|
|
505
|
+
(sg) => sg.parent === null && sg.nodes.length > 0,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
for (let i = 0; i < rootSubgraphs.length; i++) {
|
|
509
|
+
for (let j = i + 1; j < rootSubgraphs.length; j++) {
|
|
510
|
+
const sg1 = rootSubgraphs[i]!
|
|
511
|
+
const sg2 = rootSubgraphs[j]!
|
|
512
|
+
|
|
513
|
+
// Horizontal overlap → adjust vertical
|
|
514
|
+
if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) {
|
|
515
|
+
if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) {
|
|
516
|
+
sg2.minY = sg1.maxY + minSpacing + 1
|
|
517
|
+
} else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) {
|
|
518
|
+
sg1.minY = sg2.maxY + minSpacing + 1
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Vertical overlap → adjust horizontal
|
|
522
|
+
if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) {
|
|
523
|
+
if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) {
|
|
524
|
+
sg2.minX = sg1.maxX + minSpacing + 1
|
|
525
|
+
} else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) {
|
|
526
|
+
sg1.minX = sg2.maxX + minSpacing + 1
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void {
|
|
534
|
+
for (const sg of graph.subgraphs) {
|
|
535
|
+
calculateSubgraphBoundingBox(graph, sg)
|
|
536
|
+
}
|
|
537
|
+
ensureSubgraphSpacing(graph)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Offset all drawing coordinates so subgraph borders don't go negative.
|
|
542
|
+
* If any subgraph has negative min coordinates, shift everything positive.
|
|
543
|
+
*/
|
|
544
|
+
export function offsetDrawingForSubgraphs(graph: AsciiGraph): void {
|
|
545
|
+
if (graph.subgraphs.length === 0) return
|
|
546
|
+
|
|
547
|
+
let minX = 0
|
|
548
|
+
let minY = 0
|
|
549
|
+
for (const sg of graph.subgraphs) {
|
|
550
|
+
minX = Math.min(minX, sg.minX)
|
|
551
|
+
minY = Math.min(minY, sg.minY)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const offsetX = -minX
|
|
555
|
+
const offsetY = -minY
|
|
556
|
+
if (offsetX === 0 && offsetY === 0) return
|
|
557
|
+
|
|
558
|
+
graph.offsetX = offsetX
|
|
559
|
+
graph.offsetY = offsetY
|
|
560
|
+
|
|
561
|
+
for (const sg of graph.subgraphs) {
|
|
562
|
+
sg.minX += offsetX
|
|
563
|
+
sg.minY += offsetY
|
|
564
|
+
sg.maxX += offsetX
|
|
565
|
+
sg.maxY += offsetY
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for (const node of graph.nodes) {
|
|
569
|
+
if (node.drawingCoord) {
|
|
570
|
+
node.drawingCoord.x += offsetX
|
|
571
|
+
node.drawingCoord.y += offsetY
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Group root nodes by which downstream target they feed into, preserving a
|
|
578
|
+
* stable order: groups appear in the order their shared target was first
|
|
579
|
+
* seen among `roots`, and a root with no children (or whose target no other
|
|
580
|
+
* root shares) keeps its original relative position.
|
|
581
|
+
*
|
|
582
|
+
* Fixes fan-in root placement: without this, `createMapping` places roots
|
|
583
|
+
* sequentially in whatever order they were discovered, so e.g. `A1, B1, A2,
|
|
584
|
+
* B2` (all roots, A1/A2 feeding A and B1/B2 feeding B) land interleaved on
|
|
585
|
+
* the grid instead of grouped as `A1, A2, B1, B2` — causing the two fan-in
|
|
586
|
+
* bundles' trunk edges to share a row and visually cross.
|
|
587
|
+
*/
|
|
588
|
+
function groupRootsByDownstreamTarget(
|
|
589
|
+
graph: AsciiGraph,
|
|
590
|
+
roots: AsciiNode[],
|
|
591
|
+
): AsciiNode[] {
|
|
592
|
+
const primaryTargetKey = (node: AsciiNode): string | null => {
|
|
593
|
+
const children = getChildren(graph, node)
|
|
594
|
+
return children.length > 0 ? children[0]!.name : null
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// For each root, find the index (within `roots`) of the first root that
|
|
598
|
+
// shares its primary target — that's this root's sort anchor.
|
|
599
|
+
const firstIndexForTarget = new Map<string, number>()
|
|
600
|
+
const anchorIndex: number[] = roots.map((node, i) => {
|
|
601
|
+
const key = primaryTargetKey(node)
|
|
602
|
+
if (key === null) return i // no downstream target: anchor to self
|
|
603
|
+
const existing = firstIndexForTarget.get(key)
|
|
604
|
+
if (existing !== undefined) return existing
|
|
605
|
+
firstIndexForTarget.set(key, i)
|
|
606
|
+
return i
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
return roots
|
|
610
|
+
.map((node, i) => ({ node, i }))
|
|
611
|
+
.sort((a, b) => anchorIndex[a.i]! - anchorIndex[b.i]! || a.i - b.i)
|
|
612
|
+
.map(({ node }) => node)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* A node whose every incoming path loops back through itself (e.g. `A -->
|
|
617
|
+
* B --> C --> A`, or a lone self-loop `A --> A`) is, correctly, never a
|
|
618
|
+
* "root" — every node in the cycle has a real incoming edge. But the grid
|
|
619
|
+
* layout still needs at least one seed node per such component to place
|
|
620
|
+
* anything at all; with zero roots feeding it, that component would never
|
|
621
|
+
* get a gridCoord and later crash (setColumnWidth calls `requireGridCoord`).
|
|
622
|
+
*
|
|
623
|
+
* The old order-dependent detection accidentally provided this seed — the
|
|
624
|
+
* first node the scan reached "looked like" a root simply because it
|
|
625
|
+
* hadn't been visited as a target *yet* — which is the exact bug fixed
|
|
626
|
+
* above. This restores just the useful part of that behavior for genuine
|
|
627
|
+
* cycles: for each weakly-connected component not reachable from any real
|
|
628
|
+
* root, seed it with its first-declared (graph.nodes order) node.
|
|
629
|
+
*/
|
|
630
|
+
function addPseudoRootsForUnreachableCycles(
|
|
631
|
+
graph: AsciiGraph,
|
|
632
|
+
roots: AsciiNode[],
|
|
633
|
+
): AsciiNode[] {
|
|
634
|
+
const reachable = new Set<string>()
|
|
635
|
+
const floodFrom = (start: AsciiNode): void => {
|
|
636
|
+
const queue: AsciiNode[] = [start]
|
|
637
|
+
while (queue.length > 0) {
|
|
638
|
+
const n = queue.shift()!
|
|
639
|
+
if (reachable.has(n.name)) continue
|
|
640
|
+
reachable.add(n.name)
|
|
641
|
+
for (const child of getChildren(graph, n)) queue.push(child)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const root of roots) floodFrom(root)
|
|
645
|
+
|
|
646
|
+
const result = [...roots]
|
|
647
|
+
for (const node of graph.nodes) {
|
|
648
|
+
if (reachable.has(node.name)) continue
|
|
649
|
+
result.push(node)
|
|
650
|
+
floodFrom(node)
|
|
651
|
+
}
|
|
652
|
+
return result
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Find the node whose grid block originates at `coord`, if any.
|
|
657
|
+
*
|
|
658
|
+
* Only meaningful before edge routing starts: every reserved grid cell at
|
|
659
|
+
* that point belongs to a placed node's block (edges haven't claimed any
|
|
660
|
+
* cells yet), and every block's origin sits on the 4-unit lattice that
|
|
661
|
+
* `reserveSpotInGrid` allocates from — so matching by exact origin equality
|
|
662
|
+
* is safe; there's no partial-overlap case to worry about yet.
|
|
663
|
+
*/
|
|
664
|
+
function findNodeAtGridOrigin(
|
|
665
|
+
graph: AsciiGraph,
|
|
666
|
+
coord: GridCoord,
|
|
667
|
+
): AsciiNode | undefined {
|
|
668
|
+
return graph.nodes.find(
|
|
669
|
+
(n) =>
|
|
670
|
+
n.gridCoord !== null &&
|
|
671
|
+
n.gridCoord.x === coord.x &&
|
|
672
|
+
n.gridCoord.y === coord.y,
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Find a free grid slot adjacent to `anchor` along `axis`, without walking
|
|
678
|
+
* through a node that belongs to a *different* top-level subgraph than
|
|
679
|
+
* `ownTopSg`.
|
|
680
|
+
*
|
|
681
|
+
* A deferred subgraph root (see the module doc above createMapping) anchors
|
|
682
|
+
* next to an already-placed sibling and slides along the shared axis until
|
|
683
|
+
* it finds free space. Sliding blindly — the naive approach, via
|
|
684
|
+
* `reserveSpotInGrid`'s generic collision handling — can walk straight
|
|
685
|
+
* through an unrelated sibling subgraph's node that already occupies the
|
|
686
|
+
* next slot over, landing the deferred node on the *far* side of that
|
|
687
|
+
* foreign node. That foreign node then sits between the anchor and the
|
|
688
|
+
* deferred node, so it falls inside this subgraph's bounding box and its own
|
|
689
|
+
* frame/title is dropped (#301).
|
|
690
|
+
*
|
|
691
|
+
* Tries `preferredSign` first (the direction the old blind slide always
|
|
692
|
+
* used), then the opposite sign. In each direction, stops — without
|
|
693
|
+
* accepting the enclosure — the moment it would have to step past a node
|
|
694
|
+
* belonging to a different top-level subgraph, and only continues past
|
|
695
|
+
* nodes belonging to the *same* one. Returns null if both directions are
|
|
696
|
+
* immediately foreign-blocked, so the caller can fall back to the old
|
|
697
|
+
* (occasionally imperfect but non-looping) blind-slide behavior.
|
|
698
|
+
*/
|
|
699
|
+
function findSubgraphAdjacentSlot(
|
|
700
|
+
graph: AsciiGraph,
|
|
701
|
+
anchor: GridCoord,
|
|
702
|
+
ownTopSg: AsciiSubgraph,
|
|
703
|
+
axis: 'x' | 'y',
|
|
704
|
+
preferredSign: 1 | -1,
|
|
705
|
+
): GridCoord | null {
|
|
706
|
+
const other: 'x' | 'y' = axis === 'x' ? 'y' : 'x'
|
|
707
|
+
|
|
708
|
+
const tryDirection = (sign: 1 | -1): GridCoord | null => {
|
|
709
|
+
let offset = 4
|
|
710
|
+
for (;;) {
|
|
711
|
+
const axisVal = anchor[axis] + sign * offset
|
|
712
|
+
if (axisVal < 0) return null
|
|
713
|
+
const candidate: GridCoord =
|
|
714
|
+
axis === 'x'
|
|
715
|
+
? { x: axisVal, y: anchor[other] }
|
|
716
|
+
: { x: anchor[other], y: axisVal }
|
|
717
|
+
if (isBlockFree(graph.grid, candidate, NODE_BLOCK_SIZE)) return candidate
|
|
718
|
+
const occupant = findNodeAtGridOrigin(graph, candidate)
|
|
719
|
+
if (!occupant || getTopLevelSubgraph(graph, occupant) !== ownTopSg) {
|
|
720
|
+
return null // a foreign subgraph (or unexpected gap) blocks this side
|
|
721
|
+
}
|
|
722
|
+
offset += 4
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return (
|
|
727
|
+
tryDirection(preferredSign) ?? tryDirection((preferredSign * -1) as 1 | -1)
|
|
728
|
+
)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Place any deferred nodes waiting on `rootNode`'s top-level subgraph
|
|
733
|
+
* immediately adjacent to it, before returning control to the root-
|
|
734
|
+
* placement loop — so an unrelated subgraph's root can never claim the slot
|
|
735
|
+
* a deferred sibling needs first (#301). Removes the subgraph's entry from
|
|
736
|
+
* `deferredByTopSg` once handled (a subgraph's deferred nodes attach to
|
|
737
|
+
* whichever of its roots is placed *first*, not every one), and records each
|
|
738
|
+
* placed node in `resolvedDeferred` so the later fallback pass (for deferred
|
|
739
|
+
* nodes whose anchor turns out to be a non-root, only available after the
|
|
740
|
+
* reachable-children traversal) skips them.
|
|
741
|
+
*/
|
|
742
|
+
function placeDeferredSiblingsNextToRoot(
|
|
743
|
+
graph: AsciiGraph,
|
|
744
|
+
rootNode: AsciiNode,
|
|
745
|
+
deferredByTopSg: Map<AsciiSubgraph, AsciiNode[]>,
|
|
746
|
+
resolvedDeferred: Set<AsciiNode>,
|
|
747
|
+
): void {
|
|
748
|
+
const topSg = getTopLevelSubgraph(graph, rootNode)
|
|
749
|
+
if (!topSg) return
|
|
750
|
+
const waiting = deferredByTopSg.get(topSg)
|
|
751
|
+
if (!waiting) return
|
|
752
|
+
deferredByTopSg.delete(topSg)
|
|
753
|
+
|
|
754
|
+
const anchor = requireGridCoord(rootNode)
|
|
755
|
+
const axis: 'x' | 'y' =
|
|
756
|
+
getEffectiveDirection(graph, rootNode) === 'LR' ? 'y' : 'x'
|
|
757
|
+
|
|
758
|
+
for (const deferred of waiting) {
|
|
759
|
+
const nodeDir = getEffectiveDirection(graph, deferred)
|
|
760
|
+
const slot = findSubgraphAdjacentSlot(graph, anchor, topSg, axis, 1)
|
|
761
|
+
reserveSpotInGrid(
|
|
762
|
+
graph,
|
|
763
|
+
graph.nodes[deferred.index]!,
|
|
764
|
+
slot ?? anchor,
|
|
765
|
+
nodeDir,
|
|
766
|
+
)
|
|
767
|
+
resolvedDeferred.add(deferred)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Place all currently-reachable, still-unplaced children of already-placed
|
|
773
|
+
* nodes, level by level, mutating `highestPositionPerLevel` as it goes.
|
|
774
|
+
* Multi-pass: iterates until no more progress can be made in a full pass
|
|
775
|
+
* (handles non-topological node order, and simply leaves anything
|
|
776
|
+
* unreachable from an already-placed node untouched).
|
|
777
|
+
*/
|
|
778
|
+
function placeReachableChildren(
|
|
779
|
+
graph: AsciiGraph,
|
|
780
|
+
highestPositionPerLevel: number[],
|
|
781
|
+
): void {
|
|
782
|
+
let progressed = true
|
|
783
|
+
while (progressed) {
|
|
784
|
+
progressed = false
|
|
785
|
+
|
|
786
|
+
// Visit already-placed nodes in cross-axis order (left-to-right for TD,
|
|
787
|
+
// top-to-bottom for LR) rather than raw `graph.nodes` declaration
|
|
788
|
+
// order. `highestPositionPerLevel[level]` hands out each level's next
|
|
789
|
+
// free slot in visiting order, so a parent that's actually positioned
|
|
790
|
+
// further along the cross axis must also be visited later — otherwise
|
|
791
|
+
// its children claim an earlier (visually misaligned) slot than a
|
|
792
|
+
// parent positioned before it, decoupling a child's column/row from its
|
|
793
|
+
// own parent's. This matters once sibling placement order can diverge
|
|
794
|
+
// from `graph.nodes` order — e.g. `compareBySiblingSubgraphOrder`
|
|
795
|
+
// above, which places a later-declared sibling subgraph's root before
|
|
796
|
+
// an earlier-declared one's (see issue #444).
|
|
797
|
+
const crossAxisOf = (n: AsciiNode): number =>
|
|
798
|
+
graph.config.graphDirection === 'LR'
|
|
799
|
+
? (n.gridCoord?.y ?? 0)
|
|
800
|
+
: (n.gridCoord?.x ?? 0)
|
|
801
|
+
const placedNodes = graph.nodes
|
|
802
|
+
.filter((n) => n.gridCoord !== null)
|
|
803
|
+
.sort((a, b) => crossAxisOf(a) - crossAxisOf(b))
|
|
804
|
+
|
|
805
|
+
for (const node of placedNodes) {
|
|
806
|
+
const gc = node.gridCoord
|
|
807
|
+
if (gc === null) continue // unreachable: `placedNodes` is pre-filtered
|
|
808
|
+
|
|
809
|
+
// Sort children so siblings that land in different subgraphs get
|
|
810
|
+
// mermaid.js's reversed-declaration-order sibling placement (see
|
|
811
|
+
// compareBySiblingSubgraphOrder) instead of raw edge-declaration
|
|
812
|
+
// order — the sort is stable, so pairs with no subgraph-order
|
|
813
|
+
// preference (the common case) keep their original relative order.
|
|
814
|
+
const children = [...getChildren(graph, node)].sort((x, y) =>
|
|
815
|
+
compareBySiblingSubgraphOrder(graph, x, y),
|
|
816
|
+
)
|
|
817
|
+
for (const child of children) {
|
|
818
|
+
if (child.gridCoord !== null) continue // already placed
|
|
819
|
+
|
|
820
|
+
// Determine direction for this edge (parent -> child)
|
|
821
|
+
// Use subgraph direction only if both are in the same subgraph with override
|
|
822
|
+
const parentSg = getNodeSubgraph(graph, node)
|
|
823
|
+
const childSg = getNodeSubgraph(graph, child)
|
|
824
|
+
const edgeDir =
|
|
825
|
+
parentSg && parentSg === childSg && parentSg.direction
|
|
826
|
+
? parentSg.direction
|
|
827
|
+
: graph.config.graphDirection
|
|
828
|
+
|
|
829
|
+
const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4
|
|
830
|
+
|
|
831
|
+
// Determine position based on direction context
|
|
832
|
+
let highestPosition: number
|
|
833
|
+
if (edgeDir !== graph.config.graphDirection) {
|
|
834
|
+
// Cross-direction: use parent's perpendicular coordinate
|
|
835
|
+
// This keeps children aligned with parent when direction changes
|
|
836
|
+
highestPosition = edgeDir === 'LR' ? gc.y : gc.x
|
|
837
|
+
} else {
|
|
838
|
+
// Same direction: use level tracker
|
|
839
|
+
highestPosition = highestPositionPerLevel[childLevel] ?? 0
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const requested: GridCoord =
|
|
843
|
+
edgeDir === 'LR'
|
|
844
|
+
? { x: childLevel, y: highestPosition }
|
|
845
|
+
: { x: highestPosition, y: childLevel }
|
|
846
|
+
reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir)
|
|
847
|
+
|
|
848
|
+
// Only update level tracker for same-direction placements
|
|
849
|
+
if (edgeDir === graph.config.graphDirection) {
|
|
850
|
+
highestPositionPerLevel[childLevel] = highestPosition + 4
|
|
851
|
+
}
|
|
852
|
+
progressed = true
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ============================================================================
|
|
859
|
+
// Main layout orchestrator
|
|
860
|
+
// ============================================================================
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* createMapping performs the full grid layout:
|
|
864
|
+
* 1. Place root nodes on the grid
|
|
865
|
+
* 2. Place child nodes level by level
|
|
866
|
+
* 3. Compute column widths and row heights
|
|
867
|
+
* 4. Run A* pathfinding for all edges
|
|
868
|
+
* 5. Determine label placement
|
|
869
|
+
* 6. Convert grid coords → drawing coords
|
|
870
|
+
* 7. Generate node box drawings
|
|
871
|
+
* 8. Calculate subgraph bounding boxes
|
|
872
|
+
*/
|
|
873
|
+
export function createMapping(graph: AsciiGraph): void {
|
|
874
|
+
const dir = graph.config.graphDirection
|
|
875
|
+
// A sparse array, not a fixed-size preallocation: level indices grow with
|
|
876
|
+
// chain depth (each level adds 4 to the coordinate), and a long enough
|
|
877
|
+
// chain would silently read past a fixed bound. Reads default missing
|
|
878
|
+
// levels to 0 via `?? 0` below instead.
|
|
879
|
+
const highestPositionPerLevel: number[] = []
|
|
880
|
+
|
|
881
|
+
// Identify root nodes — nodes that are never the target of any edge.
|
|
882
|
+
//
|
|
883
|
+
// This must be order-independent: a single forward pass over graph.nodes
|
|
884
|
+
// (in Map-insertion / first-mention order) incorrectly treats a node as a
|
|
885
|
+
// root whenever it hasn't been seen as an edge target *yet* at the point
|
|
886
|
+
// it's visited. That misclassifies nodes when a `child -> parent` edge
|
|
887
|
+
// appears in the source *after* a `parent -> grandchild` edge (e.g. `A -->
|
|
888
|
+
// C` is declared before `A1 --> A`), since `A` looks unvisited-as-target
|
|
889
|
+
// when the loop reaches it.
|
|
890
|
+
//
|
|
891
|
+
// Two-pass fix: first collect every node that appears as the target of a
|
|
892
|
+
// non-self-loop edge (a self-loop shouldn't disqualify a node from being a
|
|
893
|
+
// root — see edge-bundling.ts's identical self-loop skip), then anything
|
|
894
|
+
// never targeted is a genuine root, independent of source order.
|
|
895
|
+
const targetedNames = new Set<string>()
|
|
896
|
+
for (const edge of graph.edges) {
|
|
897
|
+
if (edge.from === edge.to) continue // self-loop: doesn't count as "targeted"
|
|
898
|
+
targetedNames.add(edge.to.name)
|
|
899
|
+
}
|
|
900
|
+
const targetBasedRoots: AsciiNode[] = graph.nodes.filter(
|
|
901
|
+
(node) => !targetedNames.has(node.name),
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
// A weakly-connected component that's entirely a cycle (e.g. `A --> B -->
|
|
905
|
+
// C --> A`) correctly has zero target-based roots — every node in it has
|
|
906
|
+
// a real incoming edge — but the grid layout still needs one seed node
|
|
907
|
+
// per component to place anything at all. See
|
|
908
|
+
// addPseudoRootsForUnreachableCycles for why and how.
|
|
909
|
+
const initialRoots = addPseudoRootsForUnreachableCycles(
|
|
910
|
+
graph,
|
|
911
|
+
targetBasedRoots,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
// Filter out subgraph nodes that have incoming edges from external sources.
|
|
915
|
+
// This handles the case where subgraph is declared before external nodes
|
|
916
|
+
// (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should).
|
|
917
|
+
const rootNodes = initialRoots.filter((node) => {
|
|
918
|
+
const nodeSg = getNodeSubgraph(graph, node)
|
|
919
|
+
if (!nodeSg) return true // external nodes: keep as roots
|
|
920
|
+
|
|
921
|
+
// Check if this subgraph node has incoming edges from outside its subgraph
|
|
922
|
+
for (const edge of graph.edges) {
|
|
923
|
+
if (edge.to === node) {
|
|
924
|
+
const sourceSg = getNodeSubgraph(graph, edge.from)
|
|
925
|
+
if (sourceSg !== nodeSg) {
|
|
926
|
+
return false // has external incoming edge → not a root
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return true
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
// Defer root nodes that belong to a subgraph which has OTHER members that
|
|
934
|
+
// are (a) not roots themselves and (b) not even reachable from this root
|
|
935
|
+
// via its own edges — i.e. members whose placement is driven by some
|
|
936
|
+
// completely unrelated part of the graph. Placing such a node at the
|
|
937
|
+
// generic root level — shared with roots of unrelated sibling subgraphs —
|
|
938
|
+
// can scatter its own subgraph's members across disjoint regions of the
|
|
939
|
+
// grid, making that subgraph's bounding box balloon out to enclose
|
|
940
|
+
// unrelated sibling content (#90). Instead, anchor these nodes next to
|
|
941
|
+
// their already-placed subgraph siblings once the normal placement pass
|
|
942
|
+
// below has run.
|
|
943
|
+
//
|
|
944
|
+
// A sibling that *is* reachable from this root (e.g. a subgraph root with
|
|
945
|
+
// its own intra-subgraph child) is left alone: the normal traversal below
|
|
946
|
+
// already positions it correctly relative to this root, so deferring would
|
|
947
|
+
// be both unnecessary and wrong.
|
|
948
|
+
const rootNodeSet = new Set(rootNodes)
|
|
949
|
+
const deferredRoots: AsciiNode[] = []
|
|
950
|
+
const placementRoots: AsciiNode[] = []
|
|
951
|
+
for (const node of rootNodes) {
|
|
952
|
+
const topSg = getTopLevelSubgraph(graph, node)
|
|
953
|
+
const hasUnrelatedNonRootSibling =
|
|
954
|
+
topSg !== null &&
|
|
955
|
+
collectSubgraphMembers(topSg).some(
|
|
956
|
+
(m) =>
|
|
957
|
+
m !== node &&
|
|
958
|
+
!rootNodeSet.has(m) &&
|
|
959
|
+
!isReachableViaEdges(graph, node, m),
|
|
960
|
+
)
|
|
961
|
+
if (hasUnrelatedNonRootSibling) {
|
|
962
|
+
deferredRoots.push(node)
|
|
963
|
+
} else {
|
|
964
|
+
placementRoots.push(node)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Group the non-deferred root nodes by which downstream target they feed
|
|
969
|
+
// into, so a fan-in cluster (e.g. A1, A2 -> A) is placed contiguously
|
|
970
|
+
// instead of interleaving with roots that feed a *different* target (e.g.
|
|
971
|
+
// B1 -> B landing between A1 and A2). Without this, unrelated fan-in
|
|
972
|
+
// bundles can end up on the same grid row and their trunk edges visually
|
|
973
|
+
// cross.
|
|
974
|
+
//
|
|
975
|
+
// Stable: each root's sort key is the position of the *first* root that
|
|
976
|
+
// shares its primary (first) downstream target, so groups appear in the
|
|
977
|
+
// order their target was first seen, and a root with no children (or a
|
|
978
|
+
// target no other root shares) keeps its original relative position.
|
|
979
|
+
const groupedRootNodes = groupRootsByDownstreamTarget(graph, placementRoots)
|
|
980
|
+
|
|
981
|
+
// Deferred nodes grouped by their top-level subgraph, so the placement
|
|
982
|
+
// loops below can attach each subgraph's deferred members to whichever of
|
|
983
|
+
// its roots gets placed first — before any *other* subgraph's root gets a
|
|
984
|
+
// chance to claim the adjacent slot (#301). Entries are removed as they're
|
|
985
|
+
// resolved; `resolvedDeferred` then lets the later fallback pass skip
|
|
986
|
+
// anything already placed this way.
|
|
987
|
+
const deferredByTopSg = new Map<AsciiSubgraph, AsciiNode[]>()
|
|
988
|
+
for (const node of deferredRoots) {
|
|
989
|
+
const topSg = getTopLevelSubgraph(graph, node)!
|
|
990
|
+
const list = deferredByTopSg.get(topSg)
|
|
991
|
+
if (list) list.push(node)
|
|
992
|
+
else deferredByTopSg.set(topSg, [node])
|
|
993
|
+
}
|
|
994
|
+
const resolvedDeferred = new Set<AsciiNode>()
|
|
995
|
+
|
|
996
|
+
// In LR mode with both external and subgraph roots, separate them
|
|
997
|
+
// so subgraph roots are placed one level deeper
|
|
998
|
+
let hasExternalRoots = false
|
|
999
|
+
let hasSubgraphRootsWithEdges = false
|
|
1000
|
+
for (const node of groupedRootNodes) {
|
|
1001
|
+
if (isNodeInAnySubgraph(graph, node)) {
|
|
1002
|
+
if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true
|
|
1003
|
+
} else {
|
|
1004
|
+
hasExternalRoots = true
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const shouldSeparate =
|
|
1008
|
+
dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges
|
|
1009
|
+
|
|
1010
|
+
let externalRootNodes: AsciiNode[]
|
|
1011
|
+
let subgraphRootNodes: AsciiNode[] = []
|
|
1012
|
+
|
|
1013
|
+
if (shouldSeparate) {
|
|
1014
|
+
externalRootNodes = groupedRootNodes.filter(
|
|
1015
|
+
(n) => !isNodeInAnySubgraph(graph, n),
|
|
1016
|
+
)
|
|
1017
|
+
subgraphRootNodes = groupedRootNodes.filter((n) =>
|
|
1018
|
+
isNodeInAnySubgraph(graph, n),
|
|
1019
|
+
)
|
|
1020
|
+
} else {
|
|
1021
|
+
externalRootNodes = groupedRootNodes
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Place external root nodes
|
|
1025
|
+
for (const node of externalRootNodes) {
|
|
1026
|
+
const requested: GridCoord =
|
|
1027
|
+
dir === 'LR'
|
|
1028
|
+
? { x: 0, y: highestPositionPerLevel[0] ?? 0 }
|
|
1029
|
+
: { x: highestPositionPerLevel[0] ?? 0, y: 0 }
|
|
1030
|
+
reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)
|
|
1031
|
+
highestPositionPerLevel[0] = (highestPositionPerLevel[0] ?? 0) + 4
|
|
1032
|
+
placeDeferredSiblingsNextToRoot(
|
|
1033
|
+
graph,
|
|
1034
|
+
node,
|
|
1035
|
+
deferredByTopSg,
|
|
1036
|
+
resolvedDeferred,
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Place subgraph root nodes at level 4 (one level in from the edge)
|
|
1041
|
+
if (shouldSeparate && subgraphRootNodes.length > 0) {
|
|
1042
|
+
const subgraphLevel = 4
|
|
1043
|
+
for (const node of subgraphRootNodes) {
|
|
1044
|
+
const requested: GridCoord =
|
|
1045
|
+
dir === 'LR'
|
|
1046
|
+
? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel] ?? 0 }
|
|
1047
|
+
: { x: highestPositionPerLevel[subgraphLevel] ?? 0, y: subgraphLevel }
|
|
1048
|
+
reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)
|
|
1049
|
+
highestPositionPerLevel[subgraphLevel] =
|
|
1050
|
+
(highestPositionPerLevel[subgraphLevel] ?? 0) + 4
|
|
1051
|
+
placeDeferredSiblingsNextToRoot(
|
|
1052
|
+
graph,
|
|
1053
|
+
node,
|
|
1054
|
+
deferredByTopSg,
|
|
1055
|
+
resolvedDeferred,
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Place child nodes level by level (reachable from the roots placed so far).
|
|
1061
|
+
placeReachableChildren(graph, highestPositionPerLevel)
|
|
1062
|
+
|
|
1063
|
+
// Now place whatever deferred subgraph-orphan roots weren't already
|
|
1064
|
+
// resolved above (anchored to a root placed in this same subgraph) —
|
|
1065
|
+
// these are the ones whose anchor is itself a non-root, only placed by
|
|
1066
|
+
// the reachable-children traversal just above.
|
|
1067
|
+
for (const node of deferredRoots) {
|
|
1068
|
+
if (resolvedDeferred.has(node)) continue
|
|
1069
|
+
const topSg = getTopLevelSubgraph(graph, node)!
|
|
1070
|
+
const nodeDir = getEffectiveDirection(graph, node)
|
|
1071
|
+
// Type predicate narrows `gridCoord` to non-null on every element, so the
|
|
1072
|
+
// loop below can read `.gridCoord.x`/`.y` directly instead of trusting
|
|
1073
|
+
// that this filter and the access stay in sync via a bare `!`.
|
|
1074
|
+
const placedSiblings = collectSubgraphMembers(topSg).filter(
|
|
1075
|
+
(m): m is AsciiNode & { gridCoord: GridCoord } =>
|
|
1076
|
+
m !== node && m.gridCoord !== null,
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
let requested: GridCoord
|
|
1080
|
+
if (placedSiblings.length > 0) {
|
|
1081
|
+
// Anchor to whichever placed sibling sits at the shallowest level
|
|
1082
|
+
// (topmost row for TD, leftmost column for LR).
|
|
1083
|
+
let anchor = placedSiblings[0]!
|
|
1084
|
+
for (const sibling of placedSiblings) {
|
|
1085
|
+
const isShallower =
|
|
1086
|
+
nodeDir === 'LR'
|
|
1087
|
+
? sibling.gridCoord.x < anchor.gridCoord.x
|
|
1088
|
+
: sibling.gridCoord.y < anchor.gridCoord.y
|
|
1089
|
+
if (isShallower) anchor = sibling
|
|
1090
|
+
}
|
|
1091
|
+
// Slide along the shared axis to find free space next to the anchor,
|
|
1092
|
+
// staying clear of unrelated sibling subgraphs (#301) rather than
|
|
1093
|
+
// reserveSpotInGrid's subgraph-agnostic blind slide. Falls back to the
|
|
1094
|
+
// anchor's own coordinate (triggering the old blind slide inside
|
|
1095
|
+
// reserveSpotInGrid below) only when both directions are immediately
|
|
1096
|
+
// foreign-blocked.
|
|
1097
|
+
const axis: 'x' | 'y' = nodeDir === 'LR' ? 'y' : 'x'
|
|
1098
|
+
requested = findSubgraphAdjacentSlot(
|
|
1099
|
+
graph,
|
|
1100
|
+
anchor.gridCoord,
|
|
1101
|
+
topSg,
|
|
1102
|
+
axis,
|
|
1103
|
+
1,
|
|
1104
|
+
) ?? { x: anchor.gridCoord.x, y: anchor.gridCoord.y }
|
|
1105
|
+
} else {
|
|
1106
|
+
// Defensive fallback — shouldn't normally happen, since we only defer
|
|
1107
|
+
// a node when it has a sibling that's guaranteed to be placed by the
|
|
1108
|
+
// traversal above. Fall back to ordinary root-level placement.
|
|
1109
|
+
requested =
|
|
1110
|
+
nodeDir === 'LR'
|
|
1111
|
+
? { x: 0, y: highestPositionPerLevel[0] ?? 0 }
|
|
1112
|
+
: { x: highestPositionPerLevel[0] ?? 0, y: 0 }
|
|
1113
|
+
/* v8 ignore next */
|
|
1114
|
+
highestPositionPerLevel[0] = (highestPositionPerLevel[0] ?? 0) + 4
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
reserveSpotInGrid(graph, graph.nodes[node.index]!, requested, nodeDir)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// A deferred root may itself have children (edges) that couldn't be placed
|
|
1121
|
+
// above since it wasn't on the grid yet — give the traversal another pass.
|
|
1122
|
+
placeReachableChildren(graph, highestPositionPerLevel)
|
|
1123
|
+
|
|
1124
|
+
// Compute column widths and row heights
|
|
1125
|
+
for (const node of graph.nodes) {
|
|
1126
|
+
setColumnWidth(graph, node)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Fresh render-wide A* iteration budget for this layout pass. Shared by
|
|
1130
|
+
// every getPath call below (both bundled-edge routing and per-edge
|
|
1131
|
+
// determinePath), so total pathfinding work for the whole render is
|
|
1132
|
+
// hard-bounded regardless of edge count — a per-call iteration cap alone
|
|
1133
|
+
// isn't enough for dense fan-in/out graphs with hundreds of edges, since
|
|
1134
|
+
// each call is independently allowed to spend up to its own cap. See
|
|
1135
|
+
// pathfinder.ts's PathBudget for details.
|
|
1136
|
+
graph.pathBudget = createPathBudget()
|
|
1137
|
+
|
|
1138
|
+
// Tag true parallel/multi-edges (same source AND target, e.g. two
|
|
1139
|
+
// separately-labeled A-->B edges) with a lane index before bundling
|
|
1140
|
+
// analysis runs, so determinePath below can route sibling edges past the
|
|
1141
|
+
// first through distinct offset lanes instead of all computing the
|
|
1142
|
+
// identical center path (see #329).
|
|
1143
|
+
assignParallelEdgeLanes(graph)
|
|
1144
|
+
|
|
1145
|
+
// Analyze edges for bundling (parallel links like A & B --> C)
|
|
1146
|
+
// This groups edges that share sources or targets for cleaner visualization
|
|
1147
|
+
graph.bundles = analyzeEdgeBundles(graph)
|
|
1148
|
+
|
|
1149
|
+
// Route bundled edges through junction points
|
|
1150
|
+
processBundles(graph)
|
|
1151
|
+
|
|
1152
|
+
// Route non-bundled edges via A* and determine label positions.
|
|
1153
|
+
//
|
|
1154
|
+
// `cellStyles` tracks which line style has claimed each cell an edge's
|
|
1155
|
+
// path has passed through so far. After routing a non-bundled edge,
|
|
1156
|
+
// `rerouteAroundStyleConflicts` checks whether its path crosses a cell
|
|
1157
|
+
// already claimed by a *different*-style edge — e.g. a solid edge and a
|
|
1158
|
+
// dotted back-edge with no shared source or target, independently
|
|
1159
|
+
// finding the same empty column — and if so, re-routes just that edge
|
|
1160
|
+
// around the conflicting cell(s). Same-style overlap is left completely
|
|
1161
|
+
// untouched: it's how sibling/bundled edges are meant to share a trunk
|
|
1162
|
+
// (see edge-cell-styles.ts's module doc).
|
|
1163
|
+
//
|
|
1164
|
+
// `nodeOnlyGrid` snapshots `graph.grid` right here — after all node
|
|
1165
|
+
// placement and bundle routing (which doesn't itself reserve into
|
|
1166
|
+
// `graph.grid`; see routeBundledEdges), before any per-edge temporary
|
|
1167
|
+
// reroute reservations start mutating the live `graph.grid` below. See
|
|
1168
|
+
// `rerouteAroundStyleConflicts`'s doc for why the conflict check needs
|
|
1169
|
+
// this frozen copy instead of the live grid.
|
|
1170
|
+
const cellStyles = createEdgeCellStyles()
|
|
1171
|
+
const cellOwners = createEdgeCellOwners()
|
|
1172
|
+
const nodeOnlyGrid = cloneGrid(graph.grid)
|
|
1173
|
+
for (const edge of graph.edges) {
|
|
1174
|
+
// Skip edges that were already routed as part of a bundle
|
|
1175
|
+
if (edge.bundle && edge.path.length > 0) {
|
|
1176
|
+
increaseGridSizeForPath(graph, edge.path)
|
|
1177
|
+
claimPathCells(nodeOnlyGrid, cellStyles, edge.path, edge.style)
|
|
1178
|
+
claimPathOwners(nodeOnlyGrid, cellOwners, edge.path, edge)
|
|
1179
|
+
determineLabelLine(graph, edge)
|
|
1180
|
+
continue
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
determinePath(graph, edge)
|
|
1184
|
+
rerouteAroundStyleConflicts(
|
|
1185
|
+
graph,
|
|
1186
|
+
edge,
|
|
1187
|
+
cellStyles,
|
|
1188
|
+
cellOwners,
|
|
1189
|
+
nodeOnlyGrid,
|
|
1190
|
+
)
|
|
1191
|
+
increaseGridSizeForPath(graph, edge.path)
|
|
1192
|
+
claimPathCells(nodeOnlyGrid, cellStyles, edge.path, edge.style)
|
|
1193
|
+
claimPathOwners(nodeOnlyGrid, cellOwners, edge.path, edge)
|
|
1194
|
+
determineLabelLine(graph, edge)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Convert grid coords → drawing coords and generate box drawings
|
|
1198
|
+
for (const node of graph.nodes) {
|
|
1199
|
+
node.drawingCoord = gridToDrawingCoord(graph, requireGridCoord(node))
|
|
1200
|
+
node.drawing = drawBox(node, graph)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Compute subgraph bounding boxes and the resulting drawing offset
|
|
1204
|
+
// *before* sizing the canvas. `offsetDrawingForSubgraphs` can set
|
|
1205
|
+
// `graph.offsetX`/`offsetY` to a positive shift (needed whenever a
|
|
1206
|
+
// subgraph's own border padding would otherwise push its content
|
|
1207
|
+
// negative) — every `gridToDrawingCoord` call, including the ones
|
|
1208
|
+
// draw.ts makes later for edge lines, adds that offset to its result.
|
|
1209
|
+
// Sizing the canvas *before* this offset is known (the previous order)
|
|
1210
|
+
// left it too narrow/short by exactly `offsetX`/`offsetY`: node boxes
|
|
1211
|
+
// were retroactively shifted to the correct position (see below), but
|
|
1212
|
+
// nothing widened the canvas array to match, so any edge line whose
|
|
1213
|
+
// drawing coordinate landed in that unreserved margin was silently
|
|
1214
|
+
// dropped by `write()`'s out-of-bounds clip — invisible whenever
|
|
1215
|
+
// nothing happened to route that close to the diagram's far edge, but
|
|
1216
|
+
// a real, reproducible content loss once something did (see #1093,
|
|
1217
|
+
// the `Error --> Idle : retry` edge in the "State: Composite States"
|
|
1218
|
+
// sample, whose rerouted path was the first to reach it).
|
|
1219
|
+
calculateSubgraphBoundingBoxes(graph)
|
|
1220
|
+
offsetDrawingForSubgraphs(graph)
|
|
1221
|
+
|
|
1222
|
+
// Set canvas size, now covering the offset computed above.
|
|
1223
|
+
setCanvasSizeToGrid(
|
|
1224
|
+
graph.canvas,
|
|
1225
|
+
graph.columnWidth,
|
|
1226
|
+
graph.rowHeight,
|
|
1227
|
+
graph.offsetX,
|
|
1228
|
+
graph.offsetY,
|
|
1229
|
+
)
|
|
1230
|
+
setRoleCanvasSizeToGrid(
|
|
1231
|
+
graph.roleCanvas,
|
|
1232
|
+
graph.columnWidth,
|
|
1233
|
+
graph.rowHeight,
|
|
1234
|
+
graph.offsetX,
|
|
1235
|
+
graph.offsetY,
|
|
1236
|
+
)
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ============================================================================
|
|
1240
|
+
// Graph traversal helpers
|
|
1241
|
+
// ============================================================================
|
|
1242
|
+
|
|
1243
|
+
/** Get all edges originating from a node. */
|
|
1244
|
+
function getEdgesFromNode(
|
|
1245
|
+
graph: AsciiGraph,
|
|
1246
|
+
node: AsciiNode,
|
|
1247
|
+
): AsciiGraph['edges'] {
|
|
1248
|
+
return graph.edges.filter((e) => e.from.name === node.name)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Outermost-to-innermost chain of subgraphs directly containing `node`
|
|
1253
|
+
* (empty if the node isn't in any subgraph).
|
|
1254
|
+
*/
|
|
1255
|
+
function subgraphChain(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph[] {
|
|
1256
|
+
const innermost = getNodeSubgraph(graph, node)
|
|
1257
|
+
const chain: AsciiSubgraph[] = []
|
|
1258
|
+
let current: AsciiSubgraph | null = innermost
|
|
1259
|
+
while (current !== null) {
|
|
1260
|
+
chain.unshift(current)
|
|
1261
|
+
current = current.parent
|
|
1262
|
+
}
|
|
1263
|
+
return chain
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Order two nodes the way real mermaid.js orders the sibling subgraphs they
|
|
1268
|
+
* (transitively) belong to, when they diverge into *different* subgraphs
|
|
1269
|
+
* under a shared subgraph ancestor (or both at the top level) — otherwise 0
|
|
1270
|
+
* (no preference; the caller's sort is stable, so original edge-declaration
|
|
1271
|
+
* order is preserved).
|
|
1272
|
+
*
|
|
1273
|
+
* Verified against mermaid@11.17.2's bundled flowDb.getData()
|
|
1274
|
+
* (node_modules/mermaid/dist/mermaid.min.js): it builds the layout node list
|
|
1275
|
+
* by iterating the parsed `subGraphs` array *backwards* to emit cluster
|
|
1276
|
+
* nodes, so sibling subgraphs end up in reversed declaration order in the
|
|
1277
|
+
* graph the layout engine actually sees — see the matching comment in
|
|
1278
|
+
* `../layout-engine/to-elk.ts`'s `mermaidToElk`, and issue #444. The ASCII
|
|
1279
|
+
* grid has no compound-node concept for the layout engine to order — node
|
|
1280
|
+
* position here is driven entirely by BFS descent from edges (see
|
|
1281
|
+
* `placeReachableChildren`) — so this comparator recovers the same visual
|
|
1282
|
+
* left-right order by reordering sibling children at the point they'd
|
|
1283
|
+
* otherwise be placed in raw edge-declaration order.
|
|
1284
|
+
*/
|
|
1285
|
+
function compareBySiblingSubgraphOrder(
|
|
1286
|
+
graph: AsciiGraph,
|
|
1287
|
+
a: AsciiNode,
|
|
1288
|
+
b: AsciiNode,
|
|
1289
|
+
): number {
|
|
1290
|
+
const chainA = subgraphChain(graph, a)
|
|
1291
|
+
const chainB = subgraphChain(graph, b)
|
|
1292
|
+
let i = 0
|
|
1293
|
+
while (i < chainA.length && i < chainB.length && chainA[i] === chainB[i]) {
|
|
1294
|
+
i++
|
|
1295
|
+
}
|
|
1296
|
+
const sgA = chainA[i]
|
|
1297
|
+
const sgB = chainB[i]
|
|
1298
|
+
if (!sgA || !sgB || sgA === sgB) return 0
|
|
1299
|
+
// Reversed declaration order: later-declared sibling sorts first. Sibling
|
|
1300
|
+
// relative declaration order is recovered from each one's position in the
|
|
1301
|
+
// flat `graph.subgraphs` list, which preserves source order (see
|
|
1302
|
+
// `convertSubgraph`'s pre-order-DFS push in converter.ts).
|
|
1303
|
+
return graph.subgraphs.indexOf(sgB) - graph.subgraphs.indexOf(sgA)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** Get all direct children of a node (targets of outgoing edges). */
|
|
1307
|
+
function getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] {
|
|
1308
|
+
return getEdgesFromNode(graph, node).map((e) => e.to)
|
|
1309
|
+
}
|