@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
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — direction system and edge path determination
|
|
3
|
+
//
|
|
4
|
+
// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go.
|
|
5
|
+
// Handles direction constants, edge attachment point selection,
|
|
6
|
+
// and dual-path comparison for optimal edge routing.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
GridCoord,
|
|
11
|
+
Direction,
|
|
12
|
+
AsciiEdge,
|
|
13
|
+
AsciiGraph,
|
|
14
|
+
AsciiNode,
|
|
15
|
+
} from './types.ts'
|
|
16
|
+
import {
|
|
17
|
+
Up,
|
|
18
|
+
Down,
|
|
19
|
+
Left,
|
|
20
|
+
Right,
|
|
21
|
+
UpperRight,
|
|
22
|
+
UpperLeft,
|
|
23
|
+
LowerRight,
|
|
24
|
+
LowerLeft,
|
|
25
|
+
Middle,
|
|
26
|
+
gridCoordDirection,
|
|
27
|
+
dirEquals,
|
|
28
|
+
requireCardinalDirection,
|
|
29
|
+
} from './types.ts'
|
|
30
|
+
import { routeEdge, mergePath } from './pathfinder.ts'
|
|
31
|
+
import { getNodeSubgraph, requireGridCoord } from './grid.ts'
|
|
32
|
+
import { displayWidth } from './display-width.ts'
|
|
33
|
+
import { isOccupied, pathCells } from './grid-occupancy.ts'
|
|
34
|
+
|
|
35
|
+
// Re-exported for existing consumers (draw-arrows.ts, draw-lines.ts,
|
|
36
|
+
// draw-bundles.ts, shapes/*.ts) that import dirEquals from this module —
|
|
37
|
+
// the implementation itself lives in types.ts now, alongside the other
|
|
38
|
+
// coordinate-equality helpers (gridCoordEquals, drawingCoordEquals).
|
|
39
|
+
export { dirEquals }
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Direction utilities
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
export function getOpposite(d: Direction): Direction {
|
|
46
|
+
if (d === Up) return Down
|
|
47
|
+
if (d === Down) return Up
|
|
48
|
+
if (d === Left) return Right
|
|
49
|
+
if (d === Right) return Left
|
|
50
|
+
if (d === UpperRight) return LowerLeft
|
|
51
|
+
if (d === UpperLeft) return LowerRight
|
|
52
|
+
if (d === LowerRight) return UpperLeft
|
|
53
|
+
if (d === LowerLeft) return UpperRight
|
|
54
|
+
return Middle
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Determine 8-way direction from one coordinate to another.
|
|
59
|
+
* Uses the coordinate difference to pick one of 8 cardinal/ordinal directions.
|
|
60
|
+
*/
|
|
61
|
+
export function determineDirection(
|
|
62
|
+
from: { x: number; y: number },
|
|
63
|
+
to: { x: number; y: number },
|
|
64
|
+
): Direction {
|
|
65
|
+
if (from.x === to.x) {
|
|
66
|
+
return from.y < to.y ? Down : Up
|
|
67
|
+
} else if (from.y === to.y) {
|
|
68
|
+
return from.x < to.x ? Right : Left
|
|
69
|
+
} else if (from.x < to.x) {
|
|
70
|
+
return from.y < to.y ? LowerRight : UpperRight
|
|
71
|
+
} else {
|
|
72
|
+
return from.y < to.y ? LowerLeft : UpperLeft
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Start/end direction selection for edges
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/** Self-reference routing (node points to itself). */
|
|
81
|
+
function selfReferenceDirection(
|
|
82
|
+
graphDirection: string,
|
|
83
|
+
): [Direction, Direction, Direction, Direction] {
|
|
84
|
+
if (graphDirection === 'LR') return [Right, Down, Down, Right]
|
|
85
|
+
return [Down, Right, Right, Down]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Determine preferred and alternative start/end directions for an edge.
|
|
90
|
+
* Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd].
|
|
91
|
+
*
|
|
92
|
+
* The edge routing tries both pairs and picks the shorter path.
|
|
93
|
+
* Direction selection depends on relative node positions and graph direction (LR vs TD).
|
|
94
|
+
*/
|
|
95
|
+
export function determineStartAndEndDir(
|
|
96
|
+
edge: AsciiEdge,
|
|
97
|
+
graphDirection: string,
|
|
98
|
+
): [Direction, Direction, Direction, Direction] {
|
|
99
|
+
if (edge.from === edge.to) return selfReferenceDirection(graphDirection)
|
|
100
|
+
|
|
101
|
+
const d = determineDirection(
|
|
102
|
+
requireGridCoord(edge.from),
|
|
103
|
+
requireGridCoord(edge.to),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
let preferredDir: Direction
|
|
107
|
+
let preferredOppositeDir: Direction
|
|
108
|
+
let alternativeDir: Direction
|
|
109
|
+
let alternativeOppositeDir: Direction
|
|
110
|
+
|
|
111
|
+
const isBackwards =
|
|
112
|
+
graphDirection === 'LR'
|
|
113
|
+
? dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft)
|
|
114
|
+
: dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight)
|
|
115
|
+
|
|
116
|
+
if (dirEquals(d, LowerRight)) {
|
|
117
|
+
if (graphDirection === 'LR') {
|
|
118
|
+
preferredDir = Down
|
|
119
|
+
preferredOppositeDir = Left
|
|
120
|
+
alternativeDir = Right
|
|
121
|
+
alternativeOppositeDir = Up
|
|
122
|
+
} else {
|
|
123
|
+
preferredDir = Right
|
|
124
|
+
preferredOppositeDir = Up
|
|
125
|
+
alternativeDir = Down
|
|
126
|
+
alternativeOppositeDir = Left
|
|
127
|
+
}
|
|
128
|
+
} else if (dirEquals(d, UpperRight)) {
|
|
129
|
+
if (graphDirection === 'LR') {
|
|
130
|
+
preferredDir = Up
|
|
131
|
+
preferredOppositeDir = Left
|
|
132
|
+
alternativeDir = Right
|
|
133
|
+
alternativeOppositeDir = Down
|
|
134
|
+
} else {
|
|
135
|
+
preferredDir = Right
|
|
136
|
+
preferredOppositeDir = Down
|
|
137
|
+
alternativeDir = Up
|
|
138
|
+
alternativeOppositeDir = Left
|
|
139
|
+
}
|
|
140
|
+
} else if (dirEquals(d, LowerLeft)) {
|
|
141
|
+
if (graphDirection === 'LR') {
|
|
142
|
+
preferredDir = Down
|
|
143
|
+
preferredOppositeDir = Down
|
|
144
|
+
alternativeDir = Left
|
|
145
|
+
alternativeOppositeDir = Up
|
|
146
|
+
} else {
|
|
147
|
+
preferredDir = Left
|
|
148
|
+
preferredOppositeDir = Up
|
|
149
|
+
alternativeDir = Down
|
|
150
|
+
alternativeOppositeDir = Right
|
|
151
|
+
}
|
|
152
|
+
} else if (dirEquals(d, UpperLeft)) {
|
|
153
|
+
if (graphDirection === 'LR') {
|
|
154
|
+
preferredDir = Down
|
|
155
|
+
preferredOppositeDir = Down
|
|
156
|
+
alternativeDir = Left
|
|
157
|
+
alternativeOppositeDir = Down
|
|
158
|
+
} else {
|
|
159
|
+
preferredDir = Right
|
|
160
|
+
preferredOppositeDir = Right
|
|
161
|
+
alternativeDir = Up
|
|
162
|
+
alternativeOppositeDir = Right
|
|
163
|
+
}
|
|
164
|
+
} else if (isBackwards) {
|
|
165
|
+
if (graphDirection === 'LR' && dirEquals(d, Left)) {
|
|
166
|
+
preferredDir = Down
|
|
167
|
+
preferredOppositeDir = Down
|
|
168
|
+
alternativeDir = Left
|
|
169
|
+
alternativeOppositeDir = Right
|
|
170
|
+
} else if (graphDirection === 'TD' && dirEquals(d, Up)) {
|
|
171
|
+
preferredDir = Right
|
|
172
|
+
preferredOppositeDir = Right
|
|
173
|
+
alternativeDir = Up
|
|
174
|
+
alternativeOppositeDir = Down
|
|
175
|
+
} else {
|
|
176
|
+
preferredDir = d
|
|
177
|
+
preferredOppositeDir = getOpposite(d)
|
|
178
|
+
alternativeDir = d
|
|
179
|
+
alternativeOppositeDir = getOpposite(d)
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Default: go in the natural direction
|
|
183
|
+
preferredDir = d
|
|
184
|
+
preferredOppositeDir = getOpposite(d)
|
|
185
|
+
alternativeDir = d
|
|
186
|
+
alternativeOppositeDir = getOpposite(d)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
preferredDir,
|
|
191
|
+
preferredOppositeDir,
|
|
192
|
+
alternativeDir,
|
|
193
|
+
alternativeOppositeDir,
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Parallel edge (multi-edge) lane assignment
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Grid rows (for a horizontal departure) or columns (for a vertical
|
|
203
|
+
* departure) between successive parallel-edge lanes. See
|
|
204
|
+
* `buildParallelLanePath` below.
|
|
205
|
+
*/
|
|
206
|
+
const PARALLEL_LANE_STEP = 2
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Bound on how far `buildParallelLanePath` will push a lane outward
|
|
210
|
+
* (1 grid unit per attempt) to escape a cell occupied by some *other*
|
|
211
|
+
* node's reserved block. Generous enough for any realistic diagram (a
|
|
212
|
+
* stack of many dozens of sibling rows/columns), while still bounding the
|
|
213
|
+
* search — see that function's doc for why an unbounded search isn't safe
|
|
214
|
+
* to assume will terminate quickly on a pathological graph.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_LANE_OFFSET_SEARCH = 200
|
|
217
|
+
|
|
218
|
+
/** Whether `cell` falls inside `node`'s own reserved 3x3 block. */
|
|
219
|
+
function isCellInNodeBlock(node: AsciiNode, cell: GridCoord): boolean {
|
|
220
|
+
const gc = node.gridCoord
|
|
221
|
+
if (!gc) return false
|
|
222
|
+
return (
|
|
223
|
+
cell.x >= gc.x && cell.x <= gc.x + 2 && cell.y >= gc.y && cell.y <= gc.y + 2
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Every cell in `cells` strictly between its own first and last entry is
|
|
229
|
+
* free of any node's reserved block — except a block listed in `ownNodes`,
|
|
230
|
+
* which is allowed. The first/last cells are excluded outright, because
|
|
231
|
+
* callers use this on lines/paths whose two endpoints are expected to
|
|
232
|
+
* touch a node border (where an edge departs/arrives) or an already-
|
|
233
|
+
* validated waypoint; only cells genuinely *passed through* matter here.
|
|
234
|
+
*
|
|
235
|
+
* Two callers, two different `ownNodes`:
|
|
236
|
+
* - `determineLabelLine` calls this with no exemptions at all: label text
|
|
237
|
+
* drawn across *any* node's border — including the edge's own source or
|
|
238
|
+
* target — corrupts that node's box-drawing characters (see #329's
|
|
239
|
+
* "Second┬Arrow" regression), so nothing is exempt there.
|
|
240
|
+
* - `buildParallelLanePath` calls this with `[edge.from, edge.to]`
|
|
241
|
+
* exempted: an edge's line is *expected* to graze its own node's other
|
|
242
|
+
* border cells on the way to its actual attachment point — that's
|
|
243
|
+
* exactly how the ├/┤/┬/┴ box connector glyphs get chosen elsewhere in
|
|
244
|
+
* this renderer (see edge-cell-styles.ts's module doc for the same
|
|
245
|
+
* "an edge's own node border is a legitimate shared cell" reasoning).
|
|
246
|
+
* Only a *different*, unrelated node's block is a genuine collision
|
|
247
|
+
* there.
|
|
248
|
+
*/
|
|
249
|
+
function interiorCellsClearOfNodes(
|
|
250
|
+
graph: AsciiGraph,
|
|
251
|
+
cells: readonly GridCoord[],
|
|
252
|
+
ownNodes: readonly AsciiNode[] = [],
|
|
253
|
+
): boolean {
|
|
254
|
+
for (let i = 1; i < cells.length - 1; i++) {
|
|
255
|
+
const cell = cells[i]!
|
|
256
|
+
if (!isOccupied(graph.grid, cell)) continue
|
|
257
|
+
if (ownNodes.some((n) => isCellInNodeBlock(n, cell))) continue
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether `edge`'s two nodes are laid out side by side on the same grid
|
|
265
|
+
* row — the layout in which every edge between them, in either direction,
|
|
266
|
+
* routes through one *horizontal* channel and so shares one label row.
|
|
267
|
+
*
|
|
268
|
+
* This is the discriminator for whether a reverse-direction sibling needs a
|
|
269
|
+
* lane of its own; see `parallelGroupKey` below for why.
|
|
270
|
+
*
|
|
271
|
+
* Deliberately symmetric in `from`/`to` (it only compares the two grid
|
|
272
|
+
* coordinates), so an edge and its reciprocal partner always agree on the
|
|
273
|
+
* answer and therefore on their group key. A node with no grid coordinate
|
|
274
|
+
* yet answers `false`, which just leaves that edge on the conservative
|
|
275
|
+
* ordered-pair grouping.
|
|
276
|
+
*/
|
|
277
|
+
function sharesHorizontalChannel(edge: AsciiEdge): boolean {
|
|
278
|
+
const from = edge.from.gridCoord
|
|
279
|
+
const to = edge.to.gridCoord
|
|
280
|
+
if (!from || !to) return false
|
|
281
|
+
return from.y === to.y && from.x !== to.x
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Grouping key for `assignParallelEdgeLanes` below: two edges get separate
|
|
286
|
+
* lanes exactly when this returns the same key for both.
|
|
287
|
+
*
|
|
288
|
+
* The base case is the *ordered* pair (`A -->|One| B` twice, #329) — two
|
|
289
|
+
* edges in the same direction always compute the identical center path,
|
|
290
|
+
* whatever the layout.
|
|
291
|
+
*
|
|
292
|
+
* A *reverse*-direction sibling (`B --> A` alongside `A --> B`, #629) is a
|
|
293
|
+
* different edge with its own natural route, so it collides in one specific
|
|
294
|
+
* layout: when the two nodes sit side by side on the same grid row, both
|
|
295
|
+
* edges route through the same horizontal channel, which puts both labels
|
|
296
|
+
* on the same row and lets one edge's line overwrite the other's arrowhead.
|
|
297
|
+
* That is the case this key folds together, by ordering the two node names.
|
|
298
|
+
*
|
|
299
|
+
* When the pair is stacked vertically instead, `drawTextOnLine`
|
|
300
|
+
* (draw-arrows.ts) already de-collides the two labels by pulling each into
|
|
301
|
+
* its own half of the shared vertical segment — a mechanism built for
|
|
302
|
+
* exactly this shape (#530) that only applies to a vertical segment.
|
|
303
|
+
* Grouping those two edges would route one of them through a lane and take
|
|
304
|
+
* that mechanism out of play, so they stay in separate ordered groups.
|
|
305
|
+
*
|
|
306
|
+
* `JSON.stringify` over the name pair rather than a delimiter-joined
|
|
307
|
+
* string: a node name may itself contain the delimiter, and `["A B", "C"]`
|
|
308
|
+
* must not collide with `["A", "B C"]`.
|
|
309
|
+
*/
|
|
310
|
+
function parallelGroupKey(edge: AsciiEdge): string {
|
|
311
|
+
const from = edge.from.name
|
|
312
|
+
const to = edge.to.name
|
|
313
|
+
if (sharesHorizontalChannel(edge) && to < from) {
|
|
314
|
+
return JSON.stringify([to, from])
|
|
315
|
+
}
|
|
316
|
+
return JSON.stringify([from, to])
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Group edges that connect the same pair of nodes — two edges in the same
|
|
321
|
+
* direction (`A -->|One| B` and `A -->|Two| B`), or, when the two nodes are
|
|
322
|
+
* laid out side by side, an edge and its reverse-direction partner
|
|
323
|
+
* (`A -->|req| B` and `B -->|res| A`) — and tag each with its 0-based
|
|
324
|
+
* position in the group plus the group's size, via `edge.parallelLane`.
|
|
325
|
+
* `parallelGroupKey` above defines exactly which pairs group together, and
|
|
326
|
+
* why a vertically-stacked reciprocal pair is deliberately left out.
|
|
327
|
+
*
|
|
328
|
+
* `determinePath` below reads this to route every edge past the first
|
|
329
|
+
* through a distinct offset lane instead of the shared center path they
|
|
330
|
+
* would otherwise all compute independently (see #329: identical paths
|
|
331
|
+
* meant identically-positioned labels drawn on top of each other,
|
|
332
|
+
* corrupting each other's text — and #629, the same defect between an edge
|
|
333
|
+
* and its reverse-direction partner).
|
|
334
|
+
*
|
|
335
|
+
* Self-loops are excluded (`edge.from === edge.to`): they're routed and
|
|
336
|
+
* drawn as a dedicated loop shape, not a lane-offset line between two
|
|
337
|
+
* distinct nodes, so they're out of scope here.
|
|
338
|
+
*
|
|
339
|
+
* Must run before `analyzeEdgeBundles` (edge-bundling.ts): that module's
|
|
340
|
+
* `canBundle` also refuses to fold a lane-assigned group into one shared
|
|
341
|
+
* fan-in/fan-out trunk (see its own doc comment), but the two checks are
|
|
342
|
+
* independent — this function is the one that actually assigns lanes for
|
|
343
|
+
* `determinePath` to use.
|
|
344
|
+
*
|
|
345
|
+
* Must also run after node placement: `sharesHorizontalChannel` reads both
|
|
346
|
+
* nodes' grid coordinates (grid.ts's call site satisfies this — it runs
|
|
347
|
+
* this immediately before bundling analysis, well after layout).
|
|
348
|
+
*/
|
|
349
|
+
export function assignParallelEdgeLanes(graph: AsciiGraph): void {
|
|
350
|
+
const groups = new Map<string, AsciiEdge[]>()
|
|
351
|
+
for (const edge of graph.edges) {
|
|
352
|
+
if (edge.from === edge.to) continue // self-loop: not in scope here
|
|
353
|
+
const key = parallelGroupKey(edge)
|
|
354
|
+
const existing = groups.get(key)
|
|
355
|
+
if (existing) existing.push(edge)
|
|
356
|
+
else groups.set(key, [edge])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const group of groups.values()) {
|
|
360
|
+
if (group.length < 2) continue
|
|
361
|
+
const usedOffsets = new Set<number>()
|
|
362
|
+
for (let i = 0; i < group.length; i++) {
|
|
363
|
+
group[i]!.parallelLane = { index: i, total: group.length, usedOffsets }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Build an offset-lane path for a non-first edge in a parallel-edge group:
|
|
370
|
+
* leave the source node at the same attachment point every sibling in the
|
|
371
|
+
* group shares (consistent with how two edges are already allowed to share
|
|
372
|
+
* a node's own border cell — see edge-cell-styles.ts's module doc), travel
|
|
373
|
+
* an offset lane, then return to the target's attachment point. Two
|
|
374
|
+
* candidate shapes are tried per offset, in order:
|
|
375
|
+
*
|
|
376
|
+
* - **"wide"**: descend/cross straight down `fromAttach`'s own column/row,
|
|
377
|
+
* travel the *full* node-to-node span at the offset lane, then rise back
|
|
378
|
+
* up `toAttach`'s own column/row. This is what `determineLabelLine`
|
|
379
|
+
* wants — a segment already wide enough to host the label outright —
|
|
380
|
+
* and is correct whenever nothing else occupies that column/row at the
|
|
381
|
+
* chosen offset.
|
|
382
|
+
* - **"gutter"**: like "wide", but the two short jogs near each endpoint
|
|
383
|
+
* step one extra grid unit into the gutter column/row immediately
|
|
384
|
+
* before/after that node — reserved by every node's own
|
|
385
|
+
* `setColumnWidth` padding (`columnWidth.set(gc.x - 1, ...)` in
|
|
386
|
+
* grid.ts) and never claimed by `placeBlock` for *any* node, at *any*
|
|
387
|
+
* row/column, by construction of the grid-level spacing scheme (`x`/`y`
|
|
388
|
+
* levels are always `NODE_BLOCK_SIZE` + 1 apart). "wide" fails exactly
|
|
389
|
+
* when a second, independent `X --> Y` chain placed directly below
|
|
390
|
+
* `A`/`B` (a completely ordinary "second flow stacked under the first"
|
|
391
|
+
* layout) shares `A`'s/`B`'s exact border column, so "wide"'s straight
|
|
392
|
+
* descent runs right through `X`'s/`Y`'s own reserved block — "gutter"
|
|
393
|
+
* detours around exactly that, at the cost of a narrower (often single-
|
|
394
|
+
* column) middle segment; still a legitimate label location, just via
|
|
395
|
+
* determineLabelLine's node-avoidance fallback tier rather than its
|
|
396
|
+
* width-based one. (Routing each jog through `routeEdge`/A* was tried
|
|
397
|
+
* before landing on this: A* has no notion of "this occupied cell is my
|
|
398
|
+
* *own* node's, so it's fine to graze", so it detoured *away* from the
|
|
399
|
+
* source's/target's own border on every lane, producing a self-crossing
|
|
400
|
+
* zigzag instead of a clean jog.)
|
|
401
|
+
*
|
|
402
|
+
* Each candidate is validated via `interiorCellsClearOfNodes` (the edge's
|
|
403
|
+
* own two nodes exempted — grazing them is expected, exactly like the
|
|
404
|
+
* `edge-cell-styles.ts` "own node border is a legitimate shared cell"
|
|
405
|
+
* reasoning) before being accepted. If neither shape is clear at the
|
|
406
|
+
* current offset, it's pushed out one more grid unit and both are retried
|
|
407
|
+
* — bounded by `MAX_LANE_OFFSET_SEARCH` — mirroring `findNonNodeColumn`'s
|
|
408
|
+
* "search outward for a non-node column" pattern above, generalized to a
|
|
409
|
+
* whole path.
|
|
410
|
+
*
|
|
411
|
+
* The offset always grows in one direction (never negative) so it can
|
|
412
|
+
* never land on a grid row/column that already exists above/left of the
|
|
413
|
+
* node block — `gridToDrawingCoord`'s row/column summation only handles
|
|
414
|
+
* coordinates from 0 upward, and `increaseGridSizeForPath` (grid.ts) is
|
|
415
|
+
* what actually grows the canvas for a lane's newly-introduced row/column.
|
|
416
|
+
*
|
|
417
|
+
* `preferredDir`/`preferredOppositeDir` are whatever `determineStartAndEndDir`
|
|
418
|
+
* computed for this edge (identical for every edge in the group, since they
|
|
419
|
+
* share the same from/to nodes) — always one of the four cardinal
|
|
420
|
+
* directions. A horizontal departure (Left/Right) offsets by row; a
|
|
421
|
+
* vertical departure (Up/Down) offsets by column.
|
|
422
|
+
*
|
|
423
|
+
* If every offset within the search bound is still blocked for both shapes
|
|
424
|
+
* (a genuinely pathological, densely-stacked graph), the last attempted
|
|
425
|
+
* path is returned anyway rather than failing the whole render — the same
|
|
426
|
+
* graceful-degradation-over-a-hard-failure choice `findNonNodeColumn` and
|
|
427
|
+
* `determinePath`'s Case-4 direct fallback already make elsewhere in this
|
|
428
|
+
* file.
|
|
429
|
+
*/
|
|
430
|
+
/** A lane path plus the segment `determinePath` should use directly as
|
|
431
|
+
* this edge's label line — see `buildParallelLanePath`'s doc for why this
|
|
432
|
+
* is computed explicitly here rather than left to `determineLabelLine`'s
|
|
433
|
+
* general (width-based) heuristic. */
|
|
434
|
+
interface ParallelLaneRoute {
|
|
435
|
+
path: GridCoord[]
|
|
436
|
+
labelSegment: [GridCoord, GridCoord]
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildParallelLanePath(
|
|
440
|
+
graph: AsciiGraph,
|
|
441
|
+
edge: AsciiEdge,
|
|
442
|
+
preferredDir: Direction,
|
|
443
|
+
preferredOppositeDir: Direction,
|
|
444
|
+
laneIndex: number,
|
|
445
|
+
): ParallelLaneRoute {
|
|
446
|
+
const fromAttach = gridCoordDirection(
|
|
447
|
+
requireGridCoord(edge.from),
|
|
448
|
+
preferredDir,
|
|
449
|
+
)
|
|
450
|
+
const toAttach = gridCoordDirection(
|
|
451
|
+
requireGridCoord(edge.to),
|
|
452
|
+
preferredOppositeDir,
|
|
453
|
+
)
|
|
454
|
+
const horizontalDeparture =
|
|
455
|
+
dirEquals(preferredDir, Left) || dirEquals(preferredDir, Right)
|
|
456
|
+
const ownNodes = [edge.from, edge.to]
|
|
457
|
+
// Shared, by reference, across every edge in this parallel group (see
|
|
458
|
+
// types.ts's parallelLane doc) — records every offset a sibling lane has
|
|
459
|
+
// already committed to, so two lanes that each have to detour around the
|
|
460
|
+
// same obstacle can't converge on the identical offset (and therefore
|
|
461
|
+
// the identical path — the exact bug this whole mechanism exists to
|
|
462
|
+
// prevent, just between two lanes instead of the original center path).
|
|
463
|
+
const usedOffsets = edge.parallelLane!.usedOffsets
|
|
464
|
+
|
|
465
|
+
// One grid unit further from each node, in the direction the edge
|
|
466
|
+
// already departs/arrives — lands in the permanently node-free gutter
|
|
467
|
+
// (see doc above) rather than the node's own border column/row.
|
|
468
|
+
const fromStep = dirEquals(preferredDir, Right)
|
|
469
|
+
? 1
|
|
470
|
+
: dirEquals(preferredDir, Left)
|
|
471
|
+
? -1
|
|
472
|
+
: dirEquals(preferredDir, Down)
|
|
473
|
+
? 1
|
|
474
|
+
: -1
|
|
475
|
+
const toStep = dirEquals(preferredOppositeDir, Right)
|
|
476
|
+
? 1
|
|
477
|
+
: dirEquals(preferredOppositeDir, Left)
|
|
478
|
+
? -1
|
|
479
|
+
: dirEquals(preferredOppositeDir, Down)
|
|
480
|
+
? 1
|
|
481
|
+
: -1
|
|
482
|
+
const fromGutter = horizontalDeparture
|
|
483
|
+
? { x: fromAttach.x + fromStep, y: fromAttach.y }
|
|
484
|
+
: { x: fromAttach.x, y: fromAttach.y + fromStep }
|
|
485
|
+
const toGutter = horizontalDeparture
|
|
486
|
+
? { x: toAttach.x + toStep, y: toAttach.y }
|
|
487
|
+
: { x: toAttach.x, y: toAttach.y + toStep }
|
|
488
|
+
|
|
489
|
+
// Always-valid fallback (ignores occupancy entirely) in case every
|
|
490
|
+
// offset attempt below fails outright — mirrors determinePath's own
|
|
491
|
+
// Case-4 direct fallback: an edge must always end up with *some* path so
|
|
492
|
+
// its arrowhead can still be drawn. Its label segment is the same
|
|
493
|
+
// degenerate zero-length line determineLabelLine itself falls back to
|
|
494
|
+
// for a path with no real segments — see this function's caller.
|
|
495
|
+
let path: GridCoord[] = [fromAttach, toAttach]
|
|
496
|
+
let labelSegment: [GridCoord, GridCoord] = [fromAttach, toAttach]
|
|
497
|
+
|
|
498
|
+
const startingOffset = PARALLEL_LANE_STEP * laneIndex
|
|
499
|
+
for (let step = 0; step < MAX_LANE_OFFSET_SEARCH; step++) {
|
|
500
|
+
const offset = startingOffset + step
|
|
501
|
+
if (usedOffsets.has(offset)) continue
|
|
502
|
+
const laneMain = fromAttach.y + offset
|
|
503
|
+
const laneCross = fromAttach.x + offset
|
|
504
|
+
|
|
505
|
+
// Candidate A ("wide"): travel the offset lane across the *full*
|
|
506
|
+
// node-to-node span (fromAttach.x..toAttach.x, or the vertical
|
|
507
|
+
// equivalent). Its middle segment is always genuinely wide (it spans
|
|
508
|
+
// two distinct nodes' attachment columns/rows), so it's used directly
|
|
509
|
+
// as the label segment — no need to search for it afterward. Tried
|
|
510
|
+
// first because it's clearly the better-looking result whenever safe.
|
|
511
|
+
const wideLabelSegment: [GridCoord, GridCoord] = horizontalDeparture
|
|
512
|
+
? [
|
|
513
|
+
{ x: fromAttach.x, y: laneMain },
|
|
514
|
+
{ x: toAttach.x, y: laneMain },
|
|
515
|
+
]
|
|
516
|
+
: [
|
|
517
|
+
{ x: laneCross, y: fromAttach.y },
|
|
518
|
+
{ x: laneCross, y: toAttach.y },
|
|
519
|
+
]
|
|
520
|
+
const wideCandidate = mergePath([fromAttach, ...wideLabelSegment, toAttach])
|
|
521
|
+
if (interiorCellsClearOfNodes(graph, pathCells(wideCandidate), ownNodes)) {
|
|
522
|
+
usedOffsets.add(offset)
|
|
523
|
+
return { path: wideCandidate, labelSegment: wideLabelSegment }
|
|
524
|
+
}
|
|
525
|
+
path = wideCandidate
|
|
526
|
+
labelSegment = wideLabelSegment
|
|
527
|
+
|
|
528
|
+
// Candidate B ("gutter"): candidate A travels along fromAttach's/
|
|
529
|
+
// toAttach's own border column/row for the vertical/horizontal jog,
|
|
530
|
+
// which can run straight through an unrelated node sharing that same
|
|
531
|
+
// column/row (see this function's doc) — candidate A's occupancy
|
|
532
|
+
// check just caught exactly that. Detour those two short jogs through
|
|
533
|
+
// the permanently node-free gutter instead.
|
|
534
|
+
//
|
|
535
|
+
// `fromGutter`/`toGutter` coincide exactly whenever the two nodes sit
|
|
536
|
+
// on directly adjacent grid levels (the ordinary case for "two edges
|
|
537
|
+
// between the same pair") — there being only one gutter column/row
|
|
538
|
+
// between them. When that happens the "travel" segment collapses to a
|
|
539
|
+
// single point, which every sibling lane's gutter candidate would
|
|
540
|
+
// share identically — reintroducing #329's own bug, just between two
|
|
541
|
+
// *lanes* instead of the original center path (this was caught while
|
|
542
|
+
// building this fix: see ascii-parallel-edges-329.test.ts's "no lane
|
|
543
|
+
// path passes through a cell owned by the unrelated X/Y chain" test).
|
|
544
|
+
// Use the vertical/horizontal "spike" descent instead in that case — a
|
|
545
|
+
// zero-length point *at* this lane's own offset depth, not a segment
|
|
546
|
+
// spanning from the attach row/column down to it: drawTextOnLine
|
|
547
|
+
// centers text at a line's *midpoint*, so a segment running from (say)
|
|
548
|
+
// row 1 down to row 7 centers its label at row 4, not row 7 — two
|
|
549
|
+
// lanes at different depths (rows 5 and 7, say) can still produce the
|
|
550
|
+
// *same* midpoint as some other lane's own row, corrupting labels all
|
|
551
|
+
// over again (also caught by the same test named above). A point's
|
|
552
|
+
// min/max/midpoint are all itself, so the label always lands exactly
|
|
553
|
+
// at this lane's own unique depth — narrow (its column/row is widened
|
|
554
|
+
// below like any single-segment label host), but unique per lane.
|
|
555
|
+
const gutterTravelHasWidth = horizontalDeparture
|
|
556
|
+
? fromGutter.x !== toGutter.x
|
|
557
|
+
: fromGutter.y !== toGutter.y
|
|
558
|
+
const gutterLabelSegment: [GridCoord, GridCoord] = horizontalDeparture
|
|
559
|
+
? gutterTravelHasWidth
|
|
560
|
+
? [
|
|
561
|
+
{ x: fromGutter.x, y: laneMain },
|
|
562
|
+
{ x: toGutter.x, y: laneMain },
|
|
563
|
+
]
|
|
564
|
+
: [
|
|
565
|
+
{ x: fromGutter.x, y: laneMain },
|
|
566
|
+
{ x: fromGutter.x, y: laneMain },
|
|
567
|
+
]
|
|
568
|
+
: gutterTravelHasWidth
|
|
569
|
+
? [
|
|
570
|
+
{ x: laneCross, y: fromGutter.y },
|
|
571
|
+
{ x: laneCross, y: toGutter.y },
|
|
572
|
+
]
|
|
573
|
+
: [
|
|
574
|
+
{ x: laneCross, y: fromGutter.y },
|
|
575
|
+
{ x: laneCross, y: fromGutter.y },
|
|
576
|
+
]
|
|
577
|
+
const gutterCandidate = horizontalDeparture
|
|
578
|
+
? mergePath([
|
|
579
|
+
fromAttach,
|
|
580
|
+
fromGutter,
|
|
581
|
+
{ x: fromGutter.x, y: laneMain },
|
|
582
|
+
{ x: toGutter.x, y: laneMain },
|
|
583
|
+
toGutter,
|
|
584
|
+
toAttach,
|
|
585
|
+
])
|
|
586
|
+
: mergePath([
|
|
587
|
+
fromAttach,
|
|
588
|
+
fromGutter,
|
|
589
|
+
{ x: laneCross, y: fromGutter.y },
|
|
590
|
+
{ x: laneCross, y: toGutter.y },
|
|
591
|
+
toGutter,
|
|
592
|
+
toAttach,
|
|
593
|
+
])
|
|
594
|
+
if (
|
|
595
|
+
interiorCellsClearOfNodes(graph, pathCells(gutterCandidate), ownNodes)
|
|
596
|
+
) {
|
|
597
|
+
usedOffsets.add(offset)
|
|
598
|
+
return { path: gutterCandidate, labelSegment: gutterLabelSegment }
|
|
599
|
+
}
|
|
600
|
+
// Defensive — shouldn't normally happen. The gutter candidate's
|
|
601
|
+
// vertical/horizontal travel runs entirely through the permanently
|
|
602
|
+
// node-free gutter column/row (see this function's doc: every node's
|
|
603
|
+
// own column-width padding reserves it, and placeBlock never claims
|
|
604
|
+
// it for any node), so gutterCandidate failing this check would mean
|
|
605
|
+
// that structural guarantee didn't hold for this graph's layout.
|
|
606
|
+
// Recorded anyway so the loop's very last iteration still has a
|
|
607
|
+
// best-effort path/labelSegment to fall back to.
|
|
608
|
+
/* v8 ignore next */
|
|
609
|
+
path = gutterCandidate
|
|
610
|
+
/* v8 ignore next */
|
|
611
|
+
labelSegment = gutterLabelSegment
|
|
612
|
+
}
|
|
613
|
+
// Defensive — shouldn't normally happen, for the same reason as above:
|
|
614
|
+
// reaching here means every offset up to MAX_LANE_OFFSET_SEARCH failed
|
|
615
|
+
// both candidates, which requires the gutter's structural guarantee to
|
|
616
|
+
// not hold. Mirrors determinePath's own Case-4 direct fallback and
|
|
617
|
+
// findNonNodeColumn's "every column occupied" fallback elsewhere in this
|
|
618
|
+
// file: graceful degradation over a hard failure, not a case expected to
|
|
619
|
+
// be exercised by a realistic graph.
|
|
620
|
+
/* v8 ignore next */
|
|
621
|
+
return { path, labelSegment }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ============================================================================
|
|
625
|
+
// Edge path determination
|
|
626
|
+
// ============================================================================
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Determine the path for an edge by trying two candidate routes (preferred + alternative)
|
|
630
|
+
* and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir.
|
|
631
|
+
*
|
|
632
|
+
* When both A* paths fail (common for edges crossing subgraph boundaries), falls back
|
|
633
|
+
* to a direct path using the start/end points. This ensures edges always have a path
|
|
634
|
+
* for arrowhead rendering.
|
|
635
|
+
*
|
|
636
|
+
* Uses the effective direction for edge routing, respecting subgraph direction overrides
|
|
637
|
+
* when both source and target are in the same subgraph.
|
|
638
|
+
*/
|
|
639
|
+
export function determinePath(graph: AsciiGraph, edge: AsciiEdge): void {
|
|
640
|
+
// Determine effective direction for this edge
|
|
641
|
+
// If both nodes are in the same subgraph with a direction override, use it
|
|
642
|
+
// Otherwise, use the graph's direction (not source's effective direction)
|
|
643
|
+
const sourceSg = getNodeSubgraph(graph, edge.from)
|
|
644
|
+
const targetSg = getNodeSubgraph(graph, edge.to)
|
|
645
|
+
const effectiveDir =
|
|
646
|
+
sourceSg && sourceSg === targetSg && sourceSg.direction
|
|
647
|
+
? sourceSg.direction
|
|
648
|
+
: graph.config.graphDirection
|
|
649
|
+
|
|
650
|
+
const [
|
|
651
|
+
preferredDir,
|
|
652
|
+
preferredOppositeDir,
|
|
653
|
+
alternativeDir,
|
|
654
|
+
alternativeOppositeDir,
|
|
655
|
+
] = determineStartAndEndDir(edge, effectiveDir)
|
|
656
|
+
|
|
657
|
+
// Edges after the first in a true-parallel (same source AND target) group
|
|
658
|
+
// skip the normal preferred/alternative search entirely: that search
|
|
659
|
+
// would just find the identical center path every sibling edge shares,
|
|
660
|
+
// which is the root cause of #329. Route through an offset lane instead
|
|
661
|
+
// so this edge's path — and its label — never overlaps a sibling's.
|
|
662
|
+
//
|
|
663
|
+
// The label line is set directly here (via applyLabelLine), not left for
|
|
664
|
+
// determineLabelLine's later call to work out: buildParallelLanePath
|
|
665
|
+
// already knows exactly which segment of the lane it just built is the
|
|
666
|
+
// correct, sibling-distinct place for the label (see its own doc for why
|
|
667
|
+
// determineLabelLine's general width-based heuristic can't reliably
|
|
668
|
+
// re-derive that from the finished path alone). determineLabelLine
|
|
669
|
+
// itself skips lane edges for exactly this reason — see its own guard.
|
|
670
|
+
if (edge.parallelLane && edge.parallelLane.index > 0) {
|
|
671
|
+
// A reverse-direction sibling (`B --> A` alongside `A --> B`, #629) runs
|
|
672
|
+
// "backwards" relative to the graph direction, so determineStartAndEndDir
|
|
673
|
+
// hands back a degenerate *same-side* pair — Down/Down in LR, Right/Right
|
|
674
|
+
// in TD — describing a U-shaped detour that leaves and re-enters on the
|
|
675
|
+
// same face. buildParallelLanePath's offset math can't express that
|
|
676
|
+
// shape: it picks its offset axis from whether the *departure* is
|
|
677
|
+
// horizontal, which for a Down/Down U-shape is exactly inverted (it
|
|
678
|
+
// offsets by column when the lane actually travels horizontally and needs
|
|
679
|
+
// a row offset), landing the lane on the nodes' own border rows. Use the
|
|
680
|
+
// alternative (straight-through) pair for such an edge instead, making a
|
|
681
|
+
// reverse-direction lane the mirror image of a forward one — the shape
|
|
682
|
+
// #329's fix already produces for a same-direction sibling. A forward
|
|
683
|
+
// edge's preferred and alternative pairs are identical (see
|
|
684
|
+
// determineStartAndEndDir's default branch), so this never changes how an
|
|
685
|
+
// existing same-direction group routes.
|
|
686
|
+
const degenerateFace = dirEquals(preferredDir, preferredOppositeDir)
|
|
687
|
+
const laneDir = degenerateFace ? alternativeDir : preferredDir
|
|
688
|
+
const laneOppositeDir = degenerateFace
|
|
689
|
+
? alternativeOppositeDir
|
|
690
|
+
: preferredOppositeDir
|
|
691
|
+
edge.startDir = laneDir
|
|
692
|
+
edge.endDir = laneOppositeDir
|
|
693
|
+
const route = buildParallelLanePath(
|
|
694
|
+
graph,
|
|
695
|
+
edge,
|
|
696
|
+
laneDir,
|
|
697
|
+
laneOppositeDir,
|
|
698
|
+
edge.parallelLane.index,
|
|
699
|
+
)
|
|
700
|
+
edge.path = route.path
|
|
701
|
+
if (edge.text.length > 0) {
|
|
702
|
+
applyLabelLine(graph, edge, route.labelSegment, displayWidth(edge.text))
|
|
703
|
+
}
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Try preferred path — routeEdge tries an unobstructed direct L-shape
|
|
708
|
+
// before falling back to A* (see routeEdge / tryDirectPath in
|
|
709
|
+
// pathfinder.ts). determineStartAndEndDir only ever produces one of the
|
|
710
|
+
// four pure cardinal directions (see its implementation above), but that
|
|
711
|
+
// invariant lives in this function's control flow, not in Direction's
|
|
712
|
+
// type, so it's narrowed explicitly at the routeEdge boundary rather than
|
|
713
|
+
// trusted silently across the module.
|
|
714
|
+
const prefFrom = gridCoordDirection(requireGridCoord(edge.from), preferredDir)
|
|
715
|
+
const prefTo = gridCoordDirection(
|
|
716
|
+
requireGridCoord(edge.to),
|
|
717
|
+
preferredOppositeDir,
|
|
718
|
+
)
|
|
719
|
+
const preferredPath = routeEdge(
|
|
720
|
+
graph,
|
|
721
|
+
prefFrom,
|
|
722
|
+
prefTo,
|
|
723
|
+
requireCardinalDirection(preferredDir),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
// Try alternative path
|
|
727
|
+
const altFrom = gridCoordDirection(
|
|
728
|
+
requireGridCoord(edge.from),
|
|
729
|
+
alternativeDir,
|
|
730
|
+
)
|
|
731
|
+
const altTo = gridCoordDirection(
|
|
732
|
+
requireGridCoord(edge.to),
|
|
733
|
+
alternativeOppositeDir,
|
|
734
|
+
)
|
|
735
|
+
const alternativePath = routeEdge(
|
|
736
|
+
graph,
|
|
737
|
+
altFrom,
|
|
738
|
+
altTo,
|
|
739
|
+
requireCardinalDirection(alternativeDir),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
// Case 1: Both paths found — pick the shorter one (routeEdge already merged each)
|
|
743
|
+
if (preferredPath !== null && alternativePath !== null) {
|
|
744
|
+
if (preferredPath.length <= alternativePath.length) {
|
|
745
|
+
edge.startDir = preferredDir
|
|
746
|
+
edge.endDir = preferredOppositeDir
|
|
747
|
+
edge.path = preferredPath
|
|
748
|
+
} else {
|
|
749
|
+
edge.startDir = alternativeDir
|
|
750
|
+
edge.endDir = alternativeOppositeDir
|
|
751
|
+
edge.path = alternativePath
|
|
752
|
+
}
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Case 2: Only preferred path found
|
|
757
|
+
if (preferredPath !== null) {
|
|
758
|
+
edge.startDir = preferredDir
|
|
759
|
+
edge.endDir = preferredOppositeDir
|
|
760
|
+
edge.path = preferredPath
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Case 3: Only alternative path found
|
|
765
|
+
if (alternativePath !== null) {
|
|
766
|
+
edge.startDir = alternativeDir
|
|
767
|
+
edge.endDir = alternativeOppositeDir
|
|
768
|
+
edge.path = alternativePath
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Case 4: Both paths failed — create a direct fallback path
|
|
773
|
+
// This happens for edges crossing subgraph boundaries where A* can't find
|
|
774
|
+
// a clear route. We create a direct path from source to target exit points
|
|
775
|
+
// so arrowheads can still be rendered correctly.
|
|
776
|
+
//
|
|
777
|
+
// `[prefFrom, prefTo]` alone is a single non-axis-aligned (diagonal) grid
|
|
778
|
+
// segment whenever the two points differ on both axes. draw-lines.ts's
|
|
779
|
+
// drawLine never draws that as a straight diagonal — it draws it as an L,
|
|
780
|
+
// horizontal-first (see expandDiagonalSegments' own doc, and #418/#1083,
|
|
781
|
+
// which already had to special-case this same diagonal-vs-drawn-L gap for
|
|
782
|
+
// label placement and arrowhead direction respectively). Everything else
|
|
783
|
+
// that reasons about `edge.path` — in particular edge-cell-styles.ts's
|
|
784
|
+
// cross-style overlap detection, which walks `edge.path` via `pathCells`
|
|
785
|
+
// assuming it already matches the drawn geometry — implicitly assumes a
|
|
786
|
+
// path is axis-aligned. Left as a raw diagonal pair, `pathCells` walks a
|
|
787
|
+
// Bresenham diagonal that visits a completely different set of cells than
|
|
788
|
+
// the L drawLine actually draws, so two Case-4 edges (or a Case-4 edge and
|
|
789
|
+
// an ordinary routed one) whose *drawn* horizontal legs genuinely overlap
|
|
790
|
+
// are never flagged as a conflict, and the later-drawn edge's style
|
|
791
|
+
// silently overwrites the earlier one's on that shared row (#1067, "All
|
|
792
|
+
// Edge Styles": the dotted edge's horizontal leg rendered as the thick
|
|
793
|
+
// edge's heavy ━ instead of dashed). Expanding here — once, at the
|
|
794
|
+
// source — keeps every downstream consumer (drawing, label placement,
|
|
795
|
+
// conflict detection, corner glyphs) working from the same axis-aligned
|
|
796
|
+
// path instead of each needing its own diagonal-awareness.
|
|
797
|
+
edge.startDir = preferredDir
|
|
798
|
+
edge.endDir = preferredOppositeDir
|
|
799
|
+
edge.path = expandDiagonalSegments([prefFrom, prefTo])
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Check whether grid column `x` falls inside any node's reserved 3-column block. */
|
|
803
|
+
function isNodeOccupiedColumn(graph: AsciiGraph, x: number): boolean {
|
|
804
|
+
for (const node of graph.nodes) {
|
|
805
|
+
const gc = node.gridCoord
|
|
806
|
+
if (gc && x >= gc.x && x <= gc.x + 2) return true
|
|
807
|
+
}
|
|
808
|
+
return false
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Find the column closest to `ideal` (within [minX, maxX]) that isn't part
|
|
813
|
+
* of any node's reserved block, searching outward in both directions. Falls
|
|
814
|
+
* back to `ideal` itself if every column in range is node-occupied (e.g. a
|
|
815
|
+
* very short segment squeezed between two nodes) — there's no better option.
|
|
816
|
+
*/
|
|
817
|
+
function findNonNodeColumn(
|
|
818
|
+
graph: AsciiGraph,
|
|
819
|
+
minX: number,
|
|
820
|
+
maxX: number,
|
|
821
|
+
ideal: number,
|
|
822
|
+
): number {
|
|
823
|
+
if (!isNodeOccupiedColumn(graph, ideal)) return ideal
|
|
824
|
+
for (let d = 1; d <= maxX - minX; d++) {
|
|
825
|
+
const right = ideal + d
|
|
826
|
+
if (right <= maxX && !isNodeOccupiedColumn(graph, right)) return right
|
|
827
|
+
const left = ideal - d
|
|
828
|
+
if (left >= minX && !isNodeOccupiedColumn(graph, left)) return left
|
|
829
|
+
}
|
|
830
|
+
return ideal
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Find the best line segment in an edge's path to place a label on.
|
|
835
|
+
* Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid
|
|
836
|
+
* label collisions when multiple edges share initial segments.
|
|
837
|
+
* Falls back to the widest segment if none are suitable.
|
|
838
|
+
* Also increases the column width at the label position to fit the text.
|
|
839
|
+
*
|
|
840
|
+
* No-ops for an edge past the first in a parallel-edge group
|
|
841
|
+
* (`edge.parallelLane.index > 0`): `determinePath` already called
|
|
842
|
+
* `applyLabelLine` directly for it, with a segment `buildParallelLanePath`
|
|
843
|
+
* chose explicitly rather than one this function's own width-based search
|
|
844
|
+
* could reliably re-derive from the finished lane path alone (see that
|
|
845
|
+
* function's doc) — re-running the search here would risk picking a
|
|
846
|
+
* different, sibling-colliding segment instead.
|
|
847
|
+
*/
|
|
848
|
+
export function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void {
|
|
849
|
+
if (edge.text.length === 0) return
|
|
850
|
+
if (edge.parallelLane && edge.parallelLane.index > 0) return
|
|
851
|
+
|
|
852
|
+
const lenLabel = displayWidth(edge.text)
|
|
853
|
+
|
|
854
|
+
// Every routed path is axis-aligned (A* is 4-directional and
|
|
855
|
+
// tryDirectPath builds explicit L-shapes), with one exception:
|
|
856
|
+
// determinePath's Case-4 direct fallback, `[prefFrom, prefTo]`, which is
|
|
857
|
+
// a single *diagonal* segment. draw-lines.ts's drawLine never draws a
|
|
858
|
+
// diagonal — it draws that segment as an L, horizontal-first (along
|
|
859
|
+
// from.y to to.x, then down/up to to.y). A label centered on the
|
|
860
|
+
// diagonal itself lands mid-way between the two legs, in open grid that
|
|
861
|
+
// has nothing to do with this edge and is often right next to some
|
|
862
|
+
// *other* edge's connector (#418, "All Edge Styles": the `thick` label
|
|
863
|
+
// abutting the dotted edge's column). Expand any diagonal segment into
|
|
864
|
+
// the same two legs drawLine will draw, so the label search below only
|
|
865
|
+
// ever considers line segments that actually get drawn.
|
|
866
|
+
const points = expandDiagonalSegments(edge.path)
|
|
867
|
+
const pathLen = points.length
|
|
868
|
+
|
|
869
|
+
// Collect all segments with their widths and orientation
|
|
870
|
+
const segments: {
|
|
871
|
+
line: [GridCoord, GridCoord]
|
|
872
|
+
width: number
|
|
873
|
+
index: number
|
|
874
|
+
isVertical: boolean
|
|
875
|
+
}[] = []
|
|
876
|
+
|
|
877
|
+
for (let i = 1; i < pathLen; i++) {
|
|
878
|
+
const p1 = points[i - 1]!
|
|
879
|
+
const p2 = points[i]!
|
|
880
|
+
const line: [GridCoord, GridCoord] = [p1, p2]
|
|
881
|
+
const width = calculateLineWidth(graph, line)
|
|
882
|
+
// A segment is vertical if X coords are same, horizontal if Y coords are same
|
|
883
|
+
const isVertical = p1.x === p2.x
|
|
884
|
+
segments.push({ line, width, index: i, isVertical })
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// A segment whose *interior* (every cell strictly between its own two
|
|
888
|
+
// endpoints — not the endpoints themselves, which are expected to touch
|
|
889
|
+
// a node border or a path corner) passes through a cell owned by some
|
|
890
|
+
// node's 3x3 block is a bad place for a label: the label text would be
|
|
891
|
+
// drawn across that node's own box-drawing characters instead of open
|
|
892
|
+
// grid. An ordinary A*/direct-routed path never does this (routing
|
|
893
|
+
// itself avoids node-occupied cells), but edge-routing.ts's parallel
|
|
894
|
+
// lane paths (buildParallelLanePath, for true multi-edges — see #329)
|
|
895
|
+
// are constructed directly rather than pathfound, and their short jog
|
|
896
|
+
// back into the target can otherwise look, by the width-only heuristic
|
|
897
|
+
// below, like the best available segment even though it runs straight
|
|
898
|
+
// across the target's own border row/column.
|
|
899
|
+
const clearOfNodes = (line: [GridCoord, GridCoord]): boolean =>
|
|
900
|
+
interiorCellsClearOfNodes(graph, pathCells(line))
|
|
901
|
+
|
|
902
|
+
// A segment is "terminal" when one of its own two endpoints IS the
|
|
903
|
+
// source's or target's box-border attachment point — i.e. it's the very
|
|
904
|
+
// first or very last segment of the path. `clearOfNodes` above can't
|
|
905
|
+
// catch a bad terminal segment on its own: it only inspects *interior*
|
|
906
|
+
// cells (by design — a path's endpoints are expected to touch a node
|
|
907
|
+
// border), so a short, node-adjacent segment with no interior cells at
|
|
908
|
+
// all (e.g. the single-grid-step box-start connector straight into a
|
|
909
|
+
// node) sails through as "clear" even though its own endpoint sits
|
|
910
|
+
// exactly on that node's border column/row. Centering a label on such a
|
|
911
|
+
// segment lands the text right where the border glyph (or box-start
|
|
912
|
+
// connector) is drawn, silently erasing it (#450) — the label canvas is
|
|
913
|
+
// merged on top of the node box canvas, so whichever writes last wins.
|
|
914
|
+
// Excluding both terminal segments (not just the first, as before) from
|
|
915
|
+
// the primary and "later" fallback tiers below keeps a label away from
|
|
916
|
+
// either node's border, symmetric with how the first segment was already
|
|
917
|
+
// excluded for being "often shared between edges from the same source
|
|
918
|
+
// node".
|
|
919
|
+
const isTerminalSegment = (s: (typeof segments)[number]): boolean =>
|
|
920
|
+
s.index === 1 || s.index === segments.length
|
|
921
|
+
|
|
922
|
+
// Find segments wide enough for the label, excluding terminal segments
|
|
923
|
+
// (the first, often shared between edges from the same source node; and
|
|
924
|
+
// the last, which is the box-start connector straight into the target —
|
|
925
|
+
// see isTerminalSegment above).
|
|
926
|
+
const suitableSegments = segments.filter(
|
|
927
|
+
(s) => s.width >= lenLabel && !isTerminalSegment(s) && clearOfNodes(s.line),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
let largestLine: [GridCoord, GridCoord]
|
|
931
|
+
|
|
932
|
+
if (suitableSegments.length > 0) {
|
|
933
|
+
// Prefer segments near the end of the path (closer to target)
|
|
934
|
+
// This avoids the shared initial segments from source
|
|
935
|
+
suitableSegments.sort((a, b) => b.index - a.index)
|
|
936
|
+
largestLine = suitableSegments[0]!.line
|
|
937
|
+
} else {
|
|
938
|
+
// Fall back to any suitable segment, including a terminal one (the
|
|
939
|
+
// first or last — see isTerminalSegment above): a very short path with
|
|
940
|
+
// no non-terminal segment at all has nothing better to offer, and a
|
|
941
|
+
// label landing on a terminal segment's *interior* is still preferable
|
|
942
|
+
// to no label at all. (A terminal segment with literally zero interior
|
|
943
|
+
// cells — the box-start-connector case #450 was filed against — can
|
|
944
|
+
// still slip through here; that's an accepted last-resort trade-off,
|
|
945
|
+
// not a regression, since this tier only runs once every non-terminal
|
|
946
|
+
// segment has already been ruled out.)
|
|
947
|
+
const fallbackSegments = segments.filter(
|
|
948
|
+
(s) => s.width >= lenLabel && clearOfNodes(s.line),
|
|
949
|
+
)
|
|
950
|
+
if (fallbackSegments.length > 0) {
|
|
951
|
+
fallbackSegments.sort((a, b) => b.index - a.index)
|
|
952
|
+
largestLine = fallbackSegments[0]!.line
|
|
953
|
+
} else {
|
|
954
|
+
// No segment both wide enough and clear of nodes — prefer the
|
|
955
|
+
// widest segment that's at least clear of nodes (its column can
|
|
956
|
+
// still be widened below to fit the label). Still prefer a
|
|
957
|
+
// non-terminal segment here too: a multi-segment lane path
|
|
958
|
+
// (buildParallelLanePath) can have every one of its later segments
|
|
959
|
+
// come up too narrow for this tier (e.g. a single-gutter-column
|
|
960
|
+
// "gutter" candidate — see that function's doc), and without this,
|
|
961
|
+
// the widest-clear-segment sort below would happily fall back to the
|
|
962
|
+
// *first* segment purely because it's typically the widest (it's on
|
|
963
|
+
// the shared trunk row every sibling edge from the same source
|
|
964
|
+
// uses) — reintroducing the exact same-row label collision this
|
|
965
|
+
// whole terminal-segment exclusion exists to prevent, just via this
|
|
966
|
+
// tier instead of the ones above. Only fall back further — first to
|
|
967
|
+
// *any* clear segment regardless of terminal-ness, then to every
|
|
968
|
+
// segment — when nothing clear survives even that relaxation (a
|
|
969
|
+
// single-segment edge with no room to exclude anything, or a segment
|
|
970
|
+
// that runs through a node with no alternative at all).
|
|
971
|
+
const clearLaterSegments = segments.filter(
|
|
972
|
+
(s) => !isTerminalSegment(s) && clearOfNodes(s.line),
|
|
973
|
+
)
|
|
974
|
+
const clearSegments = segments.filter((s) => clearOfNodes(s.line))
|
|
975
|
+
const pool =
|
|
976
|
+
clearLaterSegments.length > 0
|
|
977
|
+
? clearLaterSegments
|
|
978
|
+
: clearSegments.length > 0
|
|
979
|
+
? clearSegments
|
|
980
|
+
: segments
|
|
981
|
+
pool.sort((a, b) => b.width - a.width)
|
|
982
|
+
if (pool.length > 0) {
|
|
983
|
+
largestLine = pool[0]!.line
|
|
984
|
+
} else {
|
|
985
|
+
// No segments at all: edge.path has fewer than 2 points. This
|
|
986
|
+
// happens when a routed edge's preferred from/to grid coordinates
|
|
987
|
+
// coincide (e.g. closely-spaced/adjacent nodes), so getPath (see
|
|
988
|
+
// pathfinder.ts) returns a single-point path. Treat it as a
|
|
989
|
+
// degenerate zero-length line at that point instead of indexing
|
|
990
|
+
// past the end of a 1-element (or empty) array.
|
|
991
|
+
const only = edge.path[0] ?? { x: 0, y: 0 }
|
|
992
|
+
largestLine = [only, only]
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
applyLabelLine(graph, edge, largestLine, lenLabel)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Replace every diagonal step in `path` (consecutive points that differ in
|
|
1002
|
+
* both x and y) with the two axis-aligned legs draw-lines.ts's drawLine
|
|
1003
|
+
* actually draws for it: horizontal first, to the corner `{x: next.x,
|
|
1004
|
+
* y: prev.y}`, then vertical. Axis-aligned steps pass through unchanged, so
|
|
1005
|
+
* an ordinary routed path comes back identical. Only determinePath's Case-4
|
|
1006
|
+
* direct fallback produces a diagonal in practice — see determineLabelLine.
|
|
1007
|
+
*/
|
|
1008
|
+
export function expandDiagonalSegments(path: GridCoord[]): GridCoord[] {
|
|
1009
|
+
const out: GridCoord[] = []
|
|
1010
|
+
for (const point of path) {
|
|
1011
|
+
const prev = out[out.length - 1]
|
|
1012
|
+
if (prev && prev.x !== point.x && prev.y !== point.y) {
|
|
1013
|
+
out.push({ x: point.x, y: prev.y })
|
|
1014
|
+
}
|
|
1015
|
+
out.push(point)
|
|
1016
|
+
}
|
|
1017
|
+
return out
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Finish assigning a label to `line`: widen whichever column its midpoint
|
|
1022
|
+
* falls in enough to fit the label, then commit `edge.labelLine`. Shared
|
|
1023
|
+
* tail for `determineLabelLine`'s own segment-search heuristic above and
|
|
1024
|
+
* `buildParallelLanePath`'s explicit segment choice below — the latter
|
|
1025
|
+
* already knows exactly which segment is correct for its own lane (see
|
|
1026
|
+
* that function's doc for why the heuristic above can't reliably infer it
|
|
1027
|
+
* for a parallel-lane path), so it skips straight to this shared finish
|
|
1028
|
+
* instead of going through the search.
|
|
1029
|
+
*
|
|
1030
|
+
* The chosen column must not be one a node itself occupies (its 3-column
|
|
1031
|
+
* border/content/border block, reserved in reserveSpotInGrid): a shared
|
|
1032
|
+
* trunk segment often passes directly over/adjacent to another node's
|
|
1033
|
+
* reserved columns on its way elsewhere, and if the label's midpoint
|
|
1034
|
+
* happens to land there, widening it to fit the label inflates that
|
|
1035
|
+
* node's own border-column width — which then drags things anchored to
|
|
1036
|
+
* that column's *center* (like the box-start ├/┤/┬/┴ connector in
|
|
1037
|
+
* draw-arrows.ts, computed via gridToDrawingCoord) away from the node's
|
|
1038
|
+
* actual, fixed-width rendered border. Search outward from the ideal
|
|
1039
|
+
* midpoint for the nearest column in the segment that isn't node-owned.
|
|
1040
|
+
*/
|
|
1041
|
+
function applyLabelLine(
|
|
1042
|
+
graph: AsciiGraph,
|
|
1043
|
+
edge: AsciiEdge,
|
|
1044
|
+
line: [GridCoord, GridCoord],
|
|
1045
|
+
lenLabel: number,
|
|
1046
|
+
): void {
|
|
1047
|
+
const minX = Math.min(line[0].x, line[1].x)
|
|
1048
|
+
const maxX = Math.max(line[0].x, line[1].x)
|
|
1049
|
+
const idealX = minX + Math.floor((maxX - minX) / 2)
|
|
1050
|
+
const middleX = findNonNodeColumn(graph, minX, maxX, idealX)
|
|
1051
|
+
|
|
1052
|
+
const current = graph.columnWidth.get(middleX) ?? 0
|
|
1053
|
+
graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2))
|
|
1054
|
+
|
|
1055
|
+
edge.labelLine = [line[0], line[1]]
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Calculate the total character width of a line segment by summing column widths. */
|
|
1059
|
+
function calculateLineWidth(
|
|
1060
|
+
graph: AsciiGraph,
|
|
1061
|
+
line: [GridCoord, GridCoord],
|
|
1062
|
+
): number {
|
|
1063
|
+
let total = 0
|
|
1064
|
+
const startX = Math.min(line[0].x, line[1].x)
|
|
1065
|
+
const endX = Math.max(line[0].x, line[1].x)
|
|
1066
|
+
for (let x = startX; x <= endX; x++) {
|
|
1067
|
+
total += graph.columnWidth.get(x) ?? 0
|
|
1068
|
+
}
|
|
1069
|
+
return total
|
|
1070
|
+
}
|