@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,435 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — edge bundling for parallel links
|
|
3
|
+
//
|
|
4
|
+
// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and
|
|
5
|
+
// groups them into bundles. Bundled edges share a visual junction point
|
|
6
|
+
// where they merge/split, creating cleaner diagrams.
|
|
7
|
+
//
|
|
8
|
+
// This module provides:
|
|
9
|
+
// - analyzeEdgeBundles(): Finds and creates bundles from graph edges
|
|
10
|
+
// - calculateJunctionPoint(): Computes optimal merge/split locations
|
|
11
|
+
// - routeBundledEdges(): Routes edges through junction points
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AsciiGraph,
|
|
16
|
+
AsciiNode,
|
|
17
|
+
AsciiEdge,
|
|
18
|
+
EdgeBundle,
|
|
19
|
+
GridCoord,
|
|
20
|
+
CardinalDirection,
|
|
21
|
+
} from './types.ts'
|
|
22
|
+
import {
|
|
23
|
+
Up,
|
|
24
|
+
Down,
|
|
25
|
+
Left,
|
|
26
|
+
Right,
|
|
27
|
+
Middle,
|
|
28
|
+
gridCoordDirection,
|
|
29
|
+
requireCardinalDirection,
|
|
30
|
+
} from './types.ts'
|
|
31
|
+
import { getNodeSubgraph, requireGridCoord } from './grid.ts'
|
|
32
|
+
import { routeEdge } from './pathfinder.ts'
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Bundle analysis
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Analyze graph edges and create bundles for parallel links.
|
|
40
|
+
*
|
|
41
|
+
* Groups edges by:
|
|
42
|
+
* - Fan-in: Multiple edges sharing the same target (A & B --> C)
|
|
43
|
+
* - Fan-out: Multiple edges sharing the same source (A --> B & C)
|
|
44
|
+
*
|
|
45
|
+
* Only creates bundles when:
|
|
46
|
+
* - Graph direction is TD (top-down) - LR routing handles merging naturally
|
|
47
|
+
* - 2+ edges share the endpoint
|
|
48
|
+
* - All edges have the same style (solid/dotted/thick)
|
|
49
|
+
* - None of the edges have labels (labels would overlap at junction)
|
|
50
|
+
* - Edges are not self-loops
|
|
51
|
+
*
|
|
52
|
+
* @returns Array of bundles. Each edge can belong to at most one bundle.
|
|
53
|
+
*/
|
|
54
|
+
export function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] {
|
|
55
|
+
// Only bundle in TD direction - LR routing handles merging naturally at corners
|
|
56
|
+
if (graph.config.graphDirection !== 'TD') {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
const bundles: EdgeBundle[] = []
|
|
60
|
+
const bundledEdges = new Set<AsciiEdge>()
|
|
61
|
+
|
|
62
|
+
// Group edges by target (fan-in candidates)
|
|
63
|
+
const edgesByTarget = new Map<AsciiNode, AsciiEdge[]>()
|
|
64
|
+
for (const edge of graph.edges) {
|
|
65
|
+
// Skip self-loops
|
|
66
|
+
if (edge.from === edge.to) continue
|
|
67
|
+
|
|
68
|
+
const existing = edgesByTarget.get(edge.to) ?? []
|
|
69
|
+
existing.push(edge)
|
|
70
|
+
edgesByTarget.set(edge.to, existing)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Create fan-in bundles
|
|
74
|
+
for (const [target, edges] of edgesByTarget) {
|
|
75
|
+
if (edges.length < 2) continue
|
|
76
|
+
if (!canBundle(edges, graph)) continue
|
|
77
|
+
|
|
78
|
+
// Check if all edges are already bundled
|
|
79
|
+
if (edges.some((e) => bundledEdges.has(e))) continue
|
|
80
|
+
|
|
81
|
+
const bundle: EdgeBundle = {
|
|
82
|
+
type: 'fan-in',
|
|
83
|
+
edges: [...edges],
|
|
84
|
+
sharedNode: target,
|
|
85
|
+
otherNodes: edges.map((e) => e.from),
|
|
86
|
+
junctionPoint: null,
|
|
87
|
+
sharedPath: [],
|
|
88
|
+
junctionDir: Middle,
|
|
89
|
+
sharedNodeDir: Middle,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Mark edges as bundled
|
|
93
|
+
for (const edge of edges) {
|
|
94
|
+
edge.bundle = bundle
|
|
95
|
+
bundledEdges.add(edge)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
bundles.push(bundle)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Group edges by source (fan-out candidates)
|
|
102
|
+
const edgesBySource = new Map<AsciiNode, AsciiEdge[]>()
|
|
103
|
+
for (const edge of graph.edges) {
|
|
104
|
+
// Skip self-loops and already bundled edges
|
|
105
|
+
if (edge.from === edge.to) continue
|
|
106
|
+
if (bundledEdges.has(edge)) continue
|
|
107
|
+
|
|
108
|
+
const existing = edgesBySource.get(edge.from) ?? []
|
|
109
|
+
existing.push(edge)
|
|
110
|
+
edgesBySource.set(edge.from, existing)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create fan-out bundles
|
|
114
|
+
for (const [source, edges] of edgesBySource) {
|
|
115
|
+
if (edges.length < 2) continue
|
|
116
|
+
if (!canBundle(edges, graph)) continue
|
|
117
|
+
|
|
118
|
+
const bundle: EdgeBundle = {
|
|
119
|
+
type: 'fan-out',
|
|
120
|
+
edges: [...edges],
|
|
121
|
+
sharedNode: source,
|
|
122
|
+
otherNodes: edges.map((e) => e.to),
|
|
123
|
+
junctionPoint: null,
|
|
124
|
+
sharedPath: [],
|
|
125
|
+
junctionDir: Middle,
|
|
126
|
+
sharedNodeDir: Middle,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Mark edges as bundled
|
|
130
|
+
for (const edge of edges) {
|
|
131
|
+
edge.bundle = bundle
|
|
132
|
+
bundledEdges.add(edge)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
bundles.push(bundle)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return bundles
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if a group of edges can be bundled together.
|
|
143
|
+
* Returns false if edges have different styles, any have labels,
|
|
144
|
+
* or if the edges span subgraph boundaries (which creates complex routing).
|
|
145
|
+
*/
|
|
146
|
+
function canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean {
|
|
147
|
+
if (edges.length < 2) return false
|
|
148
|
+
|
|
149
|
+
const firstStyle = edges[0]!.style
|
|
150
|
+
const firstFromSg = getNodeSubgraph(graph, edges[0]!.from)
|
|
151
|
+
const firstToSg = getNodeSubgraph(graph, edges[0]!.to)
|
|
152
|
+
|
|
153
|
+
for (const edge of edges) {
|
|
154
|
+
// Different styles can't be bundled (would look confusing)
|
|
155
|
+
if (edge.style !== firstStyle) return false
|
|
156
|
+
|
|
157
|
+
// Edges with labels can't be bundled (labels would overlap at junction)
|
|
158
|
+
if (edge.text.length > 0) return false
|
|
159
|
+
|
|
160
|
+
// Don't bundle if edges span different subgraph boundaries
|
|
161
|
+
// (creates complex routing that doesn't look good)
|
|
162
|
+
const fromSg = getNodeSubgraph(graph, edge.from)
|
|
163
|
+
const toSg = getNodeSubgraph(graph, edge.to)
|
|
164
|
+
if (fromSg !== firstFromSg || toSg !== firstToSg) return false
|
|
165
|
+
|
|
166
|
+
// Don't bundle if source and target are in different subgraphs
|
|
167
|
+
// (cross-boundary edges have special routing needs)
|
|
168
|
+
if (fromSg !== toSg) return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A group formed by shared-target (fan-in) or shared-source (fan-out)
|
|
172
|
+
// grouping can still contain two edges that ALSO share the other
|
|
173
|
+
// endpoint — true parallel/multi-edges (e.g. two unlabeled `A --> B`
|
|
174
|
+
// edges), not a genuine fan-in/fan-out. Folding those into one shared
|
|
175
|
+
// trunk+junction would visually merge them into a single line (the same
|
|
176
|
+
// "second edge silently wins" defect this bundling feature is supposed
|
|
177
|
+
// to avoid — see #329), rather than the distinct offset lanes
|
|
178
|
+
// assignParallelEdgeLanes (edge-routing.ts) already gives them. Detect it
|
|
179
|
+
// generically: the group is fan-in (shared `.to`) or fan-out (shared
|
|
180
|
+
// `.from`) by construction (see analyzeEdgeBundles' two call sites), so
|
|
181
|
+
// checking whichever endpoint *isn't* uniformly shared for duplicates
|
|
182
|
+
// catches both cases without the caller having to say which one it is.
|
|
183
|
+
const sameTarget = edges.every((e) => e.to === edges[0]!.to)
|
|
184
|
+
const otherEndpoints = sameTarget
|
|
185
|
+
? edges.map((e) => e.from)
|
|
186
|
+
: edges.map((e) => e.to)
|
|
187
|
+
if (new Set(otherEndpoints).size !== otherEndpoints.length) return false
|
|
188
|
+
|
|
189
|
+
// The duplicate check above only catches an *ordered*-pair duplicate. A
|
|
190
|
+
// reverse-direction sibling (`B --> A` alongside `A --> B`, #629) reaches
|
|
191
|
+
// a fan-in/fan-out group with a *distinct* other endpoint and slips past
|
|
192
|
+
// it, even though assignParallelEdgeLanes has already committed that pair
|
|
193
|
+
// to two distinct lanes — and a lane is a fixed offset from the pair's
|
|
194
|
+
// own center path, not from wherever a trunk would have moved its sibling
|
|
195
|
+
// to. So no edge assigned to a lane group ever gets bundled.
|
|
196
|
+
//
|
|
197
|
+
// Defensive: currently unreachable, and deliberately kept anyway. A
|
|
198
|
+
// reverse-direction lane is only assigned when the pair sits side by side
|
|
199
|
+
// on one grid row (edge-routing.ts's sharesHorizontalChannel), which is
|
|
200
|
+
// exactly what the rank-ordering check below already rejects — every
|
|
201
|
+
// source must be strictly above the shared target (or every target
|
|
202
|
+
// strictly below the shared source). Those two conditions happen to be
|
|
203
|
+
// complementary today; nothing structural keeps them that way.
|
|
204
|
+
/* v8 ignore next */
|
|
205
|
+
if (edges.some((e) => e.parallelLane)) return false
|
|
206
|
+
|
|
207
|
+
// Bundling assumes every source sits strictly before the shared target
|
|
208
|
+
// along the graph-direction axis for fan-in (TD: above it; LR: left of
|
|
209
|
+
// it), or every target sits strictly after the shared source for
|
|
210
|
+
// fan-out — calculateJunctionPoint places the junction on that side of
|
|
211
|
+
// the shared node and routeBundledEdges exits/enters it accordingly. The
|
|
212
|
+
// grid layout can violate that: a node's rank gets pinned by whichever
|
|
213
|
+
// incoming edge's source is placed first (placeReachableChildren in
|
|
214
|
+
// grid.ts never revisits an already-placed node), so a "diamond" like
|
|
215
|
+
// `Queue --> Worker` plus `Queue --> Retry --> Worker` can leave Worker
|
|
216
|
+
// at the SAME rank as Retry instead of one rank after it (#454).
|
|
217
|
+
// Bundling that pair would route Retry's edge through the same
|
|
218
|
+
// trunk+arrowhead as Queue's, silently swallowing Retry's own arrowhead
|
|
219
|
+
// into a line that looks like it belongs solely to Queue. Refuse to
|
|
220
|
+
// bundle whenever any edge doesn't actually satisfy the "before the
|
|
221
|
+
// target" / "after the source" assumption; routed independently instead,
|
|
222
|
+
// it gets its own distinct, visible arrowhead into/out of the shared node.
|
|
223
|
+
// `analyzeEdgeBundles` (this function's only caller) already gates on
|
|
224
|
+
// `graphDirection === 'TD'` before ever calling `canBundle`, so the 'LR'
|
|
225
|
+
// arm is unreachable today — kept for whenever LR bundling support
|
|
226
|
+
// lands, matching this comment block's own "TD: above it; LR: left of
|
|
227
|
+
// it" framing above.
|
|
228
|
+
/* v8 ignore next */
|
|
229
|
+
const axis: 'x' | 'y' = graph.config.graphDirection === 'LR' ? 'x' : 'y'
|
|
230
|
+
const sharedNode = sameTarget ? edges[0]!.to : edges[0]!.from
|
|
231
|
+
const sharedCoord = sharedNode.gridCoord
|
|
232
|
+
// Grid layout always runs before bundling — every node reaching this
|
|
233
|
+
// point has a gridCoord (see `requireGridCoord`'s own use lower in this
|
|
234
|
+
// file for the same assumption). Defensive only; not reachable from any
|
|
235
|
+
// real diagram.
|
|
236
|
+
/* v8 ignore next */
|
|
237
|
+
if (!sharedCoord) return false
|
|
238
|
+
for (const edge of edges) {
|
|
239
|
+
const otherNode = sameTarget ? edge.from : edge.to
|
|
240
|
+
const otherCoord = otherNode.gridCoord
|
|
241
|
+
/* v8 ignore next */
|
|
242
|
+
if (!otherCoord) return false
|
|
243
|
+
if (sameTarget) {
|
|
244
|
+
// fan-in: source must be strictly before the shared target
|
|
245
|
+
if (otherCoord[axis] >= sharedCoord[axis]) return false
|
|
246
|
+
} else {
|
|
247
|
+
// fan-out: target must be strictly after the shared source. The
|
|
248
|
+
// fan-in violation above is reachable via a plain diamond (a shared
|
|
249
|
+
// target reached both directly and via a detour — see #454's own
|
|
250
|
+
// repro and this file's regression test) because rank assignment
|
|
251
|
+
// can shortchange whichever of two *competing parents* gets visited
|
|
252
|
+
// second. The mirror-image fan-out violation would need a target's
|
|
253
|
+
// rank to land at or before its own source's — topologically only
|
|
254
|
+
// possible with an actual cycle, not exercised by any sample in
|
|
255
|
+
// this repo. Kept for symmetry with the fan-in case above (and
|
|
256
|
+
// because a cycle isn't inherently invalid mermaid syntax) rather
|
|
257
|
+
// than assumed impossible.
|
|
258
|
+
/* v8 ignore next */
|
|
259
|
+
if (otherCoord[axis] <= sharedCoord[axis]) return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============================================================================
|
|
267
|
+
// Junction point calculation
|
|
268
|
+
// ============================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Calculate the optimal junction point for a bundle.
|
|
272
|
+
*
|
|
273
|
+
* For fan-in (A & B --> C):
|
|
274
|
+
* - Junction is placed between the sources and the target
|
|
275
|
+
* - In TD: above the target, horizontally centered between sources
|
|
276
|
+
* - In LR: left of the target, vertically centered between sources
|
|
277
|
+
*
|
|
278
|
+
* For fan-out (A --> B & C):
|
|
279
|
+
* - Junction is placed between the source and the targets
|
|
280
|
+
* - In TD: below the source, horizontally centered between targets
|
|
281
|
+
* - In LR: right of the source, vertically centered between targets
|
|
282
|
+
*/
|
|
283
|
+
export function calculateJunctionPoint(
|
|
284
|
+
graph: AsciiGraph,
|
|
285
|
+
bundle: EdgeBundle,
|
|
286
|
+
): GridCoord {
|
|
287
|
+
const dir = graph.config.graphDirection
|
|
288
|
+
const sharedCoord = requireGridCoord(bundle.sharedNode)
|
|
289
|
+
|
|
290
|
+
if (bundle.type === 'fan-in') {
|
|
291
|
+
// Junction is BEFORE the shared target
|
|
292
|
+
// Calculate center of sources
|
|
293
|
+
if (dir === 'TD') {
|
|
294
|
+
// Junction above target, centered between sources
|
|
295
|
+
// Place it one row above the target's entry point
|
|
296
|
+
const junctionY = sharedCoord.y - 1
|
|
297
|
+
// X is centered between sources, but clamped to shared node's X for alignment
|
|
298
|
+
const junctionX = sharedCoord.x + 1 // Align with target's center
|
|
299
|
+
|
|
300
|
+
return { x: junctionX, y: junctionY }
|
|
301
|
+
} else {
|
|
302
|
+
// LR: Junction left of target, centered between sources
|
|
303
|
+
const junctionX = sharedCoord.x - 1
|
|
304
|
+
const junctionY = sharedCoord.y + 1 // Align with target's center
|
|
305
|
+
|
|
306
|
+
return { x: junctionX, y: junctionY }
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
// fan-out: Junction is AFTER the shared source
|
|
310
|
+
if (dir === 'TD') {
|
|
311
|
+
// Junction below source, will then split to targets
|
|
312
|
+
const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block
|
|
313
|
+
const junctionX = sharedCoord.x + 1 // Align with source's center
|
|
314
|
+
|
|
315
|
+
return { x: junctionX, y: junctionY }
|
|
316
|
+
} else {
|
|
317
|
+
// LR: Junction right of source
|
|
318
|
+
const junctionX = sharedCoord.x + 3
|
|
319
|
+
const junctionY = sharedCoord.y + 1
|
|
320
|
+
|
|
321
|
+
return { x: junctionX, y: junctionY }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ============================================================================
|
|
327
|
+
// Bundled edge routing
|
|
328
|
+
// ============================================================================
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Route all edges in a bundle through the junction point.
|
|
332
|
+
*
|
|
333
|
+
* For fan-in bundles:
|
|
334
|
+
* 1. Route each source → junction (stored in edge.pathToJunction)
|
|
335
|
+
* 2. Route junction → target (stored in bundle.sharedPath)
|
|
336
|
+
*
|
|
337
|
+
* For fan-out bundles:
|
|
338
|
+
* 1. Route source → junction (stored in bundle.sharedPath)
|
|
339
|
+
* 2. Route junction → each target (stored in edge.pathToJunction)
|
|
340
|
+
*/
|
|
341
|
+
export function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void {
|
|
342
|
+
const dir = graph.config.graphDirection
|
|
343
|
+
|
|
344
|
+
// Calculate and store junction point
|
|
345
|
+
bundle.junctionPoint = calculateJunctionPoint(graph, bundle)
|
|
346
|
+
const junction = bundle.junctionPoint
|
|
347
|
+
|
|
348
|
+
// Determine directions based on graph direction and bundle type
|
|
349
|
+
if (bundle.type === 'fan-in') {
|
|
350
|
+
// Sources converge to junction, then junction to target
|
|
351
|
+
bundle.junctionDir = dir === 'TD' ? Up : Left
|
|
352
|
+
bundle.sharedNodeDir = dir === 'TD' ? Down : Right
|
|
353
|
+
|
|
354
|
+
// Route junction → target (shared path). Anchor offsets (top/left
|
|
355
|
+
// center of target, bottom/right center of source below) are computed
|
|
356
|
+
// via gridCoordDirection — the same per-direction offset edge-routing.ts
|
|
357
|
+
// uses — instead of each bundling call reimplementing its own
|
|
358
|
+
// arithmetic. routeEdge (pathfinder.ts, also used by edge-routing.ts)
|
|
359
|
+
// tries an unobstructed direct path before falling back to A*. Note
|
|
360
|
+
// that `targetDir` here is the *arrival* anchor at the target, not
|
|
361
|
+
// necessarily the true departure direction from `junction` — routeEdge
|
|
362
|
+
// handles that by trying both L-corner orientations internally (see
|
|
363
|
+
// tryDirectPath in pathfinder.ts) rather than trusting this value as a
|
|
364
|
+
// routing axis.
|
|
365
|
+
const targetCoord = requireGridCoord(bundle.sharedNode)
|
|
366
|
+
const targetDir: CardinalDirection = requireCardinalDirection(
|
|
367
|
+
dir === 'TD' ? Up : Left,
|
|
368
|
+
)
|
|
369
|
+
const targetEntry = gridCoordDirection(targetCoord, targetDir)
|
|
370
|
+
|
|
371
|
+
const sharedPath = routeEdge(graph, junction, targetEntry, targetDir)
|
|
372
|
+
bundle.sharedPath = sharedPath ?? [junction, targetEntry]
|
|
373
|
+
|
|
374
|
+
// Route each source → junction
|
|
375
|
+
for (const edge of bundle.edges) {
|
|
376
|
+
const sourceCoord = requireGridCoord(edge.from)
|
|
377
|
+
const sourceDir: CardinalDirection = requireCardinalDirection(
|
|
378
|
+
dir === 'TD' ? Down : Right,
|
|
379
|
+
)
|
|
380
|
+
const sourceExit = gridCoordDirection(sourceCoord, sourceDir)
|
|
381
|
+
|
|
382
|
+
const pathToJunction = routeEdge(graph, sourceExit, junction, sourceDir)
|
|
383
|
+
edge.pathToJunction = pathToJunction ?? [sourceExit, junction]
|
|
384
|
+
|
|
385
|
+
// Set edge directions for proper drawing
|
|
386
|
+
edge.startDir = sourceDir
|
|
387
|
+
edge.endDir = targetDir
|
|
388
|
+
|
|
389
|
+
// Build full path for grid size calculation: source → junction → target
|
|
390
|
+
edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)]
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
// fan-out: Source to junction, then junction splits to targets
|
|
394
|
+
bundle.junctionDir = dir === 'TD' ? Down : Right
|
|
395
|
+
bundle.sharedNodeDir = dir === 'TD' ? Up : Left
|
|
396
|
+
|
|
397
|
+
// Route source → junction (shared path)
|
|
398
|
+
const sourceCoord = requireGridCoord(bundle.sharedNode)
|
|
399
|
+
const sourceDir: CardinalDirection = requireCardinalDirection(
|
|
400
|
+
dir === 'TD' ? Down : Right,
|
|
401
|
+
)
|
|
402
|
+
const sourceExit = gridCoordDirection(sourceCoord, sourceDir)
|
|
403
|
+
|
|
404
|
+
const sharedPath = routeEdge(graph, sourceExit, junction, sourceDir)
|
|
405
|
+
bundle.sharedPath = sharedPath ?? [sourceExit, junction]
|
|
406
|
+
|
|
407
|
+
// Route junction → each target
|
|
408
|
+
for (const edge of bundle.edges) {
|
|
409
|
+
const targetCoord = requireGridCoord(edge.to)
|
|
410
|
+
const targetDir: CardinalDirection = requireCardinalDirection(
|
|
411
|
+
dir === 'TD' ? Up : Left,
|
|
412
|
+
)
|
|
413
|
+
const targetEntry = gridCoordDirection(targetCoord, targetDir)
|
|
414
|
+
|
|
415
|
+
const pathToJunction = routeEdge(graph, junction, targetEntry, targetDir)
|
|
416
|
+
edge.pathToJunction = pathToJunction ?? [junction, targetEntry]
|
|
417
|
+
|
|
418
|
+
// Set edge directions
|
|
419
|
+
edge.startDir = sourceDir
|
|
420
|
+
edge.endDir = targetDir
|
|
421
|
+
|
|
422
|
+
// Build full path for grid size calculation: source → junction → target
|
|
423
|
+
edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)]
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Process all bundles in a graph: calculate junction points and route edges.
|
|
430
|
+
*/
|
|
431
|
+
export function processBundles(graph: AsciiGraph): void {
|
|
432
|
+
for (const bundle of graph.bundles) {
|
|
433
|
+
routeBundledEdges(graph, bundle)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — cross-style edge overlap detection
|
|
3
|
+
//
|
|
4
|
+
// `graph.grid` (grid-occupancy.ts) only tracks *node* occupancy, so two
|
|
5
|
+
// unrelated edges (different source and target — no reason for
|
|
6
|
+
// `analyzeEdgeBundles` to group them, and A* itself only avoids node cells)
|
|
7
|
+
// can independently route through the same empty cell. When both edges have
|
|
8
|
+
// the *same* line style that's harmless — it just looks like one clean
|
|
9
|
+
// shared line, which `analyzeEdgeBundles`'s "LR routing handles merging
|
|
10
|
+
// naturally at corners" comment and its own trunk-sharing tests rely on
|
|
11
|
+
// intentionally. It's only a real defect when the styles *differ*: a solid
|
|
12
|
+
// cell and a dotted cell can't both be drawn at the same grid position, so
|
|
13
|
+
// one silently overwrites the other and the reader can't tell two distinct
|
|
14
|
+
// connections are there at all.
|
|
15
|
+
//
|
|
16
|
+
// This module tracks, per *open, non-node* cell, which single style has
|
|
17
|
+
// claimed it (first claim wins) so grid.ts can detect a genuine cross-style
|
|
18
|
+
// collision after routing an edge and reroute just that edge around it —
|
|
19
|
+
// see `rerouteAroundStyleConflicts` in grid.ts.
|
|
20
|
+
//
|
|
21
|
+
// Node-occupied cells are deliberately excluded from tracking: an edge's
|
|
22
|
+
// path always includes its own source/target node's border cell (that's
|
|
23
|
+
// how it connects to the box at all), so two edges sharing a node — one
|
|
24
|
+
// incoming, one outgoing, as in a retry loop's `B -->|No| D; D -.-> A` —
|
|
25
|
+
// legitimately share that exact port cell. The character actually drawn
|
|
26
|
+
// there comes from the node's own box-drawing code, not either edge's line
|
|
27
|
+
// style, so it was never a real conflict. Blocking it wouldn't even help:
|
|
28
|
+
// an edge always returns to its own node's fixed attachment point
|
|
29
|
+
// regardless of grid occupancy, so re-routing around a node-owned "conflict"
|
|
30
|
+
// just finds the identical cell again on every retry.
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
import type { AsciiEdge, AsciiEdgeStyle, GridCoord } from './types.ts'
|
|
34
|
+
import { gridKey } from './types.ts'
|
|
35
|
+
import { isOccupied, pathCells, type Grid } from './grid-occupancy.ts'
|
|
36
|
+
|
|
37
|
+
/** Cells already claimed by a drawn edge, keyed by "x,y", storing which
|
|
38
|
+
* single line style is drawn there. */
|
|
39
|
+
export type EdgeCellStyles = Map<string, AsciiEdgeStyle>
|
|
40
|
+
|
|
41
|
+
export function createEdgeCellStyles(): EdgeCellStyles {
|
|
42
|
+
return new Map()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The first open (non-node-occupied) cell in `path` already claimed by a
|
|
47
|
+
* *different* style than `style`. Same-style overlap is not reported — see
|
|
48
|
+
* module doc.
|
|
49
|
+
*/
|
|
50
|
+
export function findStyleConflict(
|
|
51
|
+
grid: Grid,
|
|
52
|
+
cellStyles: EdgeCellStyles,
|
|
53
|
+
path: readonly GridCoord[],
|
|
54
|
+
style: AsciiEdgeStyle,
|
|
55
|
+
): GridCoord | null {
|
|
56
|
+
for (const cell of pathCells(path)) {
|
|
57
|
+
if (isOccupied(grid, cell)) continue
|
|
58
|
+
const existing = cellStyles.get(gridKey(cell))
|
|
59
|
+
if (existing !== undefined && existing !== style) return cell
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record every open (non-node-occupied) cell in `path` as claimed by
|
|
66
|
+
* `style`. First claim per cell wins — a later same-style edge through the
|
|
67
|
+
* same cell is a harmless no-op, and a later different-style edge is
|
|
68
|
+
* caught by `findStyleConflict` before this ever runs for it.
|
|
69
|
+
*/
|
|
70
|
+
export function claimPathCells(
|
|
71
|
+
grid: Grid,
|
|
72
|
+
cellStyles: EdgeCellStyles,
|
|
73
|
+
path: readonly GridCoord[],
|
|
74
|
+
style: AsciiEdgeStyle,
|
|
75
|
+
): void {
|
|
76
|
+
for (const cell of pathCells(path)) {
|
|
77
|
+
if (isOccupied(grid, cell)) continue
|
|
78
|
+
const key = gridKey(cell)
|
|
79
|
+
if (!cellStyles.has(key)) cellStyles.set(key, style)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Chain-edge overlap detection (independent of style)
|
|
85
|
+
// ============================================================================
|
|
86
|
+
//
|
|
87
|
+
// The cross-*style* conflict above only fires when two overlapping edges
|
|
88
|
+
// draw different characters — a same-style overlap silently draws the same
|
|
89
|
+
// glyph twice, which this module's own doc calls harmless, and it usually
|
|
90
|
+
// is: `analyzeEdgeBundles`'s own fan-in/fan-out trunk sharing is same-style
|
|
91
|
+
// by construction, and a "complete bipartite" crossing like `A & B --> C &
|
|
92
|
+
// D` (see ascii.test.ts's `ampersand_lhs_and_rhs` golden file) *deliberately*
|
|
93
|
+
// routes two edges that share neither endpoint (`A --> D` and `B --> C`)
|
|
94
|
+
// through the same shared crossbar — that is the intended, faithful
|
|
95
|
+
// rendering of that shape, not a defect.
|
|
96
|
+
//
|
|
97
|
+
// It stops being harmless in one specific shape a plain "shares an
|
|
98
|
+
// endpoint" check can't distinguish from that crossing pattern: a *chain*
|
|
99
|
+
// through a shared intermediate node, `A --> B` then `B --> C`, each routed
|
|
100
|
+
// independently. When their corners happen to coincide, `A`'s incoming leg
|
|
101
|
+
// and `C`'s outgoing leg trace the exact same column/row beyond the shared,
|
|
102
|
+
// node-owned port cell and read as one continuous `A --> C` connector
|
|
103
|
+
// (#1067, "System Architecture": `Mobile App --> API Gateway` and
|
|
104
|
+
// `API Gateway --> User Service` sharing one on-screen row with no visual
|
|
105
|
+
// break, even though there is no `Mobile App --> User Service` edge in the
|
|
106
|
+
// source). Unlike the crossing pattern above, a chain's two edges have no
|
|
107
|
+
// reason to share open-space cells at all beyond that one port — so this
|
|
108
|
+
// check is scoped *only* to chain pairs (`isChainPair` below), not to every
|
|
109
|
+
// pair of edges that merely shares neither endpoint; widening it to that
|
|
110
|
+
// general case is exactly what broke the ampersand golden file during this
|
|
111
|
+
// fix's own development.
|
|
112
|
+
|
|
113
|
+
/** Cells already claimed by drawn edges, keyed by "x,y", storing every edge
|
|
114
|
+
* (by reference) that drew there — independent of `EdgeCellStyles` above,
|
|
115
|
+
* which tracks *style* rather than edge identity.
|
|
116
|
+
*
|
|
117
|
+
* Stores a `Set`, not a single edge: `graph.edges` order means an edge
|
|
118
|
+
* unrelated to any chain can claim a cell before a real chain pair's own
|
|
119
|
+
* edges do, and a single-owner map would let that unrelated edge's claim
|
|
120
|
+
* hide the chain pair's overlap from `findUnrelatedOverlap` entirely. See
|
|
121
|
+
* that function's own doc.
|
|
122
|
+
*/
|
|
123
|
+
export type EdgeCellOwners = Map<string, Set<AsciiEdge>>
|
|
124
|
+
|
|
125
|
+
export function createEdgeCellOwners(): EdgeCellOwners {
|
|
126
|
+
return new Map()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether `a` and `b` form a chain through a shared intermediate node: one
|
|
131
|
+
* edge's target is the other's source. Deliberately narrower than "shares
|
|
132
|
+
* any endpoint" — a fan-in/fan-out pair (shared `from` or shared `to`) is
|
|
133
|
+
* excluded on purpose, since that is exactly the shape a legitimate shared
|
|
134
|
+
* trunk (bundled or not — see module doc) already routes through common
|
|
135
|
+
* cells for.
|
|
136
|
+
*/
|
|
137
|
+
function isChainPair(a: AsciiEdge, b: AsciiEdge): boolean {
|
|
138
|
+
return a.to === b.from || b.to === a.from
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Minimum number of open, non-node cells a chain pair must share before it
|
|
143
|
+
* counts as a real conflict. A single shared cell is an ordinary crossing
|
|
144
|
+
* (two independent lines passing through the same point, which still reads
|
|
145
|
+
* as two lines) — it takes a *run* of shared cells, long enough to read as
|
|
146
|
+
* one continuous connector, to actually mislead a reader. Chosen to be the
|
|
147
|
+
* smallest value that catches a shared corner-to-corner leg (at least 2
|
|
148
|
+
* collinear cells) while still letting a lone crossing through.
|
|
149
|
+
*/
|
|
150
|
+
const MIN_CHAIN_OVERLAP = 2
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The first open (non-node-occupied) cell in `path` already claimed by a
|
|
154
|
+
* different edge that forms a chain with `edge` (`isChainPair`) — but only
|
|
155
|
+
* once *that specific chain partner's* total overlap with `edge` reaches
|
|
156
|
+
* `MIN_CHAIN_OVERLAP` cells (see that constant's doc). Each distinct owner
|
|
157
|
+
* of a cell is checked and counted independently — a cell can hold several
|
|
158
|
+
* edges' claims (see `EdgeCellOwners`'s own doc), and an unrelated same-style
|
|
159
|
+
* edge sharing a cell must not hide a real chain partner's overlap that also
|
|
160
|
+
* claimed it. Returns `null` when no chain partner's overlap reaches the
|
|
161
|
+
* threshold.
|
|
162
|
+
*/
|
|
163
|
+
export function findUnrelatedOverlap(
|
|
164
|
+
grid: Grid,
|
|
165
|
+
owners: EdgeCellOwners,
|
|
166
|
+
path: readonly GridCoord[],
|
|
167
|
+
edge: AsciiEdge,
|
|
168
|
+
): GridCoord | null {
|
|
169
|
+
const overlapCounts = new Map<AsciiEdge, number>()
|
|
170
|
+
const firstConflicts = new Map<AsciiEdge, GridCoord>()
|
|
171
|
+
for (const cell of pathCells(path)) {
|
|
172
|
+
if (isOccupied(grid, cell)) continue
|
|
173
|
+
const cellOwners = owners.get(gridKey(cell))
|
|
174
|
+
if (cellOwners === undefined) continue
|
|
175
|
+
for (const owner of cellOwners) {
|
|
176
|
+
if (owner === edge) continue
|
|
177
|
+
if (!isChainPair(owner, edge)) continue
|
|
178
|
+
overlapCounts.set(owner, (overlapCounts.get(owner) ?? 0) + 1)
|
|
179
|
+
if (!firstConflicts.has(owner)) firstConflicts.set(owner, cell)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [owner, count] of overlapCounts) {
|
|
183
|
+
if (count >= MIN_CHAIN_OVERLAP) return firstConflicts.get(owner)!
|
|
184
|
+
}
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Record every open (non-node-occupied) cell in `path` as claimed by
|
|
190
|
+
* `edge`, alongside any edge(s) that already claimed it — see
|
|
191
|
+
* `EdgeCellOwners`'s own doc for why a cell can have more than one owner.
|
|
192
|
+
*/
|
|
193
|
+
export function claimPathOwners(
|
|
194
|
+
grid: Grid,
|
|
195
|
+
owners: EdgeCellOwners,
|
|
196
|
+
path: readonly GridCoord[],
|
|
197
|
+
edge: AsciiEdge,
|
|
198
|
+
): void {
|
|
199
|
+
for (const cell of pathCells(path)) {
|
|
200
|
+
if (isOccupied(grid, cell)) continue
|
|
201
|
+
const key = gridKey(cell)
|
|
202
|
+
let cellOwners = owners.get(key)
|
|
203
|
+
if (cellOwners === undefined) {
|
|
204
|
+
cellOwners = new Set()
|
|
205
|
+
owners.set(key, cellOwners)
|
|
206
|
+
}
|
|
207
|
+
cellOwners.add(edge)
|
|
208
|
+
}
|
|
209
|
+
}
|