@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,1488 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — ER diagrams
|
|
3
|
+
//
|
|
4
|
+
// Renders erDiagram text to ASCII/Unicode art.
|
|
5
|
+
// Each entity is a 2-section box (header | attributes).
|
|
6
|
+
// Relationships are drawn as lines with crow's foot notation at endpoints.
|
|
7
|
+
//
|
|
8
|
+
// Layout: entities are placed in a grid pattern (multiple rows if needed).
|
|
9
|
+
// Relationship lines use Manhattan routing between entity boxes.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
import { parseErDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
13
|
+
import type {
|
|
14
|
+
ErDiagram,
|
|
15
|
+
ErEntity,
|
|
16
|
+
ErAttribute,
|
|
17
|
+
Cardinality,
|
|
18
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
19
|
+
import type {
|
|
20
|
+
AsciiConfig,
|
|
21
|
+
CharRole,
|
|
22
|
+
AsciiTheme,
|
|
23
|
+
ColorMode,
|
|
24
|
+
Canvas,
|
|
25
|
+
} from './types.ts'
|
|
26
|
+
import {
|
|
27
|
+
mkCanvas,
|
|
28
|
+
mkRoleCanvas,
|
|
29
|
+
canvasToString,
|
|
30
|
+
increaseSize,
|
|
31
|
+
increaseRoleCanvasSize,
|
|
32
|
+
write,
|
|
33
|
+
} from './canvas.ts'
|
|
34
|
+
import { drawMultiBox, measureMultiBox, classifyBoxChar } from './draw.ts'
|
|
35
|
+
import { splitLines, maxLineWidth } from './multiline-utils.ts'
|
|
36
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
37
|
+
import { toDisplayCells } from './display-width.ts'
|
|
38
|
+
import { findFreeLane } from './lane-search.ts'
|
|
39
|
+
import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Entity box content
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/** Format an attribute line: "PK type name" or "FK type name" etc. */
|
|
46
|
+
function formatAttribute(attr: ErAttribute): string {
|
|
47
|
+
const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : ' '
|
|
48
|
+
return `${keyStr}${attr.type} ${attr.name}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build sections for an entity box: [header], [attributes] */
|
|
52
|
+
function buildEntitySections(entity: ErEntity): string[][] {
|
|
53
|
+
// Support multi-line entity names
|
|
54
|
+
const header = splitLines(entity.label)
|
|
55
|
+
const attrs = entity.attributes.map(formatAttribute)
|
|
56
|
+
if (attrs.length === 0) return [header]
|
|
57
|
+
return [header, attrs]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Crow's foot notation
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns the ASCII/Unicode characters for a crow's foot cardinality marker.
|
|
66
|
+
* Markers are drawn adjacent to entity boxes at relationship endpoints.
|
|
67
|
+
*
|
|
68
|
+
* Standard ER notation:
|
|
69
|
+
* one: ─┤├─ perpendicular line (exactly one)
|
|
70
|
+
* zero-one: ─○┤─ circle + perpendicular (zero or one)
|
|
71
|
+
* many: ─<>─ crow's foot (one or more)
|
|
72
|
+
* zero-many: ─○<─ circle + crow's foot (zero or more)
|
|
73
|
+
*
|
|
74
|
+
* @param card - The cardinality type
|
|
75
|
+
* @param useAscii - Use ASCII-only characters
|
|
76
|
+
* @param isRight - True if this marker is on the right side of the relationship
|
|
77
|
+
*/
|
|
78
|
+
function getCrowsFootChars(
|
|
79
|
+
card: Cardinality,
|
|
80
|
+
useAscii: boolean,
|
|
81
|
+
isRight = false,
|
|
82
|
+
vertical = false,
|
|
83
|
+
): string {
|
|
84
|
+
if (useAscii) {
|
|
85
|
+
switch (card) {
|
|
86
|
+
case 'one':
|
|
87
|
+
// A bare '-' has no vertical extent, so it reads as a gap in the
|
|
88
|
+
// vertical line rather than a tick crossing it (issue: PR #442's
|
|
89
|
+
// fix made the marker visible but visually severed the line). '+'
|
|
90
|
+
// carries both strokes, matching how '|' crosses the horizontal
|
|
91
|
+
// line in the non-vertical case below.
|
|
92
|
+
return vertical ? '+' : '|'
|
|
93
|
+
case 'zero-one':
|
|
94
|
+
return isRight ? 'o|' : '|o'
|
|
95
|
+
case 'many':
|
|
96
|
+
return isRight ? '<' : '>'
|
|
97
|
+
case 'zero-many':
|
|
98
|
+
return isRight ? 'o<' : '>o'
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Use cleaner Unicode characters
|
|
102
|
+
switch (card) {
|
|
103
|
+
case 'one':
|
|
104
|
+
// Same reasoning as the ASCII '+' above: '┼' keeps the vertical
|
|
105
|
+
// stroke (line stays visually continuous) while adding the
|
|
106
|
+
// horizontal stroke that marks the "one" cardinality.
|
|
107
|
+
return vertical ? '┼' : '│'
|
|
108
|
+
case 'zero-one':
|
|
109
|
+
return isRight ? '○│' : '│○'
|
|
110
|
+
case 'many':
|
|
111
|
+
return isRight ? '╟' : '╢'
|
|
112
|
+
case 'zero-many':
|
|
113
|
+
return isRight ? '○╟' : '╢○'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Positioned entity
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
interface PlacedEntity {
|
|
123
|
+
entity: ErEntity
|
|
124
|
+
sections: string[][]
|
|
125
|
+
x: number
|
|
126
|
+
y: number
|
|
127
|
+
width: number
|
|
128
|
+
height: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Connected Component Detection
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find connected components in the ER diagram using DFS.
|
|
137
|
+
* Treats relationships as undirected edges for connectivity.
|
|
138
|
+
*
|
|
139
|
+
* Returns an array of entity ID sets, one per connected component.
|
|
140
|
+
*/
|
|
141
|
+
function findConnectedComponents(diagram: ErDiagram): Set<string>[] {
|
|
142
|
+
const visited = new Set<string>()
|
|
143
|
+
const components: Set<string>[] = []
|
|
144
|
+
|
|
145
|
+
// Build undirected adjacency list from relationships
|
|
146
|
+
const neighbors = new Map<string, Set<string>>()
|
|
147
|
+
for (const ent of diagram.entities) {
|
|
148
|
+
neighbors.set(ent.id, new Set())
|
|
149
|
+
}
|
|
150
|
+
for (const rel of diagram.relationships) {
|
|
151
|
+
neighbors.get(rel.entity1)?.add(rel.entity2)
|
|
152
|
+
neighbors.get(rel.entity2)?.add(rel.entity1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// DFS to find each component
|
|
156
|
+
function dfs(startId: string, component: Set<string>): void {
|
|
157
|
+
const stack = [startId]
|
|
158
|
+
while (stack.length > 0) {
|
|
159
|
+
const nodeId = stack.pop()!
|
|
160
|
+
if (visited.has(nodeId)) continue
|
|
161
|
+
|
|
162
|
+
visited.add(nodeId)
|
|
163
|
+
component.add(nodeId)
|
|
164
|
+
|
|
165
|
+
for (const neighbor of neighbors.get(nodeId) ?? []) {
|
|
166
|
+
if (!visited.has(neighbor)) {
|
|
167
|
+
stack.push(neighbor)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Find all components
|
|
174
|
+
for (const ent of diagram.entities) {
|
|
175
|
+
if (!visited.has(ent.id)) {
|
|
176
|
+
const component = new Set<string>()
|
|
177
|
+
dfs(ent.id, component)
|
|
178
|
+
if (component.size > 0) {
|
|
179
|
+
components.push(component)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return components
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Layout and rendering
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Look up a per-entity value computed by renderErAscii's box-sizing pass.
|
|
193
|
+
* Every entity in `diagram.entities` gets an entry in `entityBoxW`,
|
|
194
|
+
* `entityBoxH`, and `entitySections` before layout runs, so this always
|
|
195
|
+
* succeeds for a real diagram entity — but that guarantee is established by
|
|
196
|
+
* a separate imperative loop the compiler can't connect back to this
|
|
197
|
+
* lookup, so it's checked explicitly rather than trusted via `!`.
|
|
198
|
+
*/
|
|
199
|
+
function mustGetEntityValue<T>(
|
|
200
|
+
map: Map<string, T>,
|
|
201
|
+
entityId: string,
|
|
202
|
+
what: string,
|
|
203
|
+
): T {
|
|
204
|
+
const value = map.get(entityId)
|
|
205
|
+
if (value === undefined) {
|
|
206
|
+
/* v8 ignore next */
|
|
207
|
+
throw new Error(
|
|
208
|
+
`ER diagram layout: missing ${what} for entity "${entityId}"`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check whether every cell in row `y` across [xStart, xEnd] is still blank
|
|
216
|
+
* on `canvas`, ignoring column `skipX` (the relationship's own vertical
|
|
217
|
+
* stem, which legitimately already occupies that column across the whole
|
|
218
|
+
* gap before a jog row is chosen — see `chooseFreeRow`).
|
|
219
|
+
*
|
|
220
|
+
* A column past the canvas's current width isn't drawn yet — `canvas[x]` is
|
|
221
|
+
* `undefined` there — but it isn't *occupied* either: relationship labels
|
|
222
|
+
* routinely land past the initial bounds and grow the canvas on write (see
|
|
223
|
+
* `increaseSize` at the label-drawing call sites below). Treating that as
|
|
224
|
+
* "not free" made `chooseFreeRow` reject perfectly good candidate rows near
|
|
225
|
+
* the canvas edge and fall back to the plain midpoint instead, undermining
|
|
226
|
+
* the whole point of the search.
|
|
227
|
+
*/
|
|
228
|
+
// Exported for direct unit testing (see check-diff-coverage.ts's own
|
|
229
|
+
// exports for the precedent this repo already uses) — renderErAscii's fixed
|
|
230
|
+
// vGap never produces a gap wide enough to exercise every branch of
|
|
231
|
+
// chooseFreeRow's search order through the full render pipeline alone, so
|
|
232
|
+
// the row-selection logic itself is tested directly against constructed
|
|
233
|
+
// inputs instead.
|
|
234
|
+
export function isRowFree(
|
|
235
|
+
canvas: Canvas,
|
|
236
|
+
y: number,
|
|
237
|
+
xStart: number,
|
|
238
|
+
xEnd: number,
|
|
239
|
+
skipX: number,
|
|
240
|
+
): boolean {
|
|
241
|
+
for (let x = xStart; x <= xEnd; x++) {
|
|
242
|
+
if (x === skipX) continue
|
|
243
|
+
const col = canvas[x]
|
|
244
|
+
if (col === undefined) continue // not drawn yet — not occupied
|
|
245
|
+
if (col[y] !== ' ' && col[y] !== undefined) return false
|
|
246
|
+
}
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Choose a row for a vertical relationship's horizontal jog segment,
|
|
252
|
+
* preferring the geometric midpoint between `startY` and `endY` but
|
|
253
|
+
* scanning outward (alternating below/above) for the nearest row across
|
|
254
|
+
* [xStart, xEnd] that isn't already occupied — by an entity box the naive
|
|
255
|
+
* midpoint would otherwise cut through, or by another relationship's
|
|
256
|
+
* already-drawn jog (see issue #351: every vertical relationship between
|
|
257
|
+
* the same two component rows previously computed the *same* midpoint,
|
|
258
|
+
* so their jogs and labels landed on one shared row and overwrote each
|
|
259
|
+
* other). Candidates are restricted to the open interval (startY, endY) so
|
|
260
|
+
* the chosen row never collides with the crow's-foot markers flush against
|
|
261
|
+
* either entity border.
|
|
262
|
+
*
|
|
263
|
+
* Falls back to the plain midpoint — the only row every caller used before
|
|
264
|
+
* this fix — when the gap is too small to have any candidate row at all, or
|
|
265
|
+
* when every candidate in the gap is occupied (a dense diagram where
|
|
266
|
+
* avoiding collisions entirely isn't possible with this renderer's
|
|
267
|
+
* straight-jog routing).
|
|
268
|
+
*
|
|
269
|
+
* Exported for direct unit testing — see the comment on `isRowFree` above.
|
|
270
|
+
*/
|
|
271
|
+
export function chooseFreeRow(
|
|
272
|
+
canvas: Canvas,
|
|
273
|
+
startY: number,
|
|
274
|
+
endY: number,
|
|
275
|
+
xStart: number,
|
|
276
|
+
xEnd: number,
|
|
277
|
+
skipX: number,
|
|
278
|
+
): number {
|
|
279
|
+
const preferred = Math.floor((startY + endY) / 2)
|
|
280
|
+
// Candidates are the open interval (startY, endY) — `findFreeLane`'s
|
|
281
|
+
// bounds are inclusive, so they're the endpoints stepped one row inward.
|
|
282
|
+
// `preferred` itself is exempt from those bounds by design (see
|
|
283
|
+
// `findFreeLane`), which is what keeps the plain-midpoint fallback below
|
|
284
|
+
// reachable for a gap too narrow to hold any candidate at all.
|
|
285
|
+
return (
|
|
286
|
+
findFreeLane(preferred, startY + 1, endY - 1, (row) =>
|
|
287
|
+
isRowFree(canvas, row, xStart, xEnd, skipX),
|
|
288
|
+
) ?? preferred
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Render a Mermaid ER diagram to ASCII/Unicode text.
|
|
294
|
+
*
|
|
295
|
+
* Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string.
|
|
296
|
+
*/
|
|
297
|
+
export function renderErAscii(
|
|
298
|
+
text: string,
|
|
299
|
+
config: AsciiConfig,
|
|
300
|
+
colorMode?: ColorMode,
|
|
301
|
+
theme?: AsciiTheme,
|
|
302
|
+
): string {
|
|
303
|
+
const lines = splitStatements(text)
|
|
304
|
+
const diagram = parseErDiagram(lines)
|
|
305
|
+
|
|
306
|
+
if (diagram.entities.length === 0) return ''
|
|
307
|
+
|
|
308
|
+
const useAscii = config.useAscii
|
|
309
|
+
// See paddingOffset's doc comment (types.ts) for why these are an offset
|
|
310
|
+
// from the padding defaults rather than the raw config values.
|
|
311
|
+
//
|
|
312
|
+
// hGap/vGap floors are higher than the generic "don't collapse to
|
|
313
|
+
// nothing" minimum of 1: crow's-foot markers are up to 2 cells wide, so a
|
|
314
|
+
// same-row relationship's two markers need room not to collide (see the
|
|
315
|
+
// horizontal-marker section below). A vertical relationship needs at
|
|
316
|
+
// least 2 rows so the upper and lower markers don't land on the same
|
|
317
|
+
// row. (See issue #343's CodeRabbit review.)
|
|
318
|
+
const hGap = paddingOffset(config.paddingX, DEFAULT_PADDING_X, 6, 6) // horizontal gap between entity boxes
|
|
319
|
+
const vGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 4, 2) // vertical gap between rows (for relationship lines)
|
|
320
|
+
// Vertical gap between disconnected components. Unlike vGap, this gap
|
|
321
|
+
// never needs to fit a relationship line, a jog, or a label — disconnected
|
|
322
|
+
// components have no edges between them by definition — so it only needs
|
|
323
|
+
// enough room to read as a visual break between unrelated entities, not
|
|
324
|
+
// the same routing headroom a same-component row wrap needs. A gap this
|
|
325
|
+
// large previously left most of the output blank for diagrams with a few
|
|
326
|
+
// small unrelated components (issue #351) — base lowered from 6 to 2
|
|
327
|
+
// accordingly; still offset from config.paddingY like the others.
|
|
328
|
+
const componentGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 2, 1)
|
|
329
|
+
|
|
330
|
+
// Widest crow's-foot marker glyph across every cardinality, in the
|
|
331
|
+
// current mode (ASCII/Unicode) — the same "up to 2 cells wide" fact the
|
|
332
|
+
// hGap/vGap comment above states, but computed here instead of restated
|
|
333
|
+
// as a raw literal, so anything sized off it (labelInset, labelX below)
|
|
334
|
+
// automatically tracks a future change to the marker glyph set (issue
|
|
335
|
+
// #415, following #384's precedent for hGap/vGap/componentGap).
|
|
336
|
+
const maxMarkerWidth = Math.max(
|
|
337
|
+
...(['one', 'zero-one', 'many', 'zero-many'] as const).flatMap((card) => [
|
|
338
|
+
getCrowsFootChars(card, useAscii, false).length,
|
|
339
|
+
getCrowsFootChars(card, useAscii, true).length,
|
|
340
|
+
]),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
// --- Build entity box dimensions ---
|
|
344
|
+
const entitySections = new Map<string, string[][]>()
|
|
345
|
+
const entityBoxW = new Map<string, number>()
|
|
346
|
+
const entityBoxH = new Map<string, number>()
|
|
347
|
+
const entityById = new Map<string, ErEntity>()
|
|
348
|
+
|
|
349
|
+
for (const ent of diagram.entities) {
|
|
350
|
+
entityById.set(ent.id, ent)
|
|
351
|
+
const sections = buildEntitySections(ent)
|
|
352
|
+
entitySections.set(ent.id, sections)
|
|
353
|
+
|
|
354
|
+
// Reserve exactly what drawMultiBox will draw — measuring it here rather
|
|
355
|
+
// than re-deriving the arithmetic keeps layout and drawing in lockstep for
|
|
356
|
+
// wide-character (CJK/fullwidth) content.
|
|
357
|
+
const { width: boxW, height: boxH } = measureMultiBox(
|
|
358
|
+
sections,
|
|
359
|
+
config.boxBorderPadding,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
entityBoxW.set(ent.id, boxW)
|
|
363
|
+
entityBoxH.set(ent.id, boxH)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Widest relationship label between each unordered pair of entities.
|
|
367
|
+
// Used to widen the horizontal gap between entities so labels aren't
|
|
368
|
+
// truncated and keep at least 1 char of padding from both entity boxes
|
|
369
|
+
// (see issue #67 — labels like "ordered in" were clamped to the fixed
|
|
370
|
+
// 6-char gap and truncated to "ordere").
|
|
371
|
+
const pairLabelWidth = new Map<string, number>()
|
|
372
|
+
for (const rel of diagram.relationships) {
|
|
373
|
+
if (!rel.label) continue
|
|
374
|
+
const key = [rel.entity1, rel.entity2].sort().join('|')
|
|
375
|
+
const w = maxLineWidth(rel.label)
|
|
376
|
+
pairLabelWidth.set(key, Math.max(pairLabelWidth.get(key) ?? 0, w))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- Find connected components ---
|
|
380
|
+
const components = findConnectedComponents(diagram)
|
|
381
|
+
|
|
382
|
+
// --- Layout: place each component, then stack components vertically ---
|
|
383
|
+
const placed = new Map<string, PlacedEntity>()
|
|
384
|
+
let currentY = 0
|
|
385
|
+
|
|
386
|
+
for (const component of components) {
|
|
387
|
+
// Get entities in this component (preserve original order for consistency)
|
|
388
|
+
const componentEntities = diagram.entities.filter((e) =>
|
|
389
|
+
component.has(e.id),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// Layout entities within this component horizontally
|
|
393
|
+
// Use sqrt-based row limit for larger components
|
|
394
|
+
const maxPerRow = Math.max(
|
|
395
|
+
2,
|
|
396
|
+
Math.ceil(Math.sqrt(componentEntities.length)),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
let currentX = 0
|
|
400
|
+
let maxRowH = 0
|
|
401
|
+
let colCount = 0
|
|
402
|
+
|
|
403
|
+
for (let idx = 0; idx < componentEntities.length; idx++) {
|
|
404
|
+
const ent = componentEntities[idx]!
|
|
405
|
+
const w = mustGetEntityValue(entityBoxW, ent.id, 'box width')
|
|
406
|
+
const h = mustGetEntityValue(entityBoxH, ent.id, 'box height')
|
|
407
|
+
|
|
408
|
+
if (colCount >= maxPerRow) {
|
|
409
|
+
// Wrap to next row within this component
|
|
410
|
+
currentY += maxRowH + vGap
|
|
411
|
+
currentX = 0
|
|
412
|
+
maxRowH = 0
|
|
413
|
+
colCount = 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
placed.set(ent.id, {
|
|
417
|
+
entity: ent,
|
|
418
|
+
sections: mustGetEntityValue(entitySections, ent.id, 'sections'),
|
|
419
|
+
x: currentX,
|
|
420
|
+
y: currentY,
|
|
421
|
+
width: w,
|
|
422
|
+
height: h,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// Widen the gap to the next entity in this row so a connecting
|
|
426
|
+
// relationship's label fits with 1 char of padding on each side
|
|
427
|
+
// instead of being truncated or crammed against a box border.
|
|
428
|
+
let gap = hGap
|
|
429
|
+
const willWrapNext = colCount + 1 >= maxPerRow
|
|
430
|
+
const nextEnt = componentEntities[idx + 1]
|
|
431
|
+
if (!willWrapNext && nextEnt) {
|
|
432
|
+
const key = [ent.id, nextEnt.id].sort().join('|')
|
|
433
|
+
const labelW = pairLabelWidth.get(key) ?? 0
|
|
434
|
+
if (labelW > 0) gap = Math.max(gap, labelW + 2)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
currentX += w + gap
|
|
438
|
+
maxRowH = Math.max(maxRowH, h)
|
|
439
|
+
colCount++
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Move to next component row (add gap between components)
|
|
443
|
+
currentY += maxRowH + componentGap
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- Create canvas ---
|
|
447
|
+
let totalW = 0
|
|
448
|
+
let totalH = 0
|
|
449
|
+
for (const p of placed.values()) {
|
|
450
|
+
totalW = Math.max(totalW, p.x + p.width)
|
|
451
|
+
totalH = Math.max(totalH, p.y + p.height)
|
|
452
|
+
}
|
|
453
|
+
totalW += 4
|
|
454
|
+
totalH += 2
|
|
455
|
+
|
|
456
|
+
const canvas = mkCanvas(totalW - 1, totalH - 1)
|
|
457
|
+
const rc = mkRoleCanvas(totalW - 1, totalH - 1)
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Set a character on the canvas and track its role.
|
|
461
|
+
* Delegates bounds-checking to the shared `write()` primitive
|
|
462
|
+
* (src/ascii/canvas.ts) instead of duplicating the guard here — see
|
|
463
|
+
* issue #171.
|
|
464
|
+
*/
|
|
465
|
+
function setC(x: number, y: number, ch: string, role: CharRole): void {
|
|
466
|
+
write(canvas, x, y, ch, { role, roleCanvas: rc })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* True for a cell a relationship draw must not silently overwrite: an
|
|
471
|
+
* entity's own box border, or 'text' (a label or an entity's own
|
|
472
|
+
* header/attribute text). Relationships are drawn in declaration order, so
|
|
473
|
+
* a later relationship's line, crow's-foot marker, or label can otherwise
|
|
474
|
+
* land on the exact cell an earlier one (or a plain entity box) already
|
|
475
|
+
* wrote, corrupting it — a stray line glyph mid-word, one label's
|
|
476
|
+
* characters spliced into another's, or (for a same-row relationship whose
|
|
477
|
+
* straight line runs across an unrelated entity sitting between its two
|
|
478
|
+
* endpoints) that entity's border erased outright (issue #392). This
|
|
479
|
+
* doesn't fix the underlying routing gap — the line still crosses straight
|
|
480
|
+
* through the box, an out-of-scope defect tracked in #351/#390's "known
|
|
481
|
+
* limitation" — it only stops that crossing from destroying content.
|
|
482
|
+
*/
|
|
483
|
+
function isProtected(x: number, y: number): boolean {
|
|
484
|
+
const role = rc[x]?.[y]
|
|
485
|
+
return role === 'text' || role === 'border'
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* True when every cell a label's line would occupy (after clamping to
|
|
490
|
+
* [minX, maxX]) is free of protected content (see isProtected). Checked as
|
|
491
|
+
* a whole line rather than character-by-character: a per-character skip on
|
|
492
|
+
* a *label* write (unlike a line or marker) would let two overlapping
|
|
493
|
+
* labels' letters splice together into a new word that isn't either
|
|
494
|
+
* original label — e.g. "has" + the tail of "tagged-with" reading as
|
|
495
|
+
* "hasged-with" — which is more misleading than either label winning
|
|
496
|
+
* outright or neither appearing. A label either renders intact or is
|
|
497
|
+
* skipped entirely.
|
|
498
|
+
*/
|
|
499
|
+
function canPlaceLabelLine(
|
|
500
|
+
cells: string[],
|
|
501
|
+
startX: number,
|
|
502
|
+
y: number,
|
|
503
|
+
minX: number,
|
|
504
|
+
maxX: number,
|
|
505
|
+
): boolean {
|
|
506
|
+
for (let i = 0; i < cells.length; i++) {
|
|
507
|
+
const x = startX + i
|
|
508
|
+
if (x < minX || x > maxX) continue
|
|
509
|
+
if (isProtected(x, y)) return false
|
|
510
|
+
}
|
|
511
|
+
return true
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Like canPlaceLabelLine, but also refuses a cell already holding a
|
|
516
|
+
* crow's-foot marker ('arrow'), or reserved by an entity box (see
|
|
517
|
+
* boxCells, defined below — referenced here by closure, since this
|
|
518
|
+
* function isn't actually called until after boxCells exists). Used only
|
|
519
|
+
* when searching for an alternate row for a vertical relationship's
|
|
520
|
+
* label (see below): a label's own natural row is allowed to sit on top
|
|
521
|
+
* of that same relationship's freshly-drawn line (expected — the label
|
|
522
|
+
* always wins over the line beneath it), but a *different* row picked
|
|
523
|
+
* specifically to dodge a collision shouldn't destroy a marker it
|
|
524
|
+
* happens to land on instead. The boxCells check matters most for a
|
|
525
|
+
* bypass-routed relationship (#350): a box's own blank interior padding
|
|
526
|
+
* never gets a role written to it, so isProtected alone would treat it
|
|
527
|
+
* as free even though it's inside another entity's box.
|
|
528
|
+
*/
|
|
529
|
+
function canPlaceLabelLineAvoidingMarkers(
|
|
530
|
+
cells: string[],
|
|
531
|
+
startX: number,
|
|
532
|
+
y: number,
|
|
533
|
+
minX: number,
|
|
534
|
+
maxX: number,
|
|
535
|
+
): boolean {
|
|
536
|
+
for (let i = 0; i < cells.length; i++) {
|
|
537
|
+
const x = startX + i
|
|
538
|
+
if (x < minX || x > maxX) continue
|
|
539
|
+
if (isProtected(x, y) || rc[x]?.[y] === 'arrow') return false
|
|
540
|
+
if (boxCells.has(`${x},${y}`)) return false
|
|
541
|
+
}
|
|
542
|
+
return true
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// --- Draw entity boxes ---
|
|
546
|
+
for (const p of placed.values()) {
|
|
547
|
+
const boxCanvas = drawMultiBox(
|
|
548
|
+
p.sections,
|
|
549
|
+
useAscii,
|
|
550
|
+
config.boxBorderPadding,
|
|
551
|
+
)
|
|
552
|
+
for (let bx = 0; bx < boxCanvas.length; bx++) {
|
|
553
|
+
for (let by = 0; by < boxCanvas[0]!.length; by++) {
|
|
554
|
+
const ch = boxCanvas[bx]![by]!
|
|
555
|
+
if (ch !== ' ') {
|
|
556
|
+
const cx = p.x + bx
|
|
557
|
+
const cy = p.y + by
|
|
558
|
+
if (cx < totalW && cy < totalH) {
|
|
559
|
+
setC(cx, cy, ch, classifyBoxChar(ch))
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// --- Snapshot cells occupied by entity boxes ---
|
|
567
|
+
// Taken once, right after boxes are drawn and before any relationship
|
|
568
|
+
// line, crow's-foot marker, or label is drawn, so relationship rendering
|
|
569
|
+
// can never silently overwrite a box border or attribute text (issue
|
|
570
|
+
// #350). The obstruction-aware routing below (see `obstructionBottom` and
|
|
571
|
+
// the vertical row-band clamp) avoids these cells in the common cases;
|
|
572
|
+
// this snapshot is the last-resort guarantee for whatever routing doesn't
|
|
573
|
+
// anticipate.
|
|
574
|
+
const boxCells = new Set<string>()
|
|
575
|
+
for (const p of placed.values()) {
|
|
576
|
+
for (let by = 0; by < p.height; by++) {
|
|
577
|
+
for (let bx = 0; bx < p.width; bx++) {
|
|
578
|
+
boxCells.add(`${p.x + bx},${p.y + by}`)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Like setC, but refuses to draw into a cell reserved by an entity box
|
|
585
|
+
* (see boxCells above). Used for every relationship line, crow's-foot
|
|
586
|
+
* marker, and label write below, so a mis-routed segment degrades to a
|
|
587
|
+
* gap in the line rather than corrupting a box's border or attribute
|
|
588
|
+
* text.
|
|
589
|
+
*/
|
|
590
|
+
function setCGuarded(x: number, y: number, ch: string, role: CharRole): void {
|
|
591
|
+
if (boxCells.has(`${x},${y}`)) return
|
|
592
|
+
// Two relationship lines are allowed to cross (a normal, expected part
|
|
593
|
+
// of ER layout), but a later relationship's line/marker must not punch
|
|
594
|
+
// through an earlier relationship's already-placed label text — that's
|
|
595
|
+
// the same silent-corruption shape as issue #350, just between two
|
|
596
|
+
// relationships instead of a relationship and a box.
|
|
597
|
+
if (role !== 'text' && rc[x]?.[y] === 'text') return
|
|
598
|
+
setC(x, y, ch, role)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Like setCGuarded, but for a horizontal line/dash fill: also backs off
|
|
603
|
+
* one cell when either neighbor already holds 'text' (carried over from
|
|
604
|
+
* the #392 fix's setRelHChar). isProtected/boxCells alone guard a
|
|
605
|
+
* label's own cells, but a *different*, later-processed relationship's
|
|
606
|
+
* horizontal jog — including the obstruction-detour and band-clamped
|
|
607
|
+
* routing added for #350 — can still run its dashes right up against an
|
|
608
|
+
* earlier one's label with zero visual gap (e.g. "────authors────").
|
|
609
|
+
* Only used for horizontal fills (same-row connection line, detour
|
|
610
|
+
* bottom, vertical connection's jog): a vertical '│' passing a label's
|
|
611
|
+
* row doesn't read as visually cramped the same way, so it isn't padded.
|
|
612
|
+
*/
|
|
613
|
+
function setCGuardedH(
|
|
614
|
+
x: number,
|
|
615
|
+
y: number,
|
|
616
|
+
ch: string,
|
|
617
|
+
role: CharRole,
|
|
618
|
+
): void {
|
|
619
|
+
if (rc[x - 1]?.[y] === 'text' || rc[x + 1]?.[y] === 'text') return
|
|
620
|
+
setCGuarded(x, y, ch, role)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* True when every cell in the rectangle [xStart, xEnd] x [yStart, yEnd] is
|
|
625
|
+
* still blank — i.e. neither an entity box (see boxCells) nor a
|
|
626
|
+
* previously-drawn relationship line/marker/label occupies it. Used to
|
|
627
|
+
* pick a detour row for one relationship that doesn't land on top of
|
|
628
|
+
* another relationship's already-drawn line or label (both routed through
|
|
629
|
+
* the same row-gap band can otherwise silently overwrite each other).
|
|
630
|
+
* Reads directly from `canvas` rather than boxCells, since it must also
|
|
631
|
+
* see ink from relationships drawn earlier in this same loop.
|
|
632
|
+
*/
|
|
633
|
+
function regionClear(
|
|
634
|
+
xStart: number,
|
|
635
|
+
xEnd: number,
|
|
636
|
+
yStart: number,
|
|
637
|
+
yEnd: number,
|
|
638
|
+
): boolean {
|
|
639
|
+
for (let y = yStart; y <= yEnd; y++) {
|
|
640
|
+
for (let x = xStart; x <= xEnd; x++) {
|
|
641
|
+
const ch = canvas[x]?.[y]
|
|
642
|
+
if (ch !== undefined && ch !== ' ') return false
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return true
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Vertical relationship "lanes" already drawn — one entry per straight
|
|
650
|
+
* vertical run, added as each relationship's connector is finalized.
|
|
651
|
+
* Checked before drawing each subsequent vertical connector so that two
|
|
652
|
+
* *unrelated* relationships' stems never land on immediately-adjacent
|
|
653
|
+
* (but not identical) columns purely by coincidence: with nothing in the
|
|
654
|
+
* rendered output to distinguish them, that reads as a single connector
|
|
655
|
+
* that inexplicably shifts sideways partway down, rather than as two
|
|
656
|
+
* independent lines (issue #411). A relationship's own two segments
|
|
657
|
+
* (either side of its own jog) are never checked against this registry
|
|
658
|
+
* as they're computed — only against lanes registered by *earlier*
|
|
659
|
+
* relationships in this same loop — since a single line legitimately
|
|
660
|
+
* bending at its own jog is not the ambiguity this guards against.
|
|
661
|
+
*/
|
|
662
|
+
const verticalLanes: { x: number; yStart: number; yEnd: number }[] = []
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* True when [yStart, yEnd] at column x sits one column from an existing
|
|
666
|
+
* lane whose row range overlaps it. Every call site passes a range
|
|
667
|
+
* derived from startY/endY (upper's bottom to lower's top, or a row
|
|
668
|
+
* within that span), which the layout always establishes as
|
|
669
|
+
* startY <= endY — a degenerate/inverted range never reaches here.
|
|
670
|
+
*/
|
|
671
|
+
function verticalLaneConflict(
|
|
672
|
+
x: number,
|
|
673
|
+
yStart: number,
|
|
674
|
+
yEnd: number,
|
|
675
|
+
): boolean {
|
|
676
|
+
for (const lane of verticalLanes) {
|
|
677
|
+
if (Math.abs(lane.x - x) !== 1) continue
|
|
678
|
+
if (yStart <= lane.yEnd && yEnd >= lane.yStart) return true
|
|
679
|
+
}
|
|
680
|
+
return false
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Like verticalLaneConflict, but also rejects the *same* column as an
|
|
685
|
+
* existing lane (diff 0), not just an adjacent one (diff 1). Used only
|
|
686
|
+
* when searching for a via/bypass column (see the multiRowObstruction
|
|
687
|
+
* branch below): landing the bypass on another relationship's own
|
|
688
|
+
* column wouldn't just be visually adjacent, it would run straight
|
|
689
|
+
* through — and setCGuarded doesn't protect a 'line' cell from being
|
|
690
|
+
* overwritten by another 'line' write, so the two relationships' glyphs
|
|
691
|
+
* (including corner turns) would silently blend into one path, which is
|
|
692
|
+
* worse than the adjacent-column aliasing this fix targets in the first
|
|
693
|
+
* place.
|
|
694
|
+
*/
|
|
695
|
+
function viaColumnBlocked(x: number, yStart: number, yEnd: number): boolean {
|
|
696
|
+
for (const lane of verticalLanes) {
|
|
697
|
+
if (Math.abs(lane.x - x) > 1) continue
|
|
698
|
+
if (yStart <= lane.yEnd && yEnd >= lane.yStart) return true
|
|
699
|
+
}
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function registerVerticalLane(x: number, yStart: number, yEnd: number): void {
|
|
704
|
+
verticalLanes.push({ x, yStart, yEnd })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// --- Draw relationships ---
|
|
708
|
+
const H = useAscii ? '-' : '─'
|
|
709
|
+
const V = useAscii ? '|' : '│'
|
|
710
|
+
const dashH = useAscii ? '.' : '╌'
|
|
711
|
+
const dashV = useAscii ? ':' : '┊'
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Character for the single cell where a routed relationship's path turns
|
|
715
|
+
* — a vertical segment and a horizontal segment meeting at a right angle
|
|
716
|
+
* (issue #414). `vertDir` is the direction the vertical segment extends
|
|
717
|
+
* away from this corner cell ('up' toward smaller y, 'down' toward
|
|
718
|
+
* larger y); `horizDir` is the direction the horizontal segment extends
|
|
719
|
+
* away from this same cell. Collapses to '+' in ASCII mode, matching
|
|
720
|
+
* getCrowsFootChars' own useAscii branching.
|
|
721
|
+
*/
|
|
722
|
+
function getCornerChar(
|
|
723
|
+
vertDir: 'up' | 'down',
|
|
724
|
+
horizDir: 'left' | 'right',
|
|
725
|
+
): string {
|
|
726
|
+
if (useAscii) return '+'
|
|
727
|
+
if (vertDir === 'down') return horizDir === 'right' ? '┌' : '┐'
|
|
728
|
+
return horizDir === 'right' ? '└' : '┘'
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Draw a corner glyph at a routed relationship's turn point, through the
|
|
733
|
+
* same setCGuarded occupancy guard as every other relationship write (see
|
|
734
|
+
* setCGuarded's doc comment above), so a corner can never overwrite an
|
|
735
|
+
* entity box border or another relationship's already-placed label text
|
|
736
|
+
* — the same corruption shape #391 fixed for plain line/marker writes.
|
|
737
|
+
* Called after the plain line segments (and, in the multi-row-bypass
|
|
738
|
+
* case, before the crow's-foot markers) are drawn, so the corner glyph
|
|
739
|
+
* replaces whichever line/dash character would otherwise occupy that one
|
|
740
|
+
* cell where the path actually changes direction.
|
|
741
|
+
*/
|
|
742
|
+
function drawCorner(
|
|
743
|
+
x: number,
|
|
744
|
+
y: number,
|
|
745
|
+
vertDir: 'up' | 'down',
|
|
746
|
+
horizDir: 'left' | 'right',
|
|
747
|
+
): void {
|
|
748
|
+
setCGuarded(x, y, getCornerChar(vertDir, horizDir), 'line')
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Attachment columns for vertical (different-row) relationships, keyed by
|
|
753
|
+
* entity id, one bucket per edge the relationship attaches to. Without
|
|
754
|
+
* this, every vertical relationship's marker independently defaults to
|
|
755
|
+
* its entity's exact horizontal center — so when two or more
|
|
756
|
+
* relationships converge on the *same* entity's top edge (or leave from
|
|
757
|
+
* the same bottom edge) — e.g. USER→COMMENT and POST→COMMENT both ending
|
|
758
|
+
* at COMMENT — their markers land on the identical cell and read as one
|
|
759
|
+
* merged crow's-foot instead of two distinguishable relationships (issue
|
|
760
|
+
* #453). Real mermaid.js avoids this by staggering each edge's
|
|
761
|
+
* attachment point along the entity's border based on where the other
|
|
762
|
+
* endpoint sits; `attachmentX` below mirrors that by spreading multiple
|
|
763
|
+
* attachments left-to-right across the entity's own width, ordered by the
|
|
764
|
+
* other entity's center X (so the visual left-to-right order of the
|
|
765
|
+
* relationships matches the left-to-right order of their sources).
|
|
766
|
+
* Entities with only one attachment on a given side are unaffected — they
|
|
767
|
+
* still resolve to dead center, exactly as before this change.
|
|
768
|
+
*/
|
|
769
|
+
interface VerticalAttachment {
|
|
770
|
+
rel: (typeof diagram.relationships)[number]
|
|
771
|
+
otherCenterX: number
|
|
772
|
+
}
|
|
773
|
+
const topAttachments = new Map<string, VerticalAttachment[]>()
|
|
774
|
+
const bottomAttachments = new Map<string, VerticalAttachment[]>()
|
|
775
|
+
for (const rel of diagram.relationships) {
|
|
776
|
+
const e1 = placed.get(rel.entity1)
|
|
777
|
+
const e2 = placed.get(rel.entity2)
|
|
778
|
+
if (!e1 || !e2) continue
|
|
779
|
+
const e1CY = e1.y + Math.floor(e1.height / 2)
|
|
780
|
+
const e2CY = e2.y + Math.floor(e2.height / 2)
|
|
781
|
+
const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
|
|
782
|
+
if (sameRow) continue
|
|
783
|
+
const [upperE, lowerE] = e1CY < e2CY ? [e1, e2] : [e2, e1]
|
|
784
|
+
const upperCX = upperE.x + Math.floor(upperE.width / 2)
|
|
785
|
+
const lowerCX = lowerE.x + Math.floor(lowerE.width / 2)
|
|
786
|
+
let bottomList = bottomAttachments.get(upperE.entity.id)
|
|
787
|
+
if (!bottomList) {
|
|
788
|
+
bottomList = []
|
|
789
|
+
bottomAttachments.set(upperE.entity.id, bottomList)
|
|
790
|
+
}
|
|
791
|
+
bottomList.push({ rel, otherCenterX: lowerCX })
|
|
792
|
+
let topList = topAttachments.get(lowerE.entity.id)
|
|
793
|
+
if (!topList) {
|
|
794
|
+
topList = []
|
|
795
|
+
topAttachments.set(lowerE.entity.id, topList)
|
|
796
|
+
}
|
|
797
|
+
topList.push({ rel, otherCenterX: upperCX })
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Resolve the attachment column for `rel` on `entity`'s given side. Falls
|
|
802
|
+
* back to dead center when that side has zero or one attachment (the
|
|
803
|
+
* common case, and the pre-#453 behavior). With two or more, spreads them
|
|
804
|
+
* evenly across the entity's own width — inset by 1 cell from each edge
|
|
805
|
+
* so a 2-cell-wide crow's-foot marker (e.g. "zero-many") doesn't sit
|
|
806
|
+
* flush against a box corner — ordered by the other entity's center X.
|
|
807
|
+
*/
|
|
808
|
+
function attachmentX(
|
|
809
|
+
entity: PlacedEntity,
|
|
810
|
+
rel: (typeof diagram.relationships)[number],
|
|
811
|
+
attachments: Map<string, VerticalAttachment[]>,
|
|
812
|
+
): number {
|
|
813
|
+
const list = attachments.get(entity.entity.id)
|
|
814
|
+
const centerX = entity.x + Math.floor(entity.width / 2)
|
|
815
|
+
if (!list || list.length <= 1) return centerX
|
|
816
|
+
const sorted = [...list].sort((a, b) => a.otherCenterX - b.otherCenterX)
|
|
817
|
+
const idx = sorted.findIndex((a) => a.rel === rel)
|
|
818
|
+
if (idx === -1) return centerX
|
|
819
|
+
const margin = Math.min(1, Math.floor((entity.width - 1) / 2))
|
|
820
|
+
const usableWidth = Math.max(0, entity.width - 1 - margin * 2)
|
|
821
|
+
const step = sorted.length > 1 ? usableWidth / (sorted.length - 1) : 0
|
|
822
|
+
return entity.x + margin + Math.round(idx * step)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
for (const rel of diagram.relationships) {
|
|
826
|
+
const e1 = placed.get(rel.entity1)
|
|
827
|
+
const e2 = placed.get(rel.entity2)
|
|
828
|
+
if (!e1 || !e2) continue
|
|
829
|
+
|
|
830
|
+
const lineH = rel.identifying ? H : dashH
|
|
831
|
+
const lineV = rel.identifying ? V : dashV
|
|
832
|
+
|
|
833
|
+
// Determine connection direction based on relative position.
|
|
834
|
+
// Connect from right side of left entity to left side of right entity (horizontal),
|
|
835
|
+
// or from bottom of upper entity to top of lower entity (vertical).
|
|
836
|
+
const e1CX = e1.x + Math.floor(e1.width / 2)
|
|
837
|
+
const e1CY = e1.y + Math.floor(e1.height / 2)
|
|
838
|
+
const e2CX = e2.x + Math.floor(e2.width / 2)
|
|
839
|
+
const e2CY = e2.y + Math.floor(e2.height / 2)
|
|
840
|
+
|
|
841
|
+
// Check if entities are on the same row (horizontal connection)
|
|
842
|
+
const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)
|
|
843
|
+
|
|
844
|
+
if (sameRow) {
|
|
845
|
+
// Horizontal connection: right side of left entity → left side of right entity
|
|
846
|
+
const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1]
|
|
847
|
+
const [leftCard, rightCard] =
|
|
848
|
+
e1CX < e2CX
|
|
849
|
+
? [rel.cardinality1, rel.cardinality2]
|
|
850
|
+
: [rel.cardinality2, rel.cardinality1]
|
|
851
|
+
|
|
852
|
+
const startX = left.x + left.width
|
|
853
|
+
const endX = right.x - 1
|
|
854
|
+
const lineY = left.y + Math.floor(left.height / 2)
|
|
855
|
+
|
|
856
|
+
// A straight line at lineY only stays clear of other boxes when left
|
|
857
|
+
// and right are actually adjacent in the row. When some other entity
|
|
858
|
+
// in the same row sits between them (e.g. ORDER↔SHIPMENT with
|
|
859
|
+
// LINE_ITEM placed in between — issue #350), the direct path runs
|
|
860
|
+
// straight through that entity's box. Detect that case by checking
|
|
861
|
+
// for any other row-mate whose x-range overlaps the gap.
|
|
862
|
+
let obstructionBottom: number | undefined
|
|
863
|
+
for (const other of placed.values()) {
|
|
864
|
+
if (other === left || other === right) continue
|
|
865
|
+
if (other.y !== left.y) continue
|
|
866
|
+
const overlapsGap = other.x < endX + 1 && other.x + other.width > startX
|
|
867
|
+
if (overlapsGap) {
|
|
868
|
+
obstructionBottom = Math.max(
|
|
869
|
+
obstructionBottom ?? 0,
|
|
870
|
+
other.y + other.height,
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Horizontal crow's-foot markers: flush against the border by default
|
|
876
|
+
// (standard ER notation, and #390's original intent), except where a
|
|
877
|
+
// marker's own border-adjacent glyph is character-identical to the
|
|
878
|
+
// border glyph itself ('one'/'zero-one' use '│'/'|', the same as the
|
|
879
|
+
// vertical border) — there, flush reads as a doubled border rather
|
|
880
|
+
// than a marker touching one, so that side gets the same inset as
|
|
881
|
+
// the label (issue #67). 'many'/'zero-many' markers ('╢'/'╟',
|
|
882
|
+
// '○╢'/'○╟') never collide with the border glyph and stay flush,
|
|
883
|
+
// matching #390's original intent for them.
|
|
884
|
+
//
|
|
885
|
+
// This was briefly a blanket inset for every horizontal marker
|
|
886
|
+
// (matching the review comment at
|
|
887
|
+
// https://github.com/dfadler/zombie-mermaid/issues/351#issuecomment-5497209031,
|
|
888
|
+
// which found flush unreadable but tested against a raw text
|
|
889
|
+
// comparison, not a render) — rendered in a real terminal, adjacent
|
|
890
|
+
// monospace box-drawing glyphs each sit centered in their own cell
|
|
891
|
+
// and don't visually fuse regardless of spacing, so a blanket inset
|
|
892
|
+
// wasn't fixing a defect, it was just adding unrequested space to
|
|
893
|
+
// markers that already read fine flush (e.g. `CUSTOMER }o--o{ ORDER`
|
|
894
|
+
// — no collision on either side, no reason for either to move).
|
|
895
|
+
// Narrowed to per-side glyph-identity detection instead, so only the
|
|
896
|
+
// colliding side ever moves — see issue #413.
|
|
897
|
+
const gapWidth = endX - startX + 1
|
|
898
|
+
// Only worth insetting the label off the border when the gap could
|
|
899
|
+
// fit the widest possible marker plus at least one cell of actual
|
|
900
|
+
// label content (maxMarkerWidth + 1) — below that, insetting both
|
|
901
|
+
// sides would just shrink an already-tight label region for no
|
|
902
|
+
// readability benefit. Was a flat `>= 3` (issue #415); derived here
|
|
903
|
+
// so it stays correct if maxMarkerWidth ever changes. The false
|
|
904
|
+
// branch is unreachable today — hGap's own floor (paddingOffset's
|
|
905
|
+
// hard-coded 6, independent of config.paddingX) keeps gapWidth well
|
|
906
|
+
// above maxMarkerWidth + 1 for the current glyph set — but is kept
|
|
907
|
+
// (not simplified to a bare `1`) so a future wider marker glyph set
|
|
908
|
+
// is still handled correctly without revisiting this line.
|
|
909
|
+
/* v8 ignore next */
|
|
910
|
+
const labelInset = gapWidth >= maxMarkerWidth + 1 ? 1 : 0
|
|
911
|
+
const leftChars = getCrowsFootChars(leftCard, useAscii, false)
|
|
912
|
+
const rightChars = getCrowsFootChars(rightCard, useAscii, true)
|
|
913
|
+
const leftInset = gapWidth >= 3 && leftChars[0] === V ? 1 : 0
|
|
914
|
+
const rightInset =
|
|
915
|
+
gapWidth >= 3 && rightChars[rightChars.length - 1] === V ? 1 : 0
|
|
916
|
+
const markerStartX = startX + leftInset
|
|
917
|
+
const markerEndX = endX - rightInset
|
|
918
|
+
|
|
919
|
+
let labelBaseY: number
|
|
920
|
+
|
|
921
|
+
if (obstructionBottom === undefined) {
|
|
922
|
+
// Direct path: nothing sits between left and right in this row.
|
|
923
|
+
for (let x = startX; x <= endX; x++) {
|
|
924
|
+
setCGuardedH(x, lineY, lineH, 'line')
|
|
925
|
+
}
|
|
926
|
+
labelBaseY = lineY + 1
|
|
927
|
+
|
|
928
|
+
// Two relationships between the exact same adjacent pair (a
|
|
929
|
+
// parallel/multi edge) both compute this identical row — without
|
|
930
|
+
// this search, the second relationship's label would silently
|
|
931
|
+
// overwrite the first's, since setCGuarded only guards a
|
|
932
|
+
// non-text write against existing text, not text-over-text.
|
|
933
|
+
// Search downward, within the row-gap band, for a row where the
|
|
934
|
+
// label is still on blank canvas.
|
|
935
|
+
if (rel.label) {
|
|
936
|
+
const labelMinX = startX + labelInset
|
|
937
|
+
const labelMaxX = endX - labelInset
|
|
938
|
+
const labelRows = splitLines(rel.label).length
|
|
939
|
+
const maxLabelY = lineY + 1 + Math.max(vGap - 1, 1)
|
|
940
|
+
while (
|
|
941
|
+
labelBaseY < maxLabelY &&
|
|
942
|
+
!regionClear(
|
|
943
|
+
labelMinX,
|
|
944
|
+
labelMaxX,
|
|
945
|
+
labelBaseY,
|
|
946
|
+
labelBaseY + labelRows - 1,
|
|
947
|
+
)
|
|
948
|
+
) {
|
|
949
|
+
labelBaseY++
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
// Detour beneath the obstructing entity, through the free row-gap
|
|
954
|
+
// band (vGap) that layout already reserves below every row, then
|
|
955
|
+
// back up to the right entity's edge. Nothing else is ever placed
|
|
956
|
+
// in that band, so it's guaranteed clear regardless of how much
|
|
957
|
+
// taller the obstruction is than left/right themselves.
|
|
958
|
+
const rowBottom = Math.max(
|
|
959
|
+
left.y + left.height,
|
|
960
|
+
right.y + right.height,
|
|
961
|
+
obstructionBottom,
|
|
962
|
+
)
|
|
963
|
+
// Reserve room for the label too (it goes one row below the detour
|
|
964
|
+
// line), and search downward for a row where both the detour line
|
|
965
|
+
// and the label are still on blank canvas — another relationship
|
|
966
|
+
// detoured through the same row-gap band would otherwise land on
|
|
967
|
+
// the exact same row and silently overwrite this one's label.
|
|
968
|
+
const labelRows = rel.label ? splitLines(rel.label).length : 0
|
|
969
|
+
let detourY = rowBottom + 1
|
|
970
|
+
const maxDetourY = rowBottom + Math.max(vGap * 3, 4)
|
|
971
|
+
while (
|
|
972
|
+
detourY < maxDetourY &&
|
|
973
|
+
!regionClear(startX, endX, detourY, detourY + labelRows)
|
|
974
|
+
) {
|
|
975
|
+
detourY++
|
|
976
|
+
}
|
|
977
|
+
// Grow the canvas before drawing — a detour can reach further down
|
|
978
|
+
// than the initial component-based sizing anticipated.
|
|
979
|
+
increaseSize(canvas, endX + 1, detourY + labelRows + 1)
|
|
980
|
+
increaseRoleCanvasSize(rc, endX + 1, detourY + labelRows + 1)
|
|
981
|
+
for (let y = lineY; y <= detourY; y++) {
|
|
982
|
+
setCGuarded(startX, y, lineV, 'line')
|
|
983
|
+
setCGuarded(endX, y, lineV, 'line')
|
|
984
|
+
}
|
|
985
|
+
for (let x = startX; x <= endX; x++) {
|
|
986
|
+
setCGuardedH(x, detourY, lineH, 'line')
|
|
987
|
+
}
|
|
988
|
+
// Mark the two points where the detour actually turns — straight
|
|
989
|
+
// down from the row, then straight across, then straight back up —
|
|
990
|
+
// so the turn reads as one continuous line rather than a line
|
|
991
|
+
// ending flush against an unrelated mark (issue #414).
|
|
992
|
+
if (startX !== endX) {
|
|
993
|
+
drawCorner(startX, detourY, 'up', 'right')
|
|
994
|
+
drawCorner(endX, detourY, 'up', 'left')
|
|
995
|
+
}
|
|
996
|
+
labelBaseY = detourY + 1
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Draw crow's foot markers at endpoints
|
|
1000
|
+
// Left marker (at left entity's right edge) - isRight=false
|
|
1001
|
+
for (let i = 0; i < leftChars.length; i++) {
|
|
1002
|
+
setCGuarded(markerStartX + i, lineY, leftChars[i]!, 'arrow')
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Right marker (at right entity's left edge) - isRight=true
|
|
1006
|
+
for (let i = 0; i < rightChars.length; i++) {
|
|
1007
|
+
setCGuarded(
|
|
1008
|
+
markerEndX - rightChars.length + 1 + i,
|
|
1009
|
+
lineY,
|
|
1010
|
+
rightChars[i]!,
|
|
1011
|
+
'arrow',
|
|
1012
|
+
)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Relationship label centered in the gap between the two entities,
|
|
1016
|
+
// below the line (or below the detour, when one was needed). Clamp
|
|
1017
|
+
// label to the padded gap region [startX + inset, endX - inset] so it
|
|
1018
|
+
// never touches a box border. Supports multi-line labels. The gap
|
|
1019
|
+
// itself is widened during layout (see pairLabelWidth) so the full
|
|
1020
|
+
// label always fits when the path is direct.
|
|
1021
|
+
if (rel.label) {
|
|
1022
|
+
const lines = splitLines(rel.label)
|
|
1023
|
+
const gapMid = Math.floor((startX + endX) / 2)
|
|
1024
|
+
const labelMinX = startX + labelInset
|
|
1025
|
+
const labelMaxX = endX - labelInset
|
|
1026
|
+
|
|
1027
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
1028
|
+
const line = lines[lineIdx]!
|
|
1029
|
+
// Grid cells, not code units — see toDisplayCells.
|
|
1030
|
+
const cells = toDisplayCells(line)
|
|
1031
|
+
const labelStart = Math.max(
|
|
1032
|
+
labelMinX,
|
|
1033
|
+
gapMid - Math.floor(cells.length / 2),
|
|
1034
|
+
)
|
|
1035
|
+
const labelY = labelBaseY + lineIdx
|
|
1036
|
+
// Ensure canvas is tall enough
|
|
1037
|
+
increaseSize(
|
|
1038
|
+
canvas,
|
|
1039
|
+
Math.max(labelStart + cells.length, 1),
|
|
1040
|
+
Math.max(labelY + 1, 1),
|
|
1041
|
+
)
|
|
1042
|
+
increaseRoleCanvasSize(
|
|
1043
|
+
rc,
|
|
1044
|
+
Math.max(labelStart + cells.length, 1),
|
|
1045
|
+
Math.max(labelY + 1, 1),
|
|
1046
|
+
)
|
|
1047
|
+
if (
|
|
1048
|
+
!canPlaceLabelLine(cells, labelStart, labelY, labelMinX, labelMaxX)
|
|
1049
|
+
) {
|
|
1050
|
+
continue
|
|
1051
|
+
}
|
|
1052
|
+
for (let i = 0; i < cells.length; i++) {
|
|
1053
|
+
const lx = labelStart + i
|
|
1054
|
+
if (lx >= labelMinX && lx <= labelMaxX) {
|
|
1055
|
+
setCGuarded(lx, labelY, cells[i]!, 'text')
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
// Vertical connection: bottom of upper entity → top of lower entity
|
|
1062
|
+
const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1]
|
|
1063
|
+
const [upperCard, lowerCard] =
|
|
1064
|
+
e1CY < e2CY
|
|
1065
|
+
? [rel.cardinality1, rel.cardinality2]
|
|
1066
|
+
: [rel.cardinality2, rel.cardinality1]
|
|
1067
|
+
|
|
1068
|
+
const startY = upper.y + upper.height
|
|
1069
|
+
const endY = lower.y - 1
|
|
1070
|
+
// Dead center when this entity has only one relationship attaching
|
|
1071
|
+
// to this side; otherwise staggered so it doesn't collide with a
|
|
1072
|
+
// sibling relationship's marker on the same edge (issue #453).
|
|
1073
|
+
const lineX = attachmentX(upper, rel, bottomAttachments)
|
|
1074
|
+
const lowerCX = attachmentX(lower, rel, topAttachments)
|
|
1075
|
+
// Jog-row selection superseded by the multi-row-obstruction routing
|
|
1076
|
+
// below (issue #350's regionClear/pickBandY-based band search), which
|
|
1077
|
+
// subsumes this block's same-row-mate collision avoidance (issue
|
|
1078
|
+
// #351) as a special case. See chooseFreeRow's removal — no caller
|
|
1079
|
+
// remains.
|
|
1080
|
+
|
|
1081
|
+
/** True when column x is free of every entity box across [yStart, yEnd]. */
|
|
1082
|
+
function columnClearOfBoxes(
|
|
1083
|
+
x: number,
|
|
1084
|
+
yStart: number,
|
|
1085
|
+
yEnd: number,
|
|
1086
|
+
): boolean {
|
|
1087
|
+
for (let y = yStart; y <= yEnd; y++) {
|
|
1088
|
+
if (boxCells.has(`${x},${y}`)) return false
|
|
1089
|
+
}
|
|
1090
|
+
return true
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Crow's-foot markers sit flush against each entity border, matching
|
|
1094
|
+
// the horizontal-connection markers above — see issue #351. (No
|
|
1095
|
+
// separate label inset is needed here: the vertical label, below,
|
|
1096
|
+
// never clamped to an inset range in the first place.) Computed up
|
|
1097
|
+
// front (rather than only just before drawing the markers) because
|
|
1098
|
+
// the multi-row-obstruction routing below needs to jog *after* these
|
|
1099
|
+
// rows, not immediately at startY/endY — otherwise the marker ends up
|
|
1100
|
+
// sitting past where the line already turned away, disconnected from
|
|
1101
|
+
// it.
|
|
1102
|
+
const markerStartY = startY
|
|
1103
|
+
const markerEndY = endY
|
|
1104
|
+
|
|
1105
|
+
// The naive vertical midpoint can still sit inside a row-mate of
|
|
1106
|
+
// `upper` that's taller than `upper` itself, so a horizontal jog (or
|
|
1107
|
+
// the label, placed near the same band) at that Y would run straight
|
|
1108
|
+
// through that entity's box (issue #350 — e.g. `dispatches`, where
|
|
1109
|
+
// SHIPMENT is shorter than its row-mate LINE_ITEM). Clamp to the
|
|
1110
|
+
// row-gap band that's actually free of every entity: below the
|
|
1111
|
+
// tallest box in upper's row, and above the top of lower's row.
|
|
1112
|
+
const upperRowBottom = Math.max(
|
|
1113
|
+
...[...placed.values()]
|
|
1114
|
+
.filter((p) => p.y === upper.y)
|
|
1115
|
+
.map((p) => p.y + p.height),
|
|
1116
|
+
)
|
|
1117
|
+
const lowerRowTop = lower.y
|
|
1118
|
+
const bandTop = Math.max(startY, upperRowBottom + 1)
|
|
1119
|
+
const bandBottom = Math.min(endY, lowerRowTop - 1)
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Pick a row within the free row-gap band for a horizontal run across
|
|
1123
|
+
* [xStart, xEnd] (plus `extraRows` below it, for a multi-line label).
|
|
1124
|
+
* Prefers a row that's still entirely blank — not already used by
|
|
1125
|
+
* another relationship's line or label routed through the same band
|
|
1126
|
+
* (issue #350) — falling back to the midpoint of the band (or of the
|
|
1127
|
+
* full startY..endY span, if the band is degenerate) when nothing is
|
|
1128
|
+
* fully clear.
|
|
1129
|
+
*/
|
|
1130
|
+
function pickBandY(
|
|
1131
|
+
xStart: number,
|
|
1132
|
+
xEnd: number,
|
|
1133
|
+
extraRows: number,
|
|
1134
|
+
): number {
|
|
1135
|
+
const fallback =
|
|
1136
|
+
bandTop <= bandBottom
|
|
1137
|
+
? Math.floor((bandTop + bandBottom) / 2)
|
|
1138
|
+
: Math.floor((startY + endY) / 2)
|
|
1139
|
+
if (bandTop > bandBottom) return fallback
|
|
1140
|
+
for (let y = bandTop; y <= bandBottom; y++) {
|
|
1141
|
+
if (regionClear(xStart, xEnd, y, y + extraRows)) return y
|
|
1142
|
+
}
|
|
1143
|
+
return fallback
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Natural (unescalated) routing: a single straight column when the
|
|
1147
|
+
// two entities share a center column, otherwise a two-segment jog
|
|
1148
|
+
// through a row picked by pickBandY. Computed up front — even though
|
|
1149
|
+
// it ends up unused when multiRowObstruction below escalates to the
|
|
1150
|
+
// via-column bypass instead — because deciding *whether* to escalate
|
|
1151
|
+
// also depends on whether this natural routing's own columns would
|
|
1152
|
+
// land adjacent to another relationship's already-drawn vertical run
|
|
1153
|
+
// (see verticalLaneConflict, issue #411).
|
|
1154
|
+
const needsJog = lineX !== lowerCX
|
|
1155
|
+
const lx = Math.min(lineX, lowerCX)
|
|
1156
|
+
const rx = Math.max(lineX, lowerCX)
|
|
1157
|
+
const midY = needsJog ? pickBandY(lx, rx, 0) : endY
|
|
1158
|
+
const initialVertEnd = needsJog ? midY - 1 : endY
|
|
1159
|
+
|
|
1160
|
+
// [bandTop, bandBottom] — not the wider [startY, endY] — is what the
|
|
1161
|
+
// single-row clamp above already guarantees is clear of a row-mate of
|
|
1162
|
+
// `upper` (or the top of `lower`'s own row). Checking the wider range
|
|
1163
|
+
// here would misfire on exactly that already-handled case (a row-mate
|
|
1164
|
+
// taller than `upper`, e.g. STUDENT next to a shorter TEACHER) and
|
|
1165
|
+
// route it through the free-column bypass unnecessarily. A genuine
|
|
1166
|
+
// multi-row obstruction — some entity in a row strictly between
|
|
1167
|
+
// `upper`'s and `lower`'s own rows (e.g. A→G below, where D sits in
|
|
1168
|
+
// the same column as A, two rows down) — still shows up here, since
|
|
1169
|
+
// bandTop/bandBottom span every row in between, not just one.
|
|
1170
|
+
//
|
|
1171
|
+
// Also escalates to the via-column bypass when the *natural* routing
|
|
1172
|
+
// computed above would put lineX's or lowerCX's own straight segment
|
|
1173
|
+
// column-adjacent to a different relationship's already-registered
|
|
1174
|
+
// vertical lane over an overlapping row range (issue #411) — bumping
|
|
1175
|
+
// this relationship onto its own distinct column keeps the two
|
|
1176
|
+
// readable as separate lines instead of one that appears to bend
|
|
1177
|
+
// sideways where they happen to sit closest.
|
|
1178
|
+
// The registered/checked range for each column includes the jog row
|
|
1179
|
+
// (midY) itself, not just the pure-vertical run up to it — the corner
|
|
1180
|
+
// glyph at (lineX, midY) or (lowerCX, midY) is where the bend
|
|
1181
|
+
// actually happens, and it's exactly as visually adjacent to a
|
|
1182
|
+
// neighboring column as the straight run above/below it.
|
|
1183
|
+
const multiRowObstruction =
|
|
1184
|
+
!columnClearOfBoxes(lineX, bandTop, bandBottom) ||
|
|
1185
|
+
!columnClearOfBoxes(lowerCX, bandTop, bandBottom) ||
|
|
1186
|
+
verticalLaneConflict(lineX, startY, needsJog ? midY : endY) ||
|
|
1187
|
+
(needsJog && verticalLaneConflict(lowerCX, midY, endY))
|
|
1188
|
+
|
|
1189
|
+
// Where the visible line actually runs — lineX in the common case,
|
|
1190
|
+
// or the free bypass column found below when a third entity sits
|
|
1191
|
+
// directly in the way. The label (further down) anchors to whichever
|
|
1192
|
+
// one is real, instead of always assuming lineX.
|
|
1193
|
+
let routingX = lineX
|
|
1194
|
+
|
|
1195
|
+
if (multiRowObstruction) {
|
|
1196
|
+
// Route around the intervening entity through a column that's
|
|
1197
|
+
// completely free of every box across the whole vertical span,
|
|
1198
|
+
// searched outward from lineX. Two short horizontal jogs — right
|
|
1199
|
+
// after leaving upper, right before reaching lower — connect
|
|
1200
|
+
// lineX/lowerCX to that column; the long middle run is a plain
|
|
1201
|
+
// vertical line, guaranteed not to touch any box.
|
|
1202
|
+
// Also skips a column that would collide with (viaColumnBlocked
|
|
1203
|
+
// rejects both identical and lane-adjacent columns) a different
|
|
1204
|
+
// relationship's already-registered vertical run (issue #411) — a
|
|
1205
|
+
// via column must be safe on both fronts, and reusing another
|
|
1206
|
+
// relationship's own column outright would silently blend the two
|
|
1207
|
+
// paths together (setCGuarded lets one 'line' write overwrite
|
|
1208
|
+
// another), which is worse than the adjacency this escalation is
|
|
1209
|
+
// meant to fix.
|
|
1210
|
+
const canvasWidth = canvas.length
|
|
1211
|
+
let viaX: number | undefined
|
|
1212
|
+
for (let offset = 0; offset <= canvasWidth; offset++) {
|
|
1213
|
+
const right = lineX + offset
|
|
1214
|
+
if (
|
|
1215
|
+
columnClearOfBoxes(right, startY, endY) &&
|
|
1216
|
+
!viaColumnBlocked(right, startY, endY)
|
|
1217
|
+
) {
|
|
1218
|
+
viaX = right
|
|
1219
|
+
break
|
|
1220
|
+
}
|
|
1221
|
+
const left = lineX - offset
|
|
1222
|
+
if (
|
|
1223
|
+
offset > 0 &&
|
|
1224
|
+
columnClearOfBoxes(left, startY, endY) &&
|
|
1225
|
+
!viaColumnBlocked(left, startY, endY)
|
|
1226
|
+
) {
|
|
1227
|
+
viaX = left
|
|
1228
|
+
break
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// No column anywhere on the canvas is fully clear (a very dense
|
|
1232
|
+
// diagram) — fall back to lineX. setCGuarded still guarantees no
|
|
1233
|
+
// box gets corrupted; the line just keeps the gap it already had.
|
|
1234
|
+
routingX = viaX ?? lineX
|
|
1235
|
+
|
|
1236
|
+
// Stay on lineX/lowerCX through the marker rows (so each crow's
|
|
1237
|
+
// foot marker still sits on a connected line, same as the
|
|
1238
|
+
// non-obstructed case below), then jog over to routingX for the
|
|
1239
|
+
// long middle run.
|
|
1240
|
+
for (let y = startY; y <= markerStartY; y++) {
|
|
1241
|
+
setCGuarded(lineX, y, lineV, 'line')
|
|
1242
|
+
}
|
|
1243
|
+
for (
|
|
1244
|
+
let x = Math.min(lineX, routingX);
|
|
1245
|
+
x <= Math.max(lineX, routingX);
|
|
1246
|
+
x++
|
|
1247
|
+
) {
|
|
1248
|
+
setCGuardedH(x, markerStartY, lineH, 'line')
|
|
1249
|
+
}
|
|
1250
|
+
for (let y = markerStartY; y <= markerEndY; y++) {
|
|
1251
|
+
setCGuarded(routingX, y, lineV, 'line')
|
|
1252
|
+
}
|
|
1253
|
+
for (
|
|
1254
|
+
let x = Math.min(routingX, lowerCX);
|
|
1255
|
+
x <= Math.max(routingX, lowerCX);
|
|
1256
|
+
x++
|
|
1257
|
+
) {
|
|
1258
|
+
setCGuardedH(x, markerEndY, lineH, 'line')
|
|
1259
|
+
}
|
|
1260
|
+
for (let y = markerEndY; y <= endY; y++) {
|
|
1261
|
+
setCGuarded(lowerCX, y, lineV, 'line')
|
|
1262
|
+
}
|
|
1263
|
+
// Mark the bypass's four turns (issue #414). Drawn before the
|
|
1264
|
+
// crow's-foot markers below, so a marker centered on the same
|
|
1265
|
+
// column/row still wins where the two coincide — same precedence
|
|
1266
|
+
// as before this change, when the marker overwrote a plain line
|
|
1267
|
+
// character there instead of a corner.
|
|
1268
|
+
if (lineX !== routingX) {
|
|
1269
|
+
const horizDirAtLine = routingX > lineX ? 'right' : 'left'
|
|
1270
|
+
const horizDirAtRouting =
|
|
1271
|
+
horizDirAtLine === 'right' ? 'left' : 'right'
|
|
1272
|
+
drawCorner(lineX, markerStartY, 'up', horizDirAtLine)
|
|
1273
|
+
drawCorner(routingX, markerStartY, 'down', horizDirAtRouting)
|
|
1274
|
+
}
|
|
1275
|
+
if (routingX !== lowerCX) {
|
|
1276
|
+
const horizDirAtRouting2 = lowerCX > routingX ? 'right' : 'left'
|
|
1277
|
+
const horizDirAtLower =
|
|
1278
|
+
horizDirAtRouting2 === 'right' ? 'left' : 'right'
|
|
1279
|
+
drawCorner(routingX, markerEndY, 'up', horizDirAtRouting2)
|
|
1280
|
+
drawCorner(lowerCX, markerEndY, 'down', horizDirAtLower)
|
|
1281
|
+
}
|
|
1282
|
+
// Register the long middle run so a later relationship's own
|
|
1283
|
+
// vertical connector won't land adjacent to it either (#411). The
|
|
1284
|
+
// short lineX/lowerCX stubs at the marker rows are only 1 row deep
|
|
1285
|
+
// (markerStartY..markerStartY, markerEndY..markerEndY) — negligible
|
|
1286
|
+
// for this check, so they're not registered separately.
|
|
1287
|
+
registerVerticalLane(routingX, markerStartY, markerEndY)
|
|
1288
|
+
} else {
|
|
1289
|
+
// Vertical line. Column lineX stays within upper's own x-range,
|
|
1290
|
+
// which by layout construction never overlaps a row-mate's box, so
|
|
1291
|
+
// this straight run is safe regardless of how far it descends. When
|
|
1292
|
+
// a horizontal jog is needed below, though, the path redirects to
|
|
1293
|
+
// lowerCX at the jog row — so this initial fill must stop *before*
|
|
1294
|
+
// that row rather than always running the full height. Otherwise a
|
|
1295
|
+
// leftover remnant keeps drawing all the way to endY at the
|
|
1296
|
+
// original lineX, a stray parallel line beside the actual jogged
|
|
1297
|
+
// path that connects to nothing (issue #392).
|
|
1298
|
+
//
|
|
1299
|
+
// needsJog/lx/rx/midY/initialVertEnd were already computed above,
|
|
1300
|
+
// alongside the multiRowObstruction check itself (issue #411) —
|
|
1301
|
+
// reused here rather than recomputed.
|
|
1302
|
+
for (let y = startY; y <= initialVertEnd; y++) {
|
|
1303
|
+
setCGuarded(lineX, y, lineV, 'line')
|
|
1304
|
+
}
|
|
1305
|
+
// Registered range includes the jog row itself (midY) when jogging
|
|
1306
|
+
// — see the multiRowObstruction comment above for why.
|
|
1307
|
+
registerVerticalLane(lineX, startY, needsJog ? midY : initialVertEnd)
|
|
1308
|
+
|
|
1309
|
+
// If horizontal offset needed, add a horizontal segment
|
|
1310
|
+
if (needsJog) {
|
|
1311
|
+
// Horizontal segment at midY
|
|
1312
|
+
for (let x = lx; x <= rx; x++) {
|
|
1313
|
+
setCGuardedH(x, midY, lineH, 'line')
|
|
1314
|
+
}
|
|
1315
|
+
// Vertical from midY to lower entity
|
|
1316
|
+
for (let y = midY + 1; y <= endY; y++) {
|
|
1317
|
+
setCGuarded(lowerCX, y, lineV, 'line')
|
|
1318
|
+
}
|
|
1319
|
+
registerVerticalLane(lowerCX, midY, endY)
|
|
1320
|
+
// Mark the jog's two turns (issue #414): the vertical run from
|
|
1321
|
+
// upper arrives from above and turns toward lowerCX; the
|
|
1322
|
+
// vertical run into lower departs downward, having turned away
|
|
1323
|
+
// from lineX.
|
|
1324
|
+
const horizDirAtLine = lowerCX > lineX ? 'right' : 'left'
|
|
1325
|
+
const horizDirAtLower = horizDirAtLine === 'right' ? 'left' : 'right'
|
|
1326
|
+
drawCorner(lineX, midY, 'up', horizDirAtLine)
|
|
1327
|
+
drawCorner(lowerCX, midY, 'down', horizDirAtLower)
|
|
1328
|
+
// The path now ends at lowerCX, not lineX — the lower marker and
|
|
1329
|
+
// label (below) must anchor there too, or they render visually
|
|
1330
|
+
// disconnected from the line that actually reaches them (#392).
|
|
1331
|
+
routingX = lowerCX
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Crow's foot markers (vertical direction) — markerStartY/markerEndY
|
|
1336
|
+
// computed up front, above.
|
|
1337
|
+
// Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false)
|
|
1338
|
+
const upperChars = getCrowsFootChars(upperCard, useAscii, false, true)
|
|
1339
|
+
for (let i = 0; i < upperChars.length; i++) {
|
|
1340
|
+
setCGuarded(
|
|
1341
|
+
lineX - Math.floor(upperChars.length / 2) + i,
|
|
1342
|
+
markerStartY,
|
|
1343
|
+
upperChars[i]!,
|
|
1344
|
+
'arrow',
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Lower marker (at lower entity's top edge) - treat as target side (isRight=true)
|
|
1349
|
+
const targetX = lineX !== lowerCX ? lowerCX : lineX
|
|
1350
|
+
const lowerChars = getCrowsFootChars(lowerCard, useAscii, true, true)
|
|
1351
|
+
for (let i = 0; i < lowerChars.length; i++) {
|
|
1352
|
+
setCGuarded(
|
|
1353
|
+
targetX - Math.floor(lowerChars.length / 2) + i,
|
|
1354
|
+
markerEndY,
|
|
1355
|
+
lowerChars[i]!,
|
|
1356
|
+
'arrow',
|
|
1357
|
+
)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Relationship label — placed to the right of the vertical line.
|
|
1361
|
+
// Anchored to routingX, not lineX/targetX: when the connection was
|
|
1362
|
+
// detoured around an obstructing entity (#350), or jogged over to
|
|
1363
|
+
// the lower entity's own column, the label needs to sit next to
|
|
1364
|
+
// wherever the line actually ends up, not next to the relationship's
|
|
1365
|
+
// original upper-entity column — otherwise it renders visually
|
|
1366
|
+
// disconnected, floating in the gap between the two. We expand the
|
|
1367
|
+
// canvas as needed since labels can extend beyond the initial
|
|
1368
|
+
// bounds. Supports multi-line labels.
|
|
1369
|
+
if (rel.label) {
|
|
1370
|
+
const lines = splitLines(rel.label)
|
|
1371
|
+
// How far a centered vertical marker's own rightmost glyph column
|
|
1372
|
+
// extends past its own center column — see upperChars/lowerChars
|
|
1373
|
+
// above, drawn via `center - Math.floor(chars.length / 2) + i`. For
|
|
1374
|
+
// every current 1- or 2-cell marker this is 0 (centering always
|
|
1375
|
+
// leans the extra cell left, never right), so labelX below reduces
|
|
1376
|
+
// to routingX + 2, matching the flat constant this replaces — but
|
|
1377
|
+
// it stays correct if a future marker's glyph or centering ever
|
|
1378
|
+
// extended past the line column (issue #415).
|
|
1379
|
+
const markerRightExtent = Math.max(
|
|
1380
|
+
upperChars.length - 1 - Math.floor(upperChars.length / 2),
|
|
1381
|
+
lowerChars.length - 1 - Math.floor(lowerChars.length / 2),
|
|
1382
|
+
)
|
|
1383
|
+
// 1 cell for the line column itself + 1 cell of breathing room
|
|
1384
|
+
// before the label starts. Was a flat `+ 2` (issue #415).
|
|
1385
|
+
const labelX = routingX + markerRightExtent + 2
|
|
1386
|
+
const cellsPerLine = lines.map((line) => toDisplayCells(line))
|
|
1387
|
+
const maxCells = Math.max(...cellsPerLine.map((cells) => cells.length))
|
|
1388
|
+
const lastLx = labelX + maxCells - 1
|
|
1389
|
+
const blockHeight = lines.length
|
|
1390
|
+
// Geometric midpoint, used only as a starting point for the
|
|
1391
|
+
// collision search below — the jog itself no longer shares this
|
|
1392
|
+
// value (chooseFreeRow was removed; multiRowObstruction routing
|
|
1393
|
+
// picks its own row independently), so this is a fresh, local
|
|
1394
|
+
// fallback rather than a value reused from the line/jog above.
|
|
1395
|
+
const midY = Math.floor((startY + endY) / 2)
|
|
1396
|
+
// the search for the label's block there rather than at a freshly
|
|
1397
|
+
// recomputed geometric midpoint — otherwise the label could still
|
|
1398
|
+
// drift onto a different row than its own horizontal segment.
|
|
1399
|
+
const naturalStartY = midY - Math.floor((blockHeight - 1) / 2)
|
|
1400
|
+
|
|
1401
|
+
if (lastLx >= 0) {
|
|
1402
|
+
increaseSize(canvas, lastLx + 1, endY + 1)
|
|
1403
|
+
increaseRoleCanvasSize(rc, lastLx + 1, endY + 1)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Multiple vertical relationships sharing the same upper/lower
|
|
1407
|
+
// entity "rows" in the grid layout end up with the identical
|
|
1408
|
+
// startY/endY — and so the identical natural midY — for their own
|
|
1409
|
+
// labels. Without ever considering another row, only the first of
|
|
1410
|
+
// them to draw would keep its label; the rest would find their
|
|
1411
|
+
// natural row already taken and drop out entirely, even though
|
|
1412
|
+
// there's room a row or two away. Try the natural row first, then
|
|
1413
|
+
// scan outward (alternating below/above) within the relationship's
|
|
1414
|
+
// own vertical run for the nearest row where the whole label fits
|
|
1415
|
+
// cleanly. "Fits" means clear of every entity box (see boxCells) —
|
|
1416
|
+
// not just text/border content already drawn — since a box's own
|
|
1417
|
+
// blank interior padding never gets a role written to it and would
|
|
1418
|
+
// otherwise look free; a bypass-routed relationship (routingX) can
|
|
1419
|
+
// legitimately run its label past a box that never touched
|
|
1420
|
+
// startY/endY's naive band (#350). It also means clear of markers,
|
|
1421
|
+
// not just text/borders (#392) — before giving up.
|
|
1422
|
+
let placedAtY: number | null = null
|
|
1423
|
+
for (
|
|
1424
|
+
let offset = 0;
|
|
1425
|
+
offset <= endY - startY && placedAtY === null;
|
|
1426
|
+
offset++
|
|
1427
|
+
) {
|
|
1428
|
+
const candidates =
|
|
1429
|
+
offset === 0
|
|
1430
|
+
? [naturalStartY]
|
|
1431
|
+
: [naturalStartY + offset, naturalStartY - offset]
|
|
1432
|
+
for (const candidateStart of candidates) {
|
|
1433
|
+
if (
|
|
1434
|
+
candidateStart < startY ||
|
|
1435
|
+
candidateStart + blockHeight - 1 > endY
|
|
1436
|
+
) {
|
|
1437
|
+
continue
|
|
1438
|
+
}
|
|
1439
|
+
const fits = cellsPerLine.every((cells, lineIdx) =>
|
|
1440
|
+
canPlaceLabelLineAvoidingMarkers(
|
|
1441
|
+
cells,
|
|
1442
|
+
labelX,
|
|
1443
|
+
candidateStart + lineIdx,
|
|
1444
|
+
0,
|
|
1445
|
+
lastLx,
|
|
1446
|
+
),
|
|
1447
|
+
)
|
|
1448
|
+
if (fits) {
|
|
1449
|
+
placedAtY = candidateStart
|
|
1450
|
+
break
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (placedAtY !== null) {
|
|
1456
|
+
for (let lineIdx = 0; lineIdx < blockHeight; lineIdx++) {
|
|
1457
|
+
const cells = cellsPerLine[lineIdx]!
|
|
1458
|
+
const y = placedAtY + lineIdx
|
|
1459
|
+
for (let i = 0; i < cells.length; i++) {
|
|
1460
|
+
const lx = labelX + i
|
|
1461
|
+
if (lx >= 0) {
|
|
1462
|
+
setCGuarded(lx, y, cells[i]!, 'text')
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
// A relationship's own jog can leave a line character
|
|
1466
|
+
// immediately beside where its own label starts —
|
|
1467
|
+
// setCGuardedH only stops a *different*, later write from
|
|
1468
|
+
// crowding an already-placed label; it can't retroactively
|
|
1469
|
+
// clean up a dash this same relationship left right there
|
|
1470
|
+
// moments earlier, before the label existed to protect
|
|
1471
|
+
// against it. Clear one cell of breathing room on each side
|
|
1472
|
+
// whenever a line glyph (from any relationship) is sitting
|
|
1473
|
+
// there.
|
|
1474
|
+
if (rc[labelX - 1]?.[y] === 'line') {
|
|
1475
|
+
setC(labelX - 1, y, ' ', 'line')
|
|
1476
|
+
}
|
|
1477
|
+
const afterX = labelX + cells.length
|
|
1478
|
+
if (rc[afterX]?.[y] === 'line') {
|
|
1479
|
+
setC(afterX, y, ' ', 'line')
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })
|
|
1488
|
+
}
|