@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,2001 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASCII renderer — class diagrams
|
|
3
|
+
//
|
|
4
|
+
// Renders classDiagram text to ASCII/Unicode art.
|
|
5
|
+
// Each class is a multi-compartment box (header | attributes | methods).
|
|
6
|
+
// Relationships are drawn as lines between classes with UML markers.
|
|
7
|
+
//
|
|
8
|
+
// Layout: level-based top-down. "From" classes are placed above "to" classes
|
|
9
|
+
// for all relationship types, matching ELK/mermaid.com behavior.
|
|
10
|
+
// Relationship lines use simple Manhattan routing (vertical + horizontal).
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
parseClassDiagram,
|
|
15
|
+
formatClassMember,
|
|
16
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
17
|
+
import type {
|
|
18
|
+
ClassDiagram,
|
|
19
|
+
ClassNode,
|
|
20
|
+
ClassNote,
|
|
21
|
+
RelationshipType,
|
|
22
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
23
|
+
import type {
|
|
24
|
+
AsciiConfig,
|
|
25
|
+
Canvas,
|
|
26
|
+
CharRole,
|
|
27
|
+
AsciiTheme,
|
|
28
|
+
ColorMode,
|
|
29
|
+
} from './types.ts'
|
|
30
|
+
import {
|
|
31
|
+
mkCanvas,
|
|
32
|
+
mkRoleCanvas,
|
|
33
|
+
canvasToString,
|
|
34
|
+
increaseSize,
|
|
35
|
+
increaseRoleCanvasSize,
|
|
36
|
+
write,
|
|
37
|
+
isJunctionChar,
|
|
38
|
+
mergeJunctions,
|
|
39
|
+
} from './canvas.ts'
|
|
40
|
+
import { drawMultiBox, measureMultiBox, classifyBoxChar } from './draw.ts'
|
|
41
|
+
import { allocateTerritory } from './territory.ts'
|
|
42
|
+
import type { Territory } from './territory.ts'
|
|
43
|
+
import { markBoxLabelLinks, mkLinkCanvas } from './hyperlinks.ts'
|
|
44
|
+
import type { LinkCanvas } from './hyperlinks.ts'
|
|
45
|
+
import { safeHref, splitStatements } from '@zombie-mermaid/core'
|
|
46
|
+
import { getCorners } from './shapes/corners.ts'
|
|
47
|
+
import { splitLines } from './multiline-utils.ts'
|
|
48
|
+
import { displayWidth, toDisplayCells } from './display-width.ts'
|
|
49
|
+
import { findFreeLane } from './lane-search.ts'
|
|
50
|
+
import { DEFAULT_PADDING_X, DEFAULT_PADDING_Y, paddingOffset } from './types.ts'
|
|
51
|
+
|
|
52
|
+
/** Build the text sections for a class box: [header], [attributes], [methods] */
|
|
53
|
+
function buildClassSections(cls: ClassNode): string[][] {
|
|
54
|
+
// Header section: optional annotation + class name (may be multi-line)
|
|
55
|
+
const header: string[] = []
|
|
56
|
+
if (cls.annotation) header.push(`<<${cls.annotation}>>`)
|
|
57
|
+
// Support multi-line class names
|
|
58
|
+
const nameLines = splitLines(cls.label)
|
|
59
|
+
header.push(...nameLines)
|
|
60
|
+
|
|
61
|
+
// Attributes section
|
|
62
|
+
const attrs = cls.attributes.map(formatClassMember)
|
|
63
|
+
|
|
64
|
+
// Methods section
|
|
65
|
+
const methods = cls.methods.map(formatClassMember)
|
|
66
|
+
|
|
67
|
+
// Build sections from only the non-empty parts. Only attrs (2-section),
|
|
68
|
+
// only methods (2-section), both (3-section), or neither (1-section, header only).
|
|
69
|
+
const sections: string[][] = [header]
|
|
70
|
+
if (attrs.length > 0) sections.push(attrs)
|
|
71
|
+
if (methods.length > 0) sections.push(methods)
|
|
72
|
+
return sections
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Relationship marker characters
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
interface RelMarker {
|
|
80
|
+
/** Relationship type (determines marker shape) */
|
|
81
|
+
type: RelationshipType
|
|
82
|
+
/** Which end the marker is placed at */
|
|
83
|
+
markerAt: 'from' | 'to'
|
|
84
|
+
/** Whether the line is dashed */
|
|
85
|
+
dashed: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the marker metadata for a relationship.
|
|
90
|
+
* The actual marker character will be determined at placement time based on line direction.
|
|
91
|
+
*/
|
|
92
|
+
function getRelMarker(
|
|
93
|
+
type: RelationshipType,
|
|
94
|
+
markerAt: 'from' | 'to',
|
|
95
|
+
): RelMarker {
|
|
96
|
+
const dashed = type === 'dependency' || type === 'realization'
|
|
97
|
+
return { type, markerAt, dashed }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the UML marker shape character for a relationship type.
|
|
102
|
+
* For directional arrows (association/dependency), the direction parameter
|
|
103
|
+
* specifies which way the arrow should point.
|
|
104
|
+
*/
|
|
105
|
+
function getMarkerShape(
|
|
106
|
+
type: RelationshipType,
|
|
107
|
+
useAscii: boolean,
|
|
108
|
+
direction?: 'up' | 'down' | 'left' | 'right',
|
|
109
|
+
): string {
|
|
110
|
+
switch (type) {
|
|
111
|
+
case 'inheritance':
|
|
112
|
+
case 'realization':
|
|
113
|
+
// Hollow triangle - rotate based on line direction
|
|
114
|
+
// Triangle points TOWARD the parent class
|
|
115
|
+
if (direction === 'down') {
|
|
116
|
+
// Line goes down (parent above, child below) - triangle points UP
|
|
117
|
+
return useAscii ? '^' : '△'
|
|
118
|
+
} else if (direction === 'up') {
|
|
119
|
+
// Line goes up (parent below, child above) - triangle points DOWN
|
|
120
|
+
return useAscii ? 'v' : '▽'
|
|
121
|
+
} else if (direction === 'left') {
|
|
122
|
+
// Line goes left - triangle points LEFT
|
|
123
|
+
return useAscii ? '>' : '◁'
|
|
124
|
+
} else {
|
|
125
|
+
// Default: line goes right - triangle points RIGHT
|
|
126
|
+
return useAscii ? '<' : '▷'
|
|
127
|
+
}
|
|
128
|
+
case 'composition':
|
|
129
|
+
// Filled diamond - omnidirectional shape
|
|
130
|
+
return useAscii ? '*' : '◆'
|
|
131
|
+
case 'aggregation':
|
|
132
|
+
// Hollow diamond - omnidirectional shape
|
|
133
|
+
return useAscii ? 'o' : '◇'
|
|
134
|
+
case 'association':
|
|
135
|
+
case 'dependency':
|
|
136
|
+
// Directional arrow - rotate based on line direction
|
|
137
|
+
if (direction === 'down') {
|
|
138
|
+
return useAscii ? 'v' : '▼'
|
|
139
|
+
} else if (direction === 'up') {
|
|
140
|
+
return useAscii ? '^' : '▲'
|
|
141
|
+
} else if (direction === 'left') {
|
|
142
|
+
return useAscii ? '<' : '◀'
|
|
143
|
+
} else {
|
|
144
|
+
// Default to right (or when direction not specified)
|
|
145
|
+
return useAscii ? '>' : '▶'
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clip a relationship label's padded display cells to fit within
|
|
152
|
+
* [writableStart, writableEnd] — the room that's actually free on its row
|
|
153
|
+
* before it would collide with a *different* relationship's label that a
|
|
154
|
+
* previous iteration already drew there (see the call site in
|
|
155
|
+
* `renderClassAscii` for how that room is computed).
|
|
156
|
+
*
|
|
157
|
+
* Deliberately clips in place rather than re-centering into the available
|
|
158
|
+
* window: shifting a label's anchor to dodge a collision can push it
|
|
159
|
+
* further right than its natural centered position, which then collides
|
|
160
|
+
* with the *next* relationship's rightful space and cascades into
|
|
161
|
+
* dropping labels entirely — this happened while fixing issue #447 (a
|
|
162
|
+
* label pinned near the left canvas edge, itself already clamped rightward
|
|
163
|
+
* by `Math.max(0, idealLabelStart)`, swallowed its neighbor's whole ideal
|
|
164
|
+
* column). Truncated text gets an ellipsis on whichever side got clipped;
|
|
165
|
+
* the label's own padding spaces are dropped first since they're the least
|
|
166
|
+
* meaningful thing to lose.
|
|
167
|
+
*/
|
|
168
|
+
function fitLabelToAvailableWidth(
|
|
169
|
+
naturalStart: number,
|
|
170
|
+
naturalCells: string[],
|
|
171
|
+
text: string,
|
|
172
|
+
writableStart: number,
|
|
173
|
+
writableEnd: number,
|
|
174
|
+
): { start: number; cells: string[] } {
|
|
175
|
+
const naturalEnd = naturalStart + naturalCells.length - 1
|
|
176
|
+
const clippedLeft = writableStart > naturalStart
|
|
177
|
+
const clippedRight = writableEnd < naturalEnd
|
|
178
|
+
if (!clippedLeft && !clippedRight) {
|
|
179
|
+
return { start: naturalStart, cells: naturalCells }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only the side(s) that actually collided move inward — an unclipped
|
|
183
|
+
// side keeps its natural bound. Using the raw `writableStart`/
|
|
184
|
+
// `writableEnd` for *both* sides here (even the uncollided one) would
|
|
185
|
+
// center the shrunk label inside the entire remaining canvas rather
|
|
186
|
+
// than snug against its own natural extent, drifting it away from its
|
|
187
|
+
// own relationship and into a completely different one's territory.
|
|
188
|
+
const effectiveStart = clippedLeft ? writableStart : naturalStart
|
|
189
|
+
const effectiveEnd = clippedRight ? writableEnd : naturalEnd
|
|
190
|
+
const availableCols = Math.max(0, effectiveEnd - effectiveStart + 1)
|
|
191
|
+
const ellipsisCount = (clippedLeft ? 1 : 0) + (clippedRight ? 1 : 0)
|
|
192
|
+
|
|
193
|
+
if (availableCols === 0) return { start: effectiveStart, cells: [] }
|
|
194
|
+
if (availableCols < ellipsisCount) {
|
|
195
|
+
// Not even room for both ellipsis markers — show a single one rather
|
|
196
|
+
// than nothing, so a squeezed-out label still leaves a visible trace.
|
|
197
|
+
return { start: effectiveStart, cells: ['…'] }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const innerWidth = Math.max(0, availableCols - ellipsisCount)
|
|
201
|
+
const textCells = toDisplayCells(text)
|
|
202
|
+
const kept =
|
|
203
|
+
textCells.length <= innerWidth
|
|
204
|
+
? textCells
|
|
205
|
+
: clippedLeft && !clippedRight
|
|
206
|
+
? textCells.slice(textCells.length - innerWidth) // keep the tail
|
|
207
|
+
: textCells.slice(0, innerWidth) // keep the head
|
|
208
|
+
|
|
209
|
+
const cells = [
|
|
210
|
+
...(clippedLeft ? ['…'] : []),
|
|
211
|
+
...kept,
|
|
212
|
+
...(clippedRight ? ['…'] : []),
|
|
213
|
+
]
|
|
214
|
+
// Center the shrunk label within the writable window so it still reads
|
|
215
|
+
// near the relationship it belongs to, rather than hugging one edge.
|
|
216
|
+
const start =
|
|
217
|
+
effectiveStart + Math.max(0, Math.floor((availableCols - cells.length) / 2))
|
|
218
|
+
return { start, cells }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Display width of a relationship label as it is actually drawn: its widest
|
|
223
|
+
* line plus one padding space on each side. Every pass that needs to know
|
|
224
|
+
* how much horizontal room a label takes (column reservation, per-pair
|
|
225
|
+
* column spacing, territory precomputation, drawing) must measure it the
|
|
226
|
+
* same way, or the room reserved up front won't match what gets drawn.
|
|
227
|
+
*/
|
|
228
|
+
function labelCellWidth(label: string): number {
|
|
229
|
+
return Math.max(...splitLines(label).map((l) => displayWidth(l))) + 2
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// Layout and rendering
|
|
234
|
+
// ============================================================================
|
|
235
|
+
|
|
236
|
+
/** Positioned class node on the canvas */
|
|
237
|
+
interface PlacedClass {
|
|
238
|
+
cls: ClassNode
|
|
239
|
+
sections: string[][]
|
|
240
|
+
x: number
|
|
241
|
+
y: number
|
|
242
|
+
width: number
|
|
243
|
+
height: number
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Per-render switches that aren't layout config (see `AsciiRenderOptions`). */
|
|
247
|
+
export interface ClassAsciiOptions {
|
|
248
|
+
/** Wrap each `click`-linked class's name in an OSC 8 hyperlink pair. */
|
|
249
|
+
hyperlinks?: boolean
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Render a Mermaid class diagram to ASCII/Unicode text.
|
|
254
|
+
*
|
|
255
|
+
* Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string.
|
|
256
|
+
*/
|
|
257
|
+
export function renderClassAscii(
|
|
258
|
+
text: string,
|
|
259
|
+
config: AsciiConfig,
|
|
260
|
+
colorMode?: ColorMode,
|
|
261
|
+
theme?: AsciiTheme,
|
|
262
|
+
options: ClassAsciiOptions = {},
|
|
263
|
+
): string {
|
|
264
|
+
const lines = splitStatements(text)
|
|
265
|
+
// Notes ride through the layout as pseudo-classes — see withNotePseudoClasses.
|
|
266
|
+
const { diagram, notesById } = withNotePseudoClasses(parseClassDiagram(lines))
|
|
267
|
+
|
|
268
|
+
if (diagram.classes.length === 0) return ''
|
|
269
|
+
|
|
270
|
+
const useAscii = config.useAscii
|
|
271
|
+
// See paddingOffset's doc comment (types.ts) for why these are an offset
|
|
272
|
+
// from the padding defaults rather than the raw config values.
|
|
273
|
+
const hGap = paddingOffset(config.paddingX, DEFAULT_PADDING_X, 4, 1) // horizontal gap between class boxes
|
|
274
|
+
const vGap = paddingOffset(config.paddingY, DEFAULT_PADDING_Y, 3, 1) // vertical gap between levels (enough for relationship lines)
|
|
275
|
+
|
|
276
|
+
// --- Build box dimensions for each class ---
|
|
277
|
+
const classSections = new Map<string, string[][]>()
|
|
278
|
+
const classBoxW = new Map<string, number>()
|
|
279
|
+
const classBoxH = new Map<string, number>()
|
|
280
|
+
|
|
281
|
+
for (const cls of diagram.classes) {
|
|
282
|
+
const sections = buildClassSections(cls)
|
|
283
|
+
classSections.set(cls.id, sections)
|
|
284
|
+
|
|
285
|
+
// Reserve exactly what drawMultiBox will draw — measuring it here rather
|
|
286
|
+
// than re-deriving the arithmetic keeps layout and drawing in lockstep for
|
|
287
|
+
// wide-character (CJK/fullwidth) content.
|
|
288
|
+
const { width: boxW, height: boxH } = measureMultiBox(
|
|
289
|
+
sections,
|
|
290
|
+
config.boxBorderPadding,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
classBoxW.set(cls.id, boxW)
|
|
294
|
+
classBoxH.set(cls.id, boxH)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- Assign levels: topological sort based on directed relationships ---
|
|
298
|
+
// All relationship types place "from" above "to" in the layout, matching
|
|
299
|
+
// ELK's layered algorithm and the official mermaid.com renderer behavior.
|
|
300
|
+
// For "Animal <|-- Dog": from="Animal", to="Dog" → Animal above Dog.
|
|
301
|
+
//
|
|
302
|
+
// Every relationship type (including association and dependency) forces nodes
|
|
303
|
+
// to different levels. Same-row routing for mixed diagrams causes collisions:
|
|
304
|
+
// detour lines overlap with cross-level routing, and labels overwrite box borders.
|
|
305
|
+
|
|
306
|
+
const classById = new Map<string, ClassNode>()
|
|
307
|
+
for (const cls of diagram.classes) classById.set(cls.id, cls)
|
|
308
|
+
|
|
309
|
+
const parents = new Map<string, Set<string>>() // child → set of parent IDs
|
|
310
|
+
const children = new Map<string, Set<string>>() // parent → set of child IDs
|
|
311
|
+
|
|
312
|
+
for (const rel of diagram.relationships) {
|
|
313
|
+
// Level assignment always places "from" above "to", for every relationship
|
|
314
|
+
// type — including inheritance and realization — matching real mermaid.js's
|
|
315
|
+
// layout. This is independent of which end carries the UML marker glyph
|
|
316
|
+
// (`rel.markerAt`, used only to orient the arrowhead when drawing the line
|
|
317
|
+
// below); e.g. `Bird ..|> Flyable` (markerAt='to') places Bird above
|
|
318
|
+
// Flyable even though the hollow-triangle marker touches Flyable. See
|
|
319
|
+
// issue #446 — this used to special-case inheritance/realization to put
|
|
320
|
+
// whichever end held the marker on top, which produced the correct order
|
|
321
|
+
// for `<|--` (where marker happens to be at 'from') but reversed it for
|
|
322
|
+
// `..|>` (where marker is at 'to').
|
|
323
|
+
const parentId = rel.from
|
|
324
|
+
const childId = rel.to
|
|
325
|
+
|
|
326
|
+
const parentSet = parents.get(childId) ?? new Set<string>()
|
|
327
|
+
parents.set(childId, parentSet)
|
|
328
|
+
parentSet.add(parentId)
|
|
329
|
+
const childSet = children.get(parentId) ?? new Set<string>()
|
|
330
|
+
children.set(parentId, childSet)
|
|
331
|
+
childSet.add(childId)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// BFS from roots (classes that have no parents) to assign levels.
|
|
335
|
+
// Cap at classes.length - 1 to prevent infinite loops on cyclic graphs
|
|
336
|
+
// (e.g. View --> Model and Model ..> View would otherwise push levels
|
|
337
|
+
// upward forever). In a DAG the longest path has at most N-1 edges.
|
|
338
|
+
const level = new Map<string, number>()
|
|
339
|
+
const roots = diagram.classes.filter(
|
|
340
|
+
(c) => !parents.has(c.id) || parents.get(c.id)!.size === 0,
|
|
341
|
+
)
|
|
342
|
+
const queue: string[] = roots.map((c) => c.id)
|
|
343
|
+
for (const id of queue) level.set(id, 0)
|
|
344
|
+
|
|
345
|
+
const levelCap = diagram.classes.length - 1
|
|
346
|
+
let qi = 0
|
|
347
|
+
while (qi < queue.length) {
|
|
348
|
+
const id = queue[qi++]!
|
|
349
|
+
const childSet = children.get(id)
|
|
350
|
+
if (!childSet) continue
|
|
351
|
+
for (const childId of childSet) {
|
|
352
|
+
const newLevel = (level.get(id) ?? 0) + 1
|
|
353
|
+
if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop
|
|
354
|
+
if (!level.has(childId) || level.get(childId)! < newLevel) {
|
|
355
|
+
level.set(childId, newLevel)
|
|
356
|
+
queue.push(childId)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Assign remaining (unconnected) classes to level 0
|
|
362
|
+
for (const cls of diagram.classes) {
|
|
363
|
+
if (!level.has(cls.id)) level.set(cls.id, 0)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A `note for X` note has no relationships, so it landed on level 0 above;
|
|
367
|
+
// move it onto its class's row so the row placement below puts it right
|
|
368
|
+
// beside that class.
|
|
369
|
+
pinNoteLevels(notesById, level)
|
|
370
|
+
|
|
371
|
+
// --- Position classes by level ---
|
|
372
|
+
// Group classes by level
|
|
373
|
+
const maxLevel = Math.max(...[...level.values()], 0)
|
|
374
|
+
const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => [])
|
|
375
|
+
for (const cls of diagram.classes) {
|
|
376
|
+
levelGroups[level.get(cls.id)!]!.push(cls.id)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// When more than one relationship connects the same pair of classes —
|
|
380
|
+
// most commonly a pair going in opposite directions, e.g. both
|
|
381
|
+
// `View --> Model` and `Model ..> View` — every relationship's connection
|
|
382
|
+
// point defaults to the exact same box-center column. Left alone, that
|
|
383
|
+
// means their lines, arrowheads, and labels all land on the same cells:
|
|
384
|
+
// whichever relationship draws last silently overwrites the other's, so
|
|
385
|
+
// it appears to vanish from the ASCII output entirely (issue #448). Give
|
|
386
|
+
// each relationship in such a group its own column, spread symmetrically
|
|
387
|
+
// around the box center, so their routes never start from the same point.
|
|
388
|
+
// Spacing is sized to the widest label in the group (not a fixed
|
|
389
|
+
// constant) so long labels still clear each other horizontally.
|
|
390
|
+
//
|
|
391
|
+
// Computed before positions are assigned (it depends only on the
|
|
392
|
+
// relationship list) because the column reservation below needs to know
|
|
393
|
+
// how far each group fans out from its boxes' centers.
|
|
394
|
+
const relColumnOffset = new Map<number, number>()
|
|
395
|
+
/** Distance between a group's outermost lanes, keyed by member — see `anchorOffset`. */
|
|
396
|
+
const relGroupSpread = new Map<number, number>()
|
|
397
|
+
{
|
|
398
|
+
const pairGroups = new Map<string, number[]>()
|
|
399
|
+
diagram.relationships.forEach((rel, i) => {
|
|
400
|
+
const pairKey = [rel.from, rel.to].sort().join('::')
|
|
401
|
+
const group = pairGroups.get(pairKey) ?? []
|
|
402
|
+
group.push(i)
|
|
403
|
+
pairGroups.set(pairKey, group)
|
|
404
|
+
})
|
|
405
|
+
for (const group of pairGroups.values()) {
|
|
406
|
+
if (group.length < 2) continue
|
|
407
|
+
const n = group.length
|
|
408
|
+
const widestLabel = Math.max(
|
|
409
|
+
...group.map((i) => {
|
|
410
|
+
const label = diagram.relationships[i]!.label
|
|
411
|
+
if (!label) return 3 // room for just a line/arrow
|
|
412
|
+
return labelCellWidth(label)
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
415
|
+
const step = widestLabel + 1 // +1 for a visual gap between labels
|
|
416
|
+
group.forEach((relIndex, pos) => {
|
|
417
|
+
relColumnOffset.set(relIndex, Math.round((pos - (n - 1) / 2) * step))
|
|
418
|
+
relGroupSpread.set(relIndex, (n - 1) * step)
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Fan relationships that converge on the same target (or diverge from the
|
|
424
|
+
// same source) even when they come from/go to otherwise-unrelated
|
|
425
|
+
// classes — e.g. `Teacher --> Course` and `Student --> Course`, two
|
|
426
|
+
// distinct pairs that both terminate at Course. `pairGroups` above only
|
|
427
|
+
// catches multiple relationships between the exact same two classes;
|
|
428
|
+
// nothing previously separated these, so both anchored at Course's exact
|
|
429
|
+
// center column. That collapsed their lines and arrowheads onto the same
|
|
430
|
+
// cells, and — since a relationship's only horizontal jog can land on a
|
|
431
|
+
// single shared row when the two source rows/box heights are equal —
|
|
432
|
+
// let one relationship's label (drawn in a later pass, unaware of the
|
|
433
|
+
// collision) blot out the entire visible portion of the *other*
|
|
434
|
+
// relationship's connector, leaving it looking like it stops short of
|
|
435
|
+
// its target instead of merely overlapping (issue #632).
|
|
436
|
+
//
|
|
437
|
+
// Scoped to the shared end only — the far end keeps its own center
|
|
438
|
+
// column — since, unlike a duplicate pair, the *other* end of each
|
|
439
|
+
// relationship is a different, unrelated class that needs no fanning of
|
|
440
|
+
// its own. A relationship already offset by `pairGroups` above keeps
|
|
441
|
+
// that symmetric (both-ends) offset unchanged; only relationships with
|
|
442
|
+
// no pair-based offset get fanned here.
|
|
443
|
+
//
|
|
444
|
+
// Further scoped to relationships whose target sits exactly one level
|
|
445
|
+
// below their source (`level.get(to) - level.get(from) === 1`) — a
|
|
446
|
+
// direct, adjacent-level convergence/divergence like this one. A
|
|
447
|
+
// relationship that instead skips a level (e.g. `A --> C` when `B` sits
|
|
448
|
+
// between them at `A --> B --> C`) already needs the existing
|
|
449
|
+
// box-collision detour routing below (`findClearColumn`) to route around
|
|
450
|
+
// the intervening class; fanning its anchor here too would fight that
|
|
451
|
+
// routing over the same geometry it depends on, corrupting it. Excluding
|
|
452
|
+
// skip-level relationships from the group entirely — rather than just
|
|
453
|
+
// from getting an offset — also keeps a same-level sibling's own offset
|
|
454
|
+
// arithmetic (position-in-group, spread) based only on the other
|
|
455
|
+
// relationships it could actually collide with.
|
|
456
|
+
const relFromOffset = new Map<number, number>()
|
|
457
|
+
const relFromSpread = new Map<number, number>()
|
|
458
|
+
const relToOffset = new Map<number, number>()
|
|
459
|
+
const relToSpread = new Map<number, number>()
|
|
460
|
+
{
|
|
461
|
+
const fanBySharedEndpoint = (
|
|
462
|
+
endpointOf: (rel: (typeof diagram.relationships)[number]) => string,
|
|
463
|
+
offsetOut: Map<number, number>,
|
|
464
|
+
spreadOut: Map<number, number>,
|
|
465
|
+
): void => {
|
|
466
|
+
const groups = new Map<string, number[]>()
|
|
467
|
+
diagram.relationships.forEach((rel, i) => {
|
|
468
|
+
if (relColumnOffset.has(i)) return // already fanned as a duplicate pair
|
|
469
|
+
if (!classById.has(rel.from) || !classById.has(rel.to)) return
|
|
470
|
+
const fromLevel = level.get(rel.from) ?? 0
|
|
471
|
+
const toLevel = level.get(rel.to) ?? 0
|
|
472
|
+
if (toLevel - fromLevel !== 1) return // skip-level — leave to detour routing
|
|
473
|
+
const key = endpointOf(rel)
|
|
474
|
+
const group = groups.get(key) ?? []
|
|
475
|
+
group.push(i)
|
|
476
|
+
groups.set(key, group)
|
|
477
|
+
})
|
|
478
|
+
for (const group of groups.values()) {
|
|
479
|
+
if (group.length < 2) continue
|
|
480
|
+
const n = group.length
|
|
481
|
+
const widestLabel = Math.max(
|
|
482
|
+
...group.map((i) => {
|
|
483
|
+
const label = diagram.relationships[i]!.label
|
|
484
|
+
if (!label) return 3 // room for just a line/arrow
|
|
485
|
+
return labelCellWidth(label)
|
|
486
|
+
}),
|
|
487
|
+
)
|
|
488
|
+
const step = widestLabel + 1 // +1 for a visual gap between labels
|
|
489
|
+
group.forEach((relIndex, pos) => {
|
|
490
|
+
offsetOut.set(relIndex, Math.round((pos - (n - 1) / 2) * step))
|
|
491
|
+
spreadOut.set(relIndex, (n - 1) * step)
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
fanBySharedEndpoint((rel) => rel.to, relToOffset, relToSpread)
|
|
496
|
+
fanBySharedEndpoint((rel) => rel.from, relFromOffset, relFromSpread)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- Reserve column room for relationship labels and fanned-out groups ---
|
|
500
|
+
// A class's horizontal slot used to be exactly its box's content width, so
|
|
501
|
+
// a narrow box (a single-letter class with no members) whose relationship
|
|
502
|
+
// carries a long label had nowhere to put that label: neighbouring labels
|
|
503
|
+
// fought over the same cells and the territory pass below truncated them
|
|
504
|
+
// all to `…` even though the diagram could simply have spread the columns
|
|
505
|
+
// further apart (issue #488). The same shortfall broke multi-relationship
|
|
506
|
+
// groups: the per-pair offsets above can fan out well past a narrow box's
|
|
507
|
+
// edges, and clamping them back inside the box collapsed distinct
|
|
508
|
+
// relationships onto one connection point (issue #489).
|
|
509
|
+
//
|
|
510
|
+
// So each class's column *slot* is its box plus whatever its
|
|
511
|
+
// relationships overhang past the box's edges: for every relationship,
|
|
512
|
+
// the label (or bare line) centered on that relationship's lane reaches
|
|
513
|
+
// some distance left and right of the box's center column, and the slot
|
|
514
|
+
// is padded by however much of that reach falls outside the box. The box
|
|
515
|
+
// itself keeps its content width; only the gap around it grows, and only
|
|
516
|
+
// on the side and by the amount a label or group actually needs — a
|
|
517
|
+
// right-side overhang on the last class of a level, say, moves nothing.
|
|
518
|
+
// Every relationship reserves at *both* of its endpoints so a vertical
|
|
519
|
+
// pair (A above B) is padded identically and B stays directly under A.
|
|
520
|
+
const columnReach = new Map<string, { left: number; right: number }>()
|
|
521
|
+
for (const cls of diagram.classes) {
|
|
522
|
+
columnReach.set(cls.id, { left: 0, right: 0 })
|
|
523
|
+
}
|
|
524
|
+
diagram.relationships.forEach((rel, relIndex) => {
|
|
525
|
+
if (!classById.has(rel.from) || !classById.has(rel.to)) return
|
|
526
|
+
// Mirror the draw pass: a label of `cellW` cells centered on its lane
|
|
527
|
+
// starts `floor(cellW / 2)` cells left of the lane; a bare line/arrow
|
|
528
|
+
// is a single cell on the lane itself. Each endpoint reserves room
|
|
529
|
+
// using its own offset — a duplicate-pair member's offset is the same
|
|
530
|
+
// at both ends, but a relationship fanned only at its shared target (or
|
|
531
|
+
// source) — see `relFromOffset`/`relToOffset` above — must reserve
|
|
532
|
+
// differently at each end, matching where its lane actually sits there.
|
|
533
|
+
const cellW = rel.label ? labelCellWidth(rel.label) : 1
|
|
534
|
+
const fromOffset =
|
|
535
|
+
relColumnOffset.get(relIndex) ?? relFromOffset.get(relIndex) ?? 0
|
|
536
|
+
const toOffset =
|
|
537
|
+
relColumnOffset.get(relIndex) ?? relToOffset.get(relIndex) ?? 0
|
|
538
|
+
const fromReach = columnReach.get(rel.from)!
|
|
539
|
+
fromReach.left = Math.max(
|
|
540
|
+
fromReach.left,
|
|
541
|
+
Math.floor(cellW / 2) - fromOffset,
|
|
542
|
+
)
|
|
543
|
+
fromReach.right = Math.max(
|
|
544
|
+
fromReach.right,
|
|
545
|
+
fromOffset + cellW - 1 - Math.floor(cellW / 2),
|
|
546
|
+
)
|
|
547
|
+
const toReach = columnReach.get(rel.to)!
|
|
548
|
+
toReach.left = Math.max(toReach.left, Math.floor(cellW / 2) - toOffset)
|
|
549
|
+
toReach.right = Math.max(
|
|
550
|
+
toReach.right,
|
|
551
|
+
toOffset + cellW - 1 - Math.floor(cellW / 2),
|
|
552
|
+
)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// Compute positions: each level is a row, classes in a row are spaced horizontally
|
|
556
|
+
const placed = new Map<string, PlacedClass>()
|
|
557
|
+
let currentY = 0
|
|
558
|
+
// Right edge of the widest level's last slot — a slot can be wider than
|
|
559
|
+
// its box, so the canvas has to cover the slot, not just the box.
|
|
560
|
+
let maxSlotEnd = 0
|
|
561
|
+
|
|
562
|
+
for (let lv = 0; lv <= maxLevel; lv++) {
|
|
563
|
+
const group = levelGroups[lv]!
|
|
564
|
+
if (group.length === 0) continue
|
|
565
|
+
|
|
566
|
+
// --- #970's block-based x-assignment (issues #971, #972) ---
|
|
567
|
+
// docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md.
|
|
568
|
+
// Alignment blocks group classes at this level sharing the exact same
|
|
569
|
+
// parent-id set, processed as a unit. A block's *qualifying* parents
|
|
570
|
+
// are the subset strictly shallower than this level — a parent at the
|
|
571
|
+
// same level (a rootless relationship cycle, whose members still
|
|
572
|
+
// record each other as "parents" in `parents` even though nothing
|
|
573
|
+
// above them ever resolves) never qualifies. A block with zero
|
|
574
|
+
// qualifying parents has "no resolvable parents" and every member
|
|
575
|
+
// keeps its plain left-to-right baseline position, computed
|
|
576
|
+
// individually (not as a joint unit — #971's exact per-class
|
|
577
|
+
// behavior, preserved verbatim for this shape, including when a
|
|
578
|
+
// no-resolvable-parents block's members happen not to be declaration-
|
|
579
|
+
// adjacent). A block with at least one qualifying parent computes a
|
|
580
|
+
// real desired center: the mean of its qualifying parents' own,
|
|
581
|
+
// already-placed box centers (a single qualifying parent's mean is
|
|
582
|
+
// just that parent's center — the #964 repro exactly, #971's case; a
|
|
583
|
+
// block of more than one class sharing that parent set spreads evenly
|
|
584
|
+
// by centering the block's *total* width on that shared center, #972's
|
|
585
|
+
// "multiple children of one parent" case; more than one qualifying
|
|
586
|
+
// parent averages their centers, #972's "converging edges" case).
|
|
587
|
+
// Either way, the block's desired *left* edge (its slot-space left,
|
|
588
|
+
// matching `currentX`'s role below) is `desiredCenter -
|
|
589
|
+
// floor(totalWidth / 2)` — floor chosen only for determinism. A single
|
|
590
|
+
// left-to-right compaction pass then resolves any collision between
|
|
591
|
+
// *any* two blocks' desired positions — universal across every block
|
|
592
|
+
// shape, not just multi-parent ones, since two independently-aligned
|
|
593
|
+
// singleton blocks can still want overlapping positions (#971) — by
|
|
594
|
+
// only ever pushing a block right of its desired position, never left,
|
|
595
|
+
// and never reordering blocks relative to each other.
|
|
596
|
+
//
|
|
597
|
+
// A class with no parents at all is always its own singleton block —
|
|
598
|
+
// two unrelated roots must never be merged into one block just because
|
|
599
|
+
// both happen to have an empty parent set; there is nothing shared to
|
|
600
|
+
// align them around.
|
|
601
|
+
const blockKeyOf = (id: string): string => {
|
|
602
|
+
const pset = parents.get(id)
|
|
603
|
+
return pset && pset.size > 0 ? [...pset].sort().join(',') : `root:${id}`
|
|
604
|
+
}
|
|
605
|
+
// Every member sharing a given block key, regardless of adjacency in
|
|
606
|
+
// `group` — looked up once a block is actually confirmed to have a
|
|
607
|
+
// shared center to align around (see the main loop below). Building
|
|
608
|
+
// this eagerly for every id up front, and processing every block's
|
|
609
|
+
// *entire* membership together the moment its first (in declaration
|
|
610
|
+
// order) member is reached — even for a block with no resolvable
|
|
611
|
+
// parents — used to silently reorder an unrelated class that was
|
|
612
|
+
// declared between two same-parent-set siblings ahead of it, moving it
|
|
613
|
+
// visually later than its own declaration position (caught in review
|
|
614
|
+
// on #972). The main loop below only merges members into one placement
|
|
615
|
+
// unit for a block that actually resolves to a shared center; a
|
|
616
|
+
// no-resolvable-parents "block" is never merged, so it can't reorder
|
|
617
|
+
// anything.
|
|
618
|
+
const blockMembers = new Map<string, string[]>()
|
|
619
|
+
for (const id of group) {
|
|
620
|
+
const key = blockKeyOf(id)
|
|
621
|
+
let members = blockMembers.get(key)
|
|
622
|
+
if (!members) {
|
|
623
|
+
members = []
|
|
624
|
+
blockMembers.set(key, members)
|
|
625
|
+
}
|
|
626
|
+
members.push(id)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Baseline: today's plain left-to-right slot position for every class
|
|
630
|
+
// in this level, computed exactly as the pre-#971 algorithm did — the
|
|
631
|
+
// fallback position for a no-resolvable-parents block's members, and
|
|
632
|
+
// the starting point the compaction step below can only ever push
|
|
633
|
+
// right of, never left.
|
|
634
|
+
const baselineSlotLeft = new Map<string, number>()
|
|
635
|
+
const slotWidthOf = new Map<string, number>()
|
|
636
|
+
const leftPadOf = new Map<string, number>()
|
|
637
|
+
{
|
|
638
|
+
let x = 0
|
|
639
|
+
for (const id of group) {
|
|
640
|
+
const w = classBoxW.get(id)!
|
|
641
|
+
// The box's center column sits `floor(w / 2)` cells from its left
|
|
642
|
+
// edge (the same arithmetic `connectionColumns` uses), so anything
|
|
643
|
+
// reaching further than that past the center overhangs the box.
|
|
644
|
+
const reach = columnReach.get(id)!
|
|
645
|
+
const leftPad = Math.max(0, reach.left - Math.floor(w / 2))
|
|
646
|
+
const rightPad = Math.max(0, reach.right - (w - 1 - Math.floor(w / 2)))
|
|
647
|
+
baselineSlotLeft.set(id, x)
|
|
648
|
+
slotWidthOf.set(id, leftPad + w + rightPad)
|
|
649
|
+
leftPadOf.set(id, leftPad)
|
|
650
|
+
x += leftPad + w + rightPad + hGap
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let currentX = 0
|
|
655
|
+
let maxH = 0
|
|
656
|
+
|
|
657
|
+
/** Places one class's box at slot-left `slotLeft`, advancing `currentX`/`maxH`/`maxSlotEnd`. */
|
|
658
|
+
const placeAt = (id: string, slotLeft: number): void => {
|
|
659
|
+
const cls = classById.get(id)!
|
|
660
|
+
const w = classBoxW.get(id)!
|
|
661
|
+
const h = classBoxH.get(id)!
|
|
662
|
+
const leftPad = leftPadOf.get(id)!
|
|
663
|
+
const slotWidth = slotWidthOf.get(id)!
|
|
664
|
+
placed.set(id, {
|
|
665
|
+
cls,
|
|
666
|
+
sections: classSections.get(id)!,
|
|
667
|
+
// Shifted right only by the left overhang, so a box with nothing
|
|
668
|
+
// reaching past its left edge lands exactly where its slot does.
|
|
669
|
+
x: slotLeft + leftPad,
|
|
670
|
+
y: currentY,
|
|
671
|
+
width: w,
|
|
672
|
+
height: h,
|
|
673
|
+
})
|
|
674
|
+
maxSlotEnd = Math.max(maxSlotEnd, slotLeft + slotWidth)
|
|
675
|
+
currentX = slotLeft + slotWidth + hGap
|
|
676
|
+
maxH = Math.max(maxH, h)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const placedThisLevel = new Set<string>()
|
|
680
|
+
for (const id of group) {
|
|
681
|
+
// Already placed as part of an earlier qualifying block's joint
|
|
682
|
+
// placement below (never true for a no-resolvable-parents id, which
|
|
683
|
+
// is always placed the moment the loop reaches it, in its own true
|
|
684
|
+
// declaration-order turn).
|
|
685
|
+
if (placedThisLevel.has(id)) continue
|
|
686
|
+
|
|
687
|
+
const key = blockKeyOf(id)
|
|
688
|
+
const pset = parents.get(id)
|
|
689
|
+
// `level.get(pid)!`: every relationship endpoint is guaranteed a
|
|
690
|
+
// `classById`/`level` entry (the parser's `ensureClass` auto-creates
|
|
691
|
+
// an implicit class for any id used only as a relationship endpoint,
|
|
692
|
+
// never just a dangling reference — see `classById.has(...)`'s own
|
|
693
|
+
// guards elsewhere in this file, which protect a *different* case),
|
|
694
|
+
// so this is never actually missing.
|
|
695
|
+
const qualifying = pset
|
|
696
|
+
? [...pset].filter((pid) => level.get(pid)! < lv)
|
|
697
|
+
: []
|
|
698
|
+
|
|
699
|
+
if (qualifying.length === 0) {
|
|
700
|
+
// No resolvable parents: this id has no shared center to align
|
|
701
|
+
// around, so it is never merged into a joint block with the other
|
|
702
|
+
// members `blockKeyOf` groups it with — only *this* id is placed
|
|
703
|
+
// here, in its own true position in `group`'s declaration order,
|
|
704
|
+
// exactly matching #971's per-class behavior for this shape. Any
|
|
705
|
+
// other same-key member is placed independently, on its own later
|
|
706
|
+
// turn through this same loop — merging them together here would
|
|
707
|
+
// reorder whatever was declared between them ahead of its own
|
|
708
|
+
// declaration position (issue caught in #972's review).
|
|
709
|
+
const slotLeft = Math.max(baselineSlotLeft.get(id)!, currentX, 0)
|
|
710
|
+
placeAt(id, slotLeft)
|
|
711
|
+
placedThisLevel.add(id)
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// A block with a real shared center to align around *is* placed as
|
|
716
|
+
// a joint unit, gathering every member `blockKeyOf` groups with it
|
|
717
|
+
// regardless of adjacency in `group` — the whole reason to group
|
|
718
|
+
// same-parent-set siblings together in the first place.
|
|
719
|
+
const members = blockMembers.get(key)!
|
|
720
|
+
|
|
721
|
+
// `placed.get(...)!`: every id in `qualifying` has a strictly-
|
|
722
|
+
// shallower level than `lv`, and this loop places every class of a
|
|
723
|
+
// level before moving to the next, so a strictly-shallower class is
|
|
724
|
+
// always already placed by the time this level is processed.
|
|
725
|
+
// "Center column" is always a box's own rendered center, never the
|
|
726
|
+
// reach-padded slot's — aligning to the slot center instead would
|
|
727
|
+
// visually misalign the boxes whenever a relationship's label
|
|
728
|
+
// overhang is asymmetric.
|
|
729
|
+
const parentCenters = qualifying.map((pid) => {
|
|
730
|
+
const p = placed.get(pid)!
|
|
731
|
+
return p.x + Math.floor(p.width / 2)
|
|
732
|
+
})
|
|
733
|
+
const desiredCenter = Math.round(
|
|
734
|
+
parentCenters.reduce((sum, c) => sum + c, 0) / parentCenters.length,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
let totalWidth = 0
|
|
738
|
+
for (let i = 0; i < members.length; i++) {
|
|
739
|
+
totalWidth += slotWidthOf.get(members[i]!)!
|
|
740
|
+
if (i < members.length - 1) totalWidth += hGap
|
|
741
|
+
}
|
|
742
|
+
// `totalWidth` is the *slot* span (every member's own reach padding
|
|
743
|
+
// included at both ends), but the thing that must visually center on
|
|
744
|
+
// `desiredCenter` is the *rendered box* span — from the first
|
|
745
|
+
// member's own box left edge to the last member's own box right
|
|
746
|
+
// edge, which excludes the outermost reach padding at each end (that
|
|
747
|
+
// padding only ever separates a box from a *neighbor*; at the
|
|
748
|
+
// block's own two ends there is no neighbor to separate from, so it
|
|
749
|
+
// just becomes asymmetric dead space that would otherwise skew the
|
|
750
|
+
// visible boxes off `desiredCenter` whenever the first member's
|
|
751
|
+
// leftPad differs from the last member's rightPad — e.g. one child
|
|
752
|
+
// has a long incoming label reserving room on its own left, the
|
|
753
|
+
// other has none). Subtracting them back out here, once, is cheaper
|
|
754
|
+
// than threading a separate "visible width" accumulator through the
|
|
755
|
+
// loop above (caught in #972's review).
|
|
756
|
+
const firstLeftPad = leftPadOf.get(members[0]!)!
|
|
757
|
+
const lastMember = members[members.length - 1]!
|
|
758
|
+
const lastRightPad =
|
|
759
|
+
slotWidthOf.get(lastMember)! -
|
|
760
|
+
leftPadOf.get(lastMember)! -
|
|
761
|
+
classBoxW.get(lastMember)!
|
|
762
|
+
const visibleWidth = totalWidth - firstLeftPad - lastRightPad
|
|
763
|
+
const blockDesiredLeft =
|
|
764
|
+
desiredCenter - firstLeftPad - Math.floor(visibleWidth / 2)
|
|
765
|
+
const blockActualLeft = Math.max(blockDesiredLeft, currentX, 0)
|
|
766
|
+
|
|
767
|
+
let memberX = blockActualLeft
|
|
768
|
+
for (const memberId of members) {
|
|
769
|
+
placeAt(memberId, memberX)
|
|
770
|
+
placedThisLevel.add(memberId)
|
|
771
|
+
memberX = currentX
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
currentY += maxH + vGap
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// --- Create canvas ---
|
|
779
|
+
let totalW = maxSlotEnd
|
|
780
|
+
let totalH = 0
|
|
781
|
+
for (const p of placed.values()) {
|
|
782
|
+
totalW = Math.max(totalW, p.x + p.width)
|
|
783
|
+
totalH = Math.max(totalH, p.y + p.height)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Extra space for relationship lines that may go below/beside
|
|
787
|
+
totalW += 4
|
|
788
|
+
totalH += 2
|
|
789
|
+
|
|
790
|
+
const canvas = mkCanvas(totalW - 1, totalH - 1)
|
|
791
|
+
const rc = mkRoleCanvas(totalW - 1, totalH - 1)
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Set a character on the canvas and track its role.
|
|
795
|
+
* Delegates bounds-checking to the shared `write()` primitive
|
|
796
|
+
* (src/ascii/canvas.ts) instead of duplicating the guard here — see
|
|
797
|
+
* issue #171.
|
|
798
|
+
*/
|
|
799
|
+
function setC(x: number, y: number, ch: string, role: CharRole): void {
|
|
800
|
+
write(canvas, x, y, ch, { role, roleCanvas: rc })
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Opt-in OSC 8 hyperlinks (see hyperlinks.ts): the link canvas is sized to
|
|
804
|
+
// the main canvas, so a cell clipped by the `cx < totalW` guard below is
|
|
805
|
+
// clipped here too.
|
|
806
|
+
const linkCanvas: LinkCanvas | undefined = options.hyperlinks
|
|
807
|
+
? mkLinkCanvas(totalW - 1, totalH - 1)
|
|
808
|
+
: undefined
|
|
809
|
+
|
|
810
|
+
// --- Draw class boxes ---
|
|
811
|
+
for (const p of placed.values()) {
|
|
812
|
+
const boxCanvas = drawMultiBox(
|
|
813
|
+
p.sections,
|
|
814
|
+
useAscii,
|
|
815
|
+
config.boxBorderPadding,
|
|
816
|
+
)
|
|
817
|
+
// Copy box onto main canvas at (p.x, p.y) with role tracking
|
|
818
|
+
for (let bx = 0; bx < boxCanvas.length; bx++) {
|
|
819
|
+
for (let by = 0; by < boxCanvas[0]!.length; by++) {
|
|
820
|
+
const ch = boxCanvas[bx]![by]!
|
|
821
|
+
if (ch !== ' ') {
|
|
822
|
+
const cx = p.x + bx
|
|
823
|
+
const cy = p.y + by
|
|
824
|
+
if (cx < totalW && cy < totalH) {
|
|
825
|
+
setC(cx, cy, ch, classifyBoxChar(ch))
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// A `click` href links the class-name line(s) of the header section —
|
|
832
|
+
// box row 1 is the first header line, which is the `<<annotation>>`
|
|
833
|
+
// when there is one, so the name starts on the row after it. The SVG
|
|
834
|
+
// renderer wraps the whole class box in an <a>; in a terminal, the name
|
|
835
|
+
// is the natural analog of "the label".
|
|
836
|
+
if (linkCanvas) {
|
|
837
|
+
const href = safeHref(diagram.interactions.get(p.cls.id)?.href)
|
|
838
|
+
const header = p.sections[0]
|
|
839
|
+
if (href !== undefined && header !== undefined) {
|
|
840
|
+
const nameRowStart = 1 + (p.cls.annotation ? 1 : 0)
|
|
841
|
+
markBoxLabelLinks(linkCanvas, boxCanvas, { x: p.x, y: p.y }, href, {
|
|
842
|
+
from: nameRowStart,
|
|
843
|
+
to: header.length,
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// --- Snapshot cells occupied by class boxes ---
|
|
850
|
+
// Taken once, right after boxes are drawn and before any relationship
|
|
851
|
+
// line, corner, marker, or label is drawn — the class-diagram analog of
|
|
852
|
+
// the `boxCells`/`setCGuarded` guard er-diagram.ts gained for issue #350.
|
|
853
|
+
// Two independent routing branches rely on it:
|
|
854
|
+
// - The cross-level ("target below"/"target above") branches, whose
|
|
855
|
+
// routing can jog a long way horizontally with no prior occupancy
|
|
856
|
+
// check — e.g. connecting a level-1 child to a level-0 parent whose
|
|
857
|
+
// column sits far from an intervening, taller same-level sibling's
|
|
858
|
+
// box, which the jog then cuts straight through.
|
|
859
|
+
// - The same-level branch's own detour (this file's final `else`), which
|
|
860
|
+
// primarily avoids a same-row obstruction by computing its detour row
|
|
861
|
+
// from this same box geometry (see `obstructionBottom` there); this
|
|
862
|
+
// snapshot is the last-resort backstop for whatever that routing
|
|
863
|
+
// doesn't anticipate.
|
|
864
|
+
const boxCells = new Set<string>()
|
|
865
|
+
for (const p of placed.values()) {
|
|
866
|
+
for (let by = 0; by < p.height; by++) {
|
|
867
|
+
for (let bx = 0; bx < p.width; bx++) {
|
|
868
|
+
boxCells.add(`${p.x + bx},${p.y + by}`)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Like setC, but refuses to draw into a cell already occupied by a class
|
|
875
|
+
* box (see boxCells above). Used by both the cross-level relationship
|
|
876
|
+
* branches (where a mis-routed jog can cut straight through an
|
|
877
|
+
* unrelated, taller same-level box) and the same-level branch's own
|
|
878
|
+
* detour (a last-resort backstop for whatever its obstruction-aware
|
|
879
|
+
* routing doesn't anticipate — see `obstructionBottom` below), so either
|
|
880
|
+
* kind of mis-routed segment degrades to a gap in the line rather than
|
|
881
|
+
* corrupting a box's border or attribute/method text.
|
|
882
|
+
*/
|
|
883
|
+
function setCGuarded(x: number, y: number, ch: string, role: CharRole): void {
|
|
884
|
+
if (boxCells.has(`${x},${y}`)) return
|
|
885
|
+
setC(x, y, ch, role)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Check if a point (x, y) is inside any class box */
|
|
889
|
+
function isInsideBox(
|
|
890
|
+
x: number,
|
|
891
|
+
y: number,
|
|
892
|
+
excludeIds?: Set<string>,
|
|
893
|
+
): boolean {
|
|
894
|
+
for (const [id, p] of placed.entries()) {
|
|
895
|
+
if (excludeIds?.has(id)) continue
|
|
896
|
+
if (
|
|
897
|
+
x >= p.x &&
|
|
898
|
+
x <= p.x + p.width - 1 &&
|
|
899
|
+
y >= p.y &&
|
|
900
|
+
y <= p.y + p.height - 1
|
|
901
|
+
) {
|
|
902
|
+
return true
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return false
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Find a clear vertical column for routing that doesn't pass through any boxes */
|
|
909
|
+
function findClearColumn(
|
|
910
|
+
startX: number,
|
|
911
|
+
y1: number,
|
|
912
|
+
y2: number,
|
|
913
|
+
excludeIds: Set<string>,
|
|
914
|
+
): number {
|
|
915
|
+
const columnIsClear = (x: number): boolean => {
|
|
916
|
+
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
|
917
|
+
if (isInsideBox(x, y, excludeIds)) return false
|
|
918
|
+
}
|
|
919
|
+
return true
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Scan outward from `startX`, right before left at each distance, never
|
|
923
|
+
// off the left edge of the canvas. The right bound reaches far enough
|
|
924
|
+
// past the widest box that a column clear of every box is always found
|
|
925
|
+
// in practice — but the caller-side fallback below stays as a backstop
|
|
926
|
+
// rather than being asserted away.
|
|
927
|
+
return (
|
|
928
|
+
findFreeLane(startX, 0, startX + totalW + 9, columnIsClear) ?? totalW + 2
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// --- Draw relationship lines ---
|
|
933
|
+
const H = useAscii ? '-' : '─'
|
|
934
|
+
const V = useAscii ? '|' : '│'
|
|
935
|
+
const dashH = useAscii ? '.' : '╌'
|
|
936
|
+
const dashV = useAscii ? ':' : '┊'
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Offset from a box's center column to the *anchor* where one
|
|
940
|
+
* relationship's line touches that box's border — always within the
|
|
941
|
+
* box's own width, so a line visibly leaves from (or arrives at) the box
|
|
942
|
+
* rather than from empty space beside it. This is the only place the
|
|
943
|
+
* per-pair offset is reined in: the *lane* the line then runs along
|
|
944
|
+
* keeps the full offset (see `connectionColumns`), which the column
|
|
945
|
+
* reservation in the layout pass guarantees room for.
|
|
946
|
+
*
|
|
947
|
+
* A group whose lanes fan out wider than the box has its anchors spread
|
|
948
|
+
* evenly across the box's full width (corner columns included) by
|
|
949
|
+
* scaling the whole group down by one factor, so as many relationships
|
|
950
|
+
* as the box has columns keep a distinct border cell — and so a distinct
|
|
951
|
+
* arrowhead. Clamping each lane on its own instead collapsed every outer
|
|
952
|
+
* lane onto the same edge column, which is exactly how #489's four
|
|
953
|
+
* relationships ended up sharing two connection points. Only a group
|
|
954
|
+
* with more members than the box has columns still has to share.
|
|
955
|
+
*/
|
|
956
|
+
function anchorOffset(
|
|
957
|
+
offset: number,
|
|
958
|
+
spread: number,
|
|
959
|
+
boxWidth: number,
|
|
960
|
+
): number {
|
|
961
|
+
// Columns available on each side of the center — the center itself is
|
|
962
|
+
// `floor(boxWidth / 2)` in from the left edge, matching `connectionColumns`.
|
|
963
|
+
const left = Math.floor(boxWidth / 2)
|
|
964
|
+
const right = boxWidth - 1 - left
|
|
965
|
+
const scale = spread > left + right ? (left + right) / spread : 1
|
|
966
|
+
return Math.max(-left, Math.min(right, Math.round(offset * scale)))
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Connection columns for one relationship, shifted by its per-pair offset.
|
|
971
|
+
*
|
|
972
|
+
* `fromCX`/`toCX` are the *lanes*: the columns the line's vertical run,
|
|
973
|
+
* arrowhead-side jog, and label all sit on. They carry the full offset,
|
|
974
|
+
* so relationships in a group never share a lane. Every pass that
|
|
975
|
+
* positions something relative to a relationship's route — the
|
|
976
|
+
* line-drawing loop, the label-territory precompute, and the
|
|
977
|
+
* label-drawing pass — must go through this one helper and use these
|
|
978
|
+
* lanes, so the label ends up on the same column the line actually runs
|
|
979
|
+
* along. When only the line pass applied the offset (an earlier version
|
|
980
|
+
* of this fix), a reciprocal pair's labels were still measured and drawn
|
|
981
|
+
* against the shared box center: the territory pass saw two labels with
|
|
982
|
+
* identical midpoints and split the column between them, truncating both
|
|
983
|
+
* to a single mashed `rea……ies` while their lines sat on separate columns
|
|
984
|
+
* (issue #448).
|
|
985
|
+
*
|
|
986
|
+
* `fromAnchorX`/`toAnchorX` are where the line meets each box border —
|
|
987
|
+
* the lane pulled back inside the box (see `anchorOffset`). When a group
|
|
988
|
+
* fans out wider than a narrow box (issue #489: four relationships
|
|
989
|
+
* between two single-letter classes), a lane can sit well outside the
|
|
990
|
+
* box; the line then jogs horizontally along the row adjacent to the box
|
|
991
|
+
* from the anchor out to its lane (see `drawJog`) instead of the lanes
|
|
992
|
+
* being collapsed onto the few columns the box has, which made distinct
|
|
993
|
+
* relationships overwrite one another. Relationships may end up sharing
|
|
994
|
+
* an anchor when a group outnumbers the box's columns — that's fine,
|
|
995
|
+
* their jogs merge into a small trunk at the border — but never a lane.
|
|
996
|
+
* Labels always use the lane, never the anchor: only the line's
|
|
997
|
+
* box-border touchpoint needs pulling back inside the box.
|
|
998
|
+
*
|
|
999
|
+
* The offset applied at each end is resolved independently: a
|
|
1000
|
+
* relationship in a duplicate-pair group (`relColumnOffset`) uses that
|
|
1001
|
+
* same offset at both ends, as before, but one that only shares a single
|
|
1002
|
+
* endpoint with other relationships (`relFromOffset`/`relToOffset` — see
|
|
1003
|
+
* their doc comment above) is fanned at that shared end only, leaving its
|
|
1004
|
+
* other, unrelated end anchored on its own box center (issue #632).
|
|
1005
|
+
*/
|
|
1006
|
+
function connectionColumns(
|
|
1007
|
+
relIndex: number,
|
|
1008
|
+
fromP: PlacedClass,
|
|
1009
|
+
toP: PlacedClass,
|
|
1010
|
+
): {
|
|
1011
|
+
fromCX: number
|
|
1012
|
+
toCX: number
|
|
1013
|
+
fromAnchorX: number
|
|
1014
|
+
toAnchorX: number
|
|
1015
|
+
} {
|
|
1016
|
+
const fromOffset =
|
|
1017
|
+
relColumnOffset.get(relIndex) ?? relFromOffset.get(relIndex) ?? 0
|
|
1018
|
+
const toOffset =
|
|
1019
|
+
relColumnOffset.get(relIndex) ?? relToOffset.get(relIndex) ?? 0
|
|
1020
|
+
const fromSpread =
|
|
1021
|
+
relGroupSpread.get(relIndex) ?? relFromSpread.get(relIndex) ?? 0
|
|
1022
|
+
const toSpread =
|
|
1023
|
+
relGroupSpread.get(relIndex) ?? relToSpread.get(relIndex) ?? 0
|
|
1024
|
+
const fromCenter = fromP.x + Math.floor(fromP.width / 2)
|
|
1025
|
+
const toCenter = toP.x + Math.floor(toP.width / 2)
|
|
1026
|
+
return {
|
|
1027
|
+
fromCX: fromCenter + fromOffset,
|
|
1028
|
+
toCX: toCenter + toOffset,
|
|
1029
|
+
fromAnchorX:
|
|
1030
|
+
fromCenter + anchorOffset(fromOffset, fromSpread, fromP.width),
|
|
1031
|
+
toAnchorX: toCenter + anchorOffset(toOffset, toSpread, toP.width),
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Write one cell of a jog, merging with whatever another relationship's
|
|
1037
|
+
* jog already drew there. Jogs of one group all run along the same row
|
|
1038
|
+
* next to the box, so they routinely overlap: two relationships sharing
|
|
1039
|
+
* an anchor stack their corners on one cell, and an outer relationship's
|
|
1040
|
+
* jog passes straight through an inner one's lane corner. Compositing
|
|
1041
|
+
* box-drawing glyphs (`─` over `┌` → `┬`, `┘` over `┘` → `┘`) turns those
|
|
1042
|
+
* overlaps into a readable trunk instead of the last write winning. A
|
|
1043
|
+
* marker glyph already placed by an earlier relationship is left alone —
|
|
1044
|
+
* an arrowhead at a shared anchor doubles as the junction.
|
|
1045
|
+
*/
|
|
1046
|
+
function setJogCell(x: number, y: number, ch: string, role: CharRole): void {
|
|
1047
|
+
if (boxCells.has(`${x},${y}`)) return
|
|
1048
|
+
if (rc[x]?.[y] === 'arrow') return
|
|
1049
|
+
const existing = canvas[x]?.[y]
|
|
1050
|
+
if (
|
|
1051
|
+
!useAscii &&
|
|
1052
|
+
existing !== undefined &&
|
|
1053
|
+
isJunctionChar(existing) &&
|
|
1054
|
+
isJunctionChar(ch)
|
|
1055
|
+
) {
|
|
1056
|
+
const merged = mergeJunctions(existing, ch)
|
|
1057
|
+
setC(x, y, merged, merged === ch ? role : 'junction')
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
setC(x, y, ch, role)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Draw the short horizontal jog that joins a relationship's anchor on a
|
|
1065
|
+
* box border to the lane its line runs along, on the row directly
|
|
1066
|
+
* adjacent to that border. `boxIsAbove` says which side of the row the
|
|
1067
|
+
* box sits on: the anchor's corner opens toward the box, the lane's
|
|
1068
|
+
* corner opens the other way, toward the vertical run. A relationship
|
|
1069
|
+
* whose lane already sits inside its box needs no jog at all, which keeps
|
|
1070
|
+
* every pre-existing (unfanned) layout byte-identical.
|
|
1071
|
+
*/
|
|
1072
|
+
function drawJog(
|
|
1073
|
+
row: number,
|
|
1074
|
+
anchorX: number,
|
|
1075
|
+
laneX: number,
|
|
1076
|
+
lineH: string,
|
|
1077
|
+
boxIsAbove: boolean,
|
|
1078
|
+
): void {
|
|
1079
|
+
if (anchorX === laneX) return
|
|
1080
|
+
const lx = Math.min(anchorX, laneX)
|
|
1081
|
+
const rx = Math.max(anchorX, laneX)
|
|
1082
|
+
// Interior cells only — the two ends are corners (or, in plain-ASCII
|
|
1083
|
+
// mode, the same line glyph), written separately so a second jog
|
|
1084
|
+
// sharing this anchor merges with the corner instead of flattening it.
|
|
1085
|
+
for (let x = lx + 1; x < rx; x++) {
|
|
1086
|
+
setJogCell(x, row, lineH, 'line')
|
|
1087
|
+
}
|
|
1088
|
+
if (useAscii) {
|
|
1089
|
+
setJogCell(anchorX, row, lineH, 'line')
|
|
1090
|
+
setJogCell(laneX, row, lineH, 'line')
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
const laneIsRight = laneX > anchorX
|
|
1094
|
+
const anchorCorner = boxIsAbove
|
|
1095
|
+
? laneIsRight
|
|
1096
|
+
? '└'
|
|
1097
|
+
: '┘'
|
|
1098
|
+
: laneIsRight
|
|
1099
|
+
? '┌'
|
|
1100
|
+
: '┐'
|
|
1101
|
+
const laneCorner = boxIsAbove
|
|
1102
|
+
? laneIsRight
|
|
1103
|
+
? '┐'
|
|
1104
|
+
: '┌'
|
|
1105
|
+
: laneIsRight
|
|
1106
|
+
? '┘'
|
|
1107
|
+
: '└'
|
|
1108
|
+
setJogCell(anchorX, row, anchorCorner, 'corner')
|
|
1109
|
+
setJogCell(laneX, row, laneCorner, 'corner')
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// A detoured relationship's actual routed path — the far column its
|
|
1113
|
+
// vertical trunk runs along, and the rows its horizontal jogs sit on —
|
|
1114
|
+
// keyed by relationship index. The line-drawing pass below is the only
|
|
1115
|
+
// place that decides whether (and where) a relationship detours, but the
|
|
1116
|
+
// label-territory precompute and label-drawing passes further down need
|
|
1117
|
+
// that same decision too, so they can anchor a detoured relationship's
|
|
1118
|
+
// label on its real path instead of the straight-line source/target
|
|
1119
|
+
// midpoint (issue #487). Populated only for the "target below source,
|
|
1120
|
+
// needs a detour around an intermediate box" case — the only routing
|
|
1121
|
+
// branch that currently detours at all.
|
|
1122
|
+
const detourRoutes = new Map<
|
|
1123
|
+
number,
|
|
1124
|
+
{
|
|
1125
|
+
routeX: number
|
|
1126
|
+
exitY: number
|
|
1127
|
+
entryY: number
|
|
1128
|
+
fromAnchorX: number
|
|
1129
|
+
toAnchorX: number
|
|
1130
|
+
clearSide: 'left' | 'right'
|
|
1131
|
+
}
|
|
1132
|
+
>()
|
|
1133
|
+
|
|
1134
|
+
// Same-level relationships' obstruction-aware detour row (issue #953),
|
|
1135
|
+
// keyed by relationship index — `computeLabelAnchor`'s own same-level
|
|
1136
|
+
// branch reads this so a labeled same-level relationship's label anchors
|
|
1137
|
+
// on the same corrected row the connector itself routes through, instead
|
|
1138
|
+
// of recomputing the naive (obstruction-unaware) `Math.max(fromBY, toBY)
|
|
1139
|
+
// + 2` and landing back inside whatever box the connector fix was
|
|
1140
|
+
// routing around.
|
|
1141
|
+
const sameLevelDetourY = new Map<number, number>()
|
|
1142
|
+
|
|
1143
|
+
diagram.relationships.forEach((rel, relIndex) => {
|
|
1144
|
+
const fromP = placed.get(rel.from)
|
|
1145
|
+
const toP = placed.get(rel.to)
|
|
1146
|
+
if (!fromP || !toP) return
|
|
1147
|
+
|
|
1148
|
+
const marker = getRelMarker(rel.type, rel.markerAt)
|
|
1149
|
+
const lineH = marker.dashed ? dashH : H
|
|
1150
|
+
const lineV = marker.dashed ? dashV : V
|
|
1151
|
+
|
|
1152
|
+
// Exclude source and target boxes from collision detection
|
|
1153
|
+
const excludeIds = new Set([rel.from, rel.to])
|
|
1154
|
+
|
|
1155
|
+
// Connection points: the lanes the line runs along (`fromCX`/`toCX`)
|
|
1156
|
+
// and the anchors where it touches each box border — see
|
|
1157
|
+
// `connectionColumns` for why those can differ.
|
|
1158
|
+
const { fromCX, toCX, fromAnchorX, toAnchorX } = connectionColumns(
|
|
1159
|
+
relIndex,
|
|
1160
|
+
fromP,
|
|
1161
|
+
toP,
|
|
1162
|
+
)
|
|
1163
|
+
const fromBY = fromP.y + fromP.height - 1
|
|
1164
|
+
const toTY = toP.y
|
|
1165
|
+
|
|
1166
|
+
// Route: Manhattan routing with collision avoidance
|
|
1167
|
+
// If target is below source: vertical down from source, horizontal if needed, vertical down to target
|
|
1168
|
+
// If same row: horizontal line with a small vertical detour above or below
|
|
1169
|
+
if (fromBY < toTY) {
|
|
1170
|
+
// Target is below source — routing with collision avoidance
|
|
1171
|
+
// Find a clear vertical column for the ENTIRE path from source to target
|
|
1172
|
+
const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds)
|
|
1173
|
+
const needsDetour = routeX !== fromCX
|
|
1174
|
+
|
|
1175
|
+
// Expand canvas if needed to accommodate routing column
|
|
1176
|
+
if (routeX >= totalW) {
|
|
1177
|
+
increaseSize(canvas, routeX + 2, totalH)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (needsDetour) {
|
|
1181
|
+
// COLLISION CASE: Route around intermediate boxes
|
|
1182
|
+
// Path: source anchor → horizontal to routeX → vertical to entry → horizontal to target anchor
|
|
1183
|
+
// The horizontals start/end at the *anchors* (on the box borders),
|
|
1184
|
+
// so a fanned-out lane needs no separate jog here — the detour's
|
|
1185
|
+
// own horizontal already runs along the jog row.
|
|
1186
|
+
|
|
1187
|
+
const exitY = fromBY + 1
|
|
1188
|
+
const entryY = toTY - 1
|
|
1189
|
+
detourRoutes.set(relIndex, {
|
|
1190
|
+
routeX,
|
|
1191
|
+
exitY,
|
|
1192
|
+
entryY,
|
|
1193
|
+
fromAnchorX,
|
|
1194
|
+
toAnchorX,
|
|
1195
|
+
// `findClearColumn` starts its search at `fromCX` and only
|
|
1196
|
+
// returns a different column when that one collided with a box
|
|
1197
|
+
// over the route's full y-range — so whichever side it moved
|
|
1198
|
+
// toward is the side with clearance, and the box it moved away
|
|
1199
|
+
// from sits on the other side of `routeX`. The label-anchor pass
|
|
1200
|
+
// uses this to keep a detour label's whole width clear of that
|
|
1201
|
+
// box, not just the single column `routeX` itself.
|
|
1202
|
+
clearSide: routeX > fromCX ? 'right' : 'left',
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
// 1. Horizontal from source anchor to route column
|
|
1206
|
+
const lx1 = Math.min(fromAnchorX, routeX)
|
|
1207
|
+
const rx1 = Math.max(fromAnchorX, routeX)
|
|
1208
|
+
for (let x = lx1; x <= rx1; x++) {
|
|
1209
|
+
setCGuarded(x, exitY, lineH, 'line')
|
|
1210
|
+
}
|
|
1211
|
+
if (!useAscii && exitY < (canvas[0]?.length ?? 0)) {
|
|
1212
|
+
if (fromAnchorX < routeX) {
|
|
1213
|
+
setCGuarded(fromAnchorX, exitY, '└', 'corner')
|
|
1214
|
+
setCGuarded(routeX, exitY, '┐', 'corner')
|
|
1215
|
+
} else {
|
|
1216
|
+
setCGuarded(fromAnchorX, exitY, '┘', 'corner')
|
|
1217
|
+
setCGuarded(routeX, exitY, '┌', 'corner')
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// 2. Vertical at routeX from exit to entry
|
|
1222
|
+
for (let y = exitY + 1; y <= entryY; y++) {
|
|
1223
|
+
setCGuarded(routeX, y, lineV, 'line')
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 3. Horizontal from routeX to target anchor at entry
|
|
1227
|
+
if (routeX !== toAnchorX) {
|
|
1228
|
+
const lx2 = Math.min(routeX, toAnchorX)
|
|
1229
|
+
const rx2 = Math.max(routeX, toAnchorX)
|
|
1230
|
+
for (let x = lx2; x <= rx2; x++) {
|
|
1231
|
+
setCGuarded(x, entryY, lineH, 'line')
|
|
1232
|
+
}
|
|
1233
|
+
if (!useAscii && entryY < (canvas[0]?.length ?? 0)) {
|
|
1234
|
+
if (routeX < toAnchorX) {
|
|
1235
|
+
setCGuarded(routeX, entryY, '└', 'corner')
|
|
1236
|
+
setCGuarded(toAnchorX, entryY, '┐', 'corner')
|
|
1237
|
+
} else {
|
|
1238
|
+
setCGuarded(routeX, entryY, '┘', 'corner')
|
|
1239
|
+
setCGuarded(toAnchorX, entryY, '┌', 'corner')
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Markers for detour case
|
|
1245
|
+
if (marker.markerAt === 'to') {
|
|
1246
|
+
// Target sits below this point — the arrowhead must point down
|
|
1247
|
+
// into it. Hierarchical markers (inheritance/realization) rotate
|
|
1248
|
+
// opposite to directional ones (association/dependency): passing
|
|
1249
|
+
// 'up' yields a hierarchical marker's down-pointing glyph, while
|
|
1250
|
+
// 'down' yields a directional marker's down-pointing glyph. See
|
|
1251
|
+
// the matching compensation in the "target is above source"
|
|
1252
|
+
// branch below, which handles the mirrored case.
|
|
1253
|
+
const isHierarchical =
|
|
1254
|
+
marker.type === 'inheritance' || marker.type === 'realization'
|
|
1255
|
+
const markerChar = getMarkerShape(
|
|
1256
|
+
marker.type,
|
|
1257
|
+
useAscii,
|
|
1258
|
+
isHierarchical ? 'up' : 'down',
|
|
1259
|
+
)
|
|
1260
|
+
setCGuarded(toAnchorX, entryY, markerChar, 'arrow')
|
|
1261
|
+
}
|
|
1262
|
+
if (marker.markerAt === 'from') {
|
|
1263
|
+
const markerChar = getMarkerShape(marker.type, useAscii, 'down')
|
|
1264
|
+
setCGuarded(fromAnchorX, fromBY + 1, markerChar, 'arrow')
|
|
1265
|
+
}
|
|
1266
|
+
} else {
|
|
1267
|
+
// NO COLLISION CASE: Use original midpoint-based routing
|
|
1268
|
+
// Path: source anchor → (jog to lane) → vertical to midY → horizontal at midY → vertical → (jog to anchor) → target
|
|
1269
|
+
//
|
|
1270
|
+
// The jogs only exist when a lane sits outside its box; otherwise
|
|
1271
|
+
// anchor and lane coincide and the path is the plain three-segment
|
|
1272
|
+
// route. With the default vertical gap the from-jog row, midY, and
|
|
1273
|
+
// the to-jog row are the three rows between the boxes, so a fanned
|
|
1274
|
+
// group reads as a trunk under the source, labels on the lanes,
|
|
1275
|
+
// and a trunk gathering back into the target.
|
|
1276
|
+
|
|
1277
|
+
const midY = fromBY + Math.floor((toTY - fromBY) / 2)
|
|
1278
|
+
const fromJogs = fromCX !== fromAnchorX
|
|
1279
|
+
const toJogs = toCX !== toAnchorX
|
|
1280
|
+
|
|
1281
|
+
// 1. Jog from the source anchor out to the lane, then vertical from
|
|
1282
|
+
// the source bottom (or from below the jog) to midY
|
|
1283
|
+
drawJog(fromBY + 1, fromAnchorX, fromCX, lineH, true)
|
|
1284
|
+
for (let y = fromBY + (fromJogs ? 2 : 1); y <= midY; y++) {
|
|
1285
|
+
setCGuarded(fromCX, y, lineV, 'line')
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// 2. Horizontal from fromCX to toCX at midY (if needed)
|
|
1289
|
+
//
|
|
1290
|
+
// This segment matters most for the boxCells guard: it can travel
|
|
1291
|
+
// a long way horizontally when the target's column sits far from
|
|
1292
|
+
// the source's (e.g. a level-1 child positioned under a completely
|
|
1293
|
+
// different level-0 sibling than its own parent). Nothing about
|
|
1294
|
+
// `findClearColumn` finding `fromCX` itself clear (the only check
|
|
1295
|
+
// that decided this is the no-collision branch) says the *row*
|
|
1296
|
+
// this horizontal run lands on is clear all the way over to
|
|
1297
|
+
// `toCX` too — a taller same-level sibling sitting between them,
|
|
1298
|
+
// extending below its own row's shortest box, is exactly the case
|
|
1299
|
+
// that check misses.
|
|
1300
|
+
if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) {
|
|
1301
|
+
const lx = Math.min(fromCX, toCX)
|
|
1302
|
+
const rx = Math.max(fromCX, toCX)
|
|
1303
|
+
for (let x = lx; x <= rx; x++) {
|
|
1304
|
+
setCGuarded(x, midY, lineH, 'line')
|
|
1305
|
+
}
|
|
1306
|
+
if (!useAscii) {
|
|
1307
|
+
setCGuarded(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner')
|
|
1308
|
+
setCGuarded(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner')
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// 3. Vertical from midY to the target top (or to above the jog),
|
|
1313
|
+
// then jog from the lane back in to the target anchor
|
|
1314
|
+
for (let y = midY + 1; y < toTY - (toJogs ? 1 : 0); y++) {
|
|
1315
|
+
setCGuarded(toCX, y, lineV, 'line')
|
|
1316
|
+
}
|
|
1317
|
+
drawJog(toTY - 1, toAnchorX, toCX, lineH, false)
|
|
1318
|
+
|
|
1319
|
+
// Markers for no-collision case
|
|
1320
|
+
if (marker.markerAt === 'to') {
|
|
1321
|
+
// Same rotation compensation as the detour case above — target is
|
|
1322
|
+
// below this point, so hierarchical markers need 'up' to point
|
|
1323
|
+
// down into it.
|
|
1324
|
+
const isHierarchical =
|
|
1325
|
+
marker.type === 'inheritance' || marker.type === 'realization'
|
|
1326
|
+
setCGuarded(
|
|
1327
|
+
toAnchorX,
|
|
1328
|
+
toTY - 1,
|
|
1329
|
+
getMarkerShape(
|
|
1330
|
+
marker.type,
|
|
1331
|
+
useAscii,
|
|
1332
|
+
isHierarchical ? 'up' : 'down',
|
|
1333
|
+
),
|
|
1334
|
+
'arrow',
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
if (marker.markerAt === 'from') {
|
|
1338
|
+
setCGuarded(
|
|
1339
|
+
fromAnchorX,
|
|
1340
|
+
fromBY + 1,
|
|
1341
|
+
getMarkerShape(marker.type, useAscii, 'down'),
|
|
1342
|
+
'arrow',
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
} else if (toP.y + toP.height - 1 < fromP.y) {
|
|
1347
|
+
// Target is ABOVE source — draw upward from source top to target bottom
|
|
1348
|
+
const fromTY = fromP.y
|
|
1349
|
+
const toBY = toP.y + toP.height - 1
|
|
1350
|
+
const midY = toBY + Math.floor((fromTY - toBY) / 2)
|
|
1351
|
+
const fromJogs = fromCX !== fromAnchorX
|
|
1352
|
+
const toJogs = toCX !== toAnchorX
|
|
1353
|
+
|
|
1354
|
+
// Jog along the row above the source from its anchor out to the lane
|
|
1355
|
+
// (mirror of the downward case), then vertical up to midY
|
|
1356
|
+
drawJog(fromTY - 1, fromAnchorX, fromCX, lineH, false)
|
|
1357
|
+
for (let y = fromTY - (fromJogs ? 2 : 1); y >= midY; y--) {
|
|
1358
|
+
setCGuarded(fromCX, y, lineV, 'line')
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// This branch has no collision-avoidance routing at all (unlike the
|
|
1362
|
+
// "target below source" branch above, which at least tries
|
|
1363
|
+
// `findClearColumn` first) — it always draws the straight
|
|
1364
|
+
// jog/vertical/horizontal/vertical/jog path. The boxCells guard is
|
|
1365
|
+
// this path's only protection against a horizontal run landing on
|
|
1366
|
+
// an unrelated box; see the equivalent midY comment in the "target
|
|
1367
|
+
// below source" branch above for why that's a real, not merely
|
|
1368
|
+
// theoretical, case.
|
|
1369
|
+
if (fromCX !== toCX) {
|
|
1370
|
+
const lx = Math.min(fromCX, toCX)
|
|
1371
|
+
const rx = Math.max(fromCX, toCX)
|
|
1372
|
+
for (let x = lx; x <= rx; x++) {
|
|
1373
|
+
setCGuarded(x, midY, lineH, 'line')
|
|
1374
|
+
}
|
|
1375
|
+
if (!useAscii && midY >= 0 && midY < totalH) {
|
|
1376
|
+
setCGuarded(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner')
|
|
1377
|
+
setCGuarded(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner')
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
for (let y = midY - 1; y > toBY + (toJogs ? 1 : 0); y--) {
|
|
1382
|
+
setCGuarded(toCX, y, lineV, 'line')
|
|
1383
|
+
}
|
|
1384
|
+
drawJog(toBY + 1, toAnchorX, toCX, lineH, true)
|
|
1385
|
+
|
|
1386
|
+
// Draw markers - arrows point in the direction of the vertical segment (upward)
|
|
1387
|
+
if (marker.markerAt === 'from') {
|
|
1388
|
+
const markerChar = getMarkerShape(marker.type, useAscii, 'up')
|
|
1389
|
+
const my = fromTY - 1
|
|
1390
|
+
for (let i = 0; i < markerChar.length; i++) {
|
|
1391
|
+
setCGuarded(
|
|
1392
|
+
fromAnchorX - Math.floor(markerChar.length / 2) + i,
|
|
1393
|
+
my,
|
|
1394
|
+
markerChar[i]!,
|
|
1395
|
+
'arrow',
|
|
1396
|
+
)
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
if (marker.markerAt === 'to') {
|
|
1400
|
+
const isHierarchical =
|
|
1401
|
+
marker.type === 'inheritance' || marker.type === 'realization'
|
|
1402
|
+
const markerDir = isHierarchical ? 'down' : 'up'
|
|
1403
|
+
const markerChar = getMarkerShape(marker.type, useAscii, markerDir)
|
|
1404
|
+
const my = toBY + 1
|
|
1405
|
+
for (let i = 0; i < markerChar.length; i++) {
|
|
1406
|
+
setCGuarded(
|
|
1407
|
+
toAnchorX - Math.floor(markerChar.length / 2) + i,
|
|
1408
|
+
my,
|
|
1409
|
+
markerChar[i]!,
|
|
1410
|
+
'arrow',
|
|
1411
|
+
)
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
} else {
|
|
1415
|
+
// Same level — draw horizontal line with a detour below both boxes.
|
|
1416
|
+
const toBY = toP.y + toP.height - 1
|
|
1417
|
+
const lx = Math.min(fromCX, toCX)
|
|
1418
|
+
const rx = Math.max(fromCX, toCX)
|
|
1419
|
+
|
|
1420
|
+
// The naive detour row — max(fromBY, toBY) + 2 — is derived only from
|
|
1421
|
+
// this relationship's own two endpoints. A same-level layout puts
|
|
1422
|
+
// every class in the level on one shared row (see the placement loop
|
|
1423
|
+
// above), so a relationship cycle that can't be linearly leveled
|
|
1424
|
+
// (e.g. A->B->C->A) lands a third, unrelated class between `fromP`
|
|
1425
|
+
// and `toP` in that same row. When that in-between class is taller
|
|
1426
|
+
// than both endpoints (more attributes/methods), the naive detour row
|
|
1427
|
+
// sits *above* its bottom edge, and the horizontal segment below
|
|
1428
|
+
// (spanning [lx, rx], which crosses straight through that class's
|
|
1429
|
+
// x-range) cuts through its box instead of running beneath it.
|
|
1430
|
+
// Search for any such same-row obstruction and route the detour below
|
|
1431
|
+
// its bottom edge too — generalizing er-diagram.ts's obstructionBottom
|
|
1432
|
+
// search (issue #350) to class diagrams' same-level detour.
|
|
1433
|
+
let obstructionBottom = Math.max(fromBY, toBY)
|
|
1434
|
+
for (const [id, other] of placed.entries()) {
|
|
1435
|
+
if (id === rel.from || id === rel.to) continue
|
|
1436
|
+
if (other.y !== fromP.y) continue
|
|
1437
|
+
const overlapsGap = other.x < rx + 1 && other.x + other.width > lx
|
|
1438
|
+
if (overlapsGap) {
|
|
1439
|
+
obstructionBottom = Math.max(
|
|
1440
|
+
obstructionBottom,
|
|
1441
|
+
other.y + other.height - 1,
|
|
1442
|
+
)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
const detourY = obstructionBottom + 2
|
|
1446
|
+
sameLevelDetourY.set(relIndex, detourY)
|
|
1447
|
+
increaseSize(canvas, totalW, detourY + 1)
|
|
1448
|
+
increaseRoleCanvasSize(rc, totalW, detourY + 1)
|
|
1449
|
+
const fromJogs = fromCX !== fromAnchorX
|
|
1450
|
+
const toJogs = toCX !== toAnchorX
|
|
1451
|
+
|
|
1452
|
+
// Jog out to the lane under the source, then vertical down from it
|
|
1453
|
+
drawJog(fromBY + 1, fromAnchorX, fromCX, lineH, true)
|
|
1454
|
+
for (let y = fromBY + (fromJogs ? 2 : 1); y <= detourY; y++) {
|
|
1455
|
+
setCGuarded(fromCX, y, lineV, 'line')
|
|
1456
|
+
}
|
|
1457
|
+
// Horizontal
|
|
1458
|
+
for (let x = lx; x <= rx; x++) {
|
|
1459
|
+
setCGuarded(x, detourY, lineH, 'line')
|
|
1460
|
+
}
|
|
1461
|
+
// Vertical up to target, then jog back in to its anchor
|
|
1462
|
+
for (let y = detourY - 1; y >= toBY + (toJogs ? 2 : 1); y--) {
|
|
1463
|
+
setCGuarded(toCX, y, lineV, 'line')
|
|
1464
|
+
}
|
|
1465
|
+
drawJog(toBY + 1, toAnchorX, toCX, lineH, true)
|
|
1466
|
+
|
|
1467
|
+
// Draw markers - same-level routing uses vertical segments at both ends
|
|
1468
|
+
if (marker.markerAt === 'from') {
|
|
1469
|
+
const markerChar = getMarkerShape(marker.type, useAscii, 'down')
|
|
1470
|
+
const my = fromBY + 1
|
|
1471
|
+
for (let i = 0; i < markerChar.length; i++) {
|
|
1472
|
+
setCGuarded(
|
|
1473
|
+
fromAnchorX - Math.floor(markerChar.length / 2) + i,
|
|
1474
|
+
my,
|
|
1475
|
+
markerChar[i]!,
|
|
1476
|
+
'arrow',
|
|
1477
|
+
)
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (marker.markerAt === 'to') {
|
|
1481
|
+
// Target sits above this point (line detours below both boxes then
|
|
1482
|
+
// comes back up) — mirrors the "target is above source" branch's
|
|
1483
|
+
// compensation above.
|
|
1484
|
+
const isHierarchical =
|
|
1485
|
+
marker.type === 'inheritance' || marker.type === 'realization'
|
|
1486
|
+
const markerChar = getMarkerShape(
|
|
1487
|
+
marker.type,
|
|
1488
|
+
useAscii,
|
|
1489
|
+
isHierarchical ? 'down' : 'up',
|
|
1490
|
+
)
|
|
1491
|
+
const my = toP.y + toP.height
|
|
1492
|
+
for (let i = 0; i < markerChar.length; i++) {
|
|
1493
|
+
setCGuarded(
|
|
1494
|
+
toAnchorX - Math.floor(markerChar.length / 2) + i,
|
|
1495
|
+
my,
|
|
1496
|
+
markerChar[i]!,
|
|
1497
|
+
'arrow',
|
|
1498
|
+
)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Where a relationship's label should ideally center — the column and row
|
|
1506
|
+
* the territory precompute and label-drawing passes below both anchor on.
|
|
1507
|
+
*
|
|
1508
|
+
* For the common case (no detour), this is the straight-line midpoint
|
|
1509
|
+
* between the source and target connection columns/rows, same as before.
|
|
1510
|
+
*
|
|
1511
|
+
* For a relationship whose line detours around an intermediate box (see
|
|
1512
|
+
* `detourRoutes` above), the label instead anchors on the *actual routed
|
|
1513
|
+
* path*: beside the midpoint of the detour's vertical trunk (the longest
|
|
1514
|
+
* segment in the common case), or — when the boxes are close enough
|
|
1515
|
+
* together that the trunk has no vertical room at all — the midpoint of
|
|
1516
|
+
* whichever horizontal jog is longer. Anchoring at the straight-line
|
|
1517
|
+
* midpoint ignored the detour entirely, so a detoured relationship's
|
|
1518
|
+
* label could land in the same column (and even the same row) as an
|
|
1519
|
+
* unrelated relationship's straight-line label, reading as though both
|
|
1520
|
+
* terminated at the same box (issue #487).
|
|
1521
|
+
*
|
|
1522
|
+
* `labelWidth` (the padded display width the caller is about to draw)
|
|
1523
|
+
* matters for the vertical-trunk case specifically: `findClearColumn`
|
|
1524
|
+
* only guarantees the single column `routeX` is clear of boxes over the
|
|
1525
|
+
* trunk's row range, not a whole label-width window centered on it. A
|
|
1526
|
+
* label centered directly on `routeX` routinely re-overlapped the very
|
|
1527
|
+
* box the trunk was routed around to avoid — visible as the label
|
|
1528
|
+
* appearing to collide with (or get shoved off) the box the trunk hugs.
|
|
1529
|
+
* `detour.clearSide` says which side of `routeX` is the side
|
|
1530
|
+
* `findClearColumn` actually found clear (see its call site), so the
|
|
1531
|
+
* label is anchored flush against the trunk on that side instead of
|
|
1532
|
+
* straddling it.
|
|
1533
|
+
*/
|
|
1534
|
+
function computeLabelAnchor(
|
|
1535
|
+
relIndex: number,
|
|
1536
|
+
fromP: PlacedClass,
|
|
1537
|
+
toP: PlacedClass,
|
|
1538
|
+
labelWidth: number,
|
|
1539
|
+
): { idealMidX: number; baseMidY: number } {
|
|
1540
|
+
const { fromCX, toCX } = connectionColumns(relIndex, fromP, toP)
|
|
1541
|
+
const fromBY = fromP.y + fromP.height - 1
|
|
1542
|
+
const toTY = toP.y
|
|
1543
|
+
|
|
1544
|
+
if (fromBY < toTY) {
|
|
1545
|
+
const detour = detourRoutes.get(relIndex)
|
|
1546
|
+
if (detour) {
|
|
1547
|
+
const trunkTop = detour.exitY + 1
|
|
1548
|
+
const trunkBottom = detour.entryY
|
|
1549
|
+
if (trunkBottom >= trunkTop) {
|
|
1550
|
+
// Vertical trunk has room — it's the longest segment of the
|
|
1551
|
+
// route in the common case, so anchor there. The label sits
|
|
1552
|
+
// flush against the trunk on its clear side (see doc comment)
|
|
1553
|
+
// rather than centered on it, with a 1-column gap for legibility.
|
|
1554
|
+
const gap = 1
|
|
1555
|
+
const idealMidX =
|
|
1556
|
+
detour.clearSide === 'right'
|
|
1557
|
+
? detour.routeX + gap + Math.floor(labelWidth / 2)
|
|
1558
|
+
: detour.routeX - gap - Math.ceil(labelWidth / 2)
|
|
1559
|
+
return {
|
|
1560
|
+
idealMidX,
|
|
1561
|
+
baseMidY: Math.floor((trunkTop + trunkBottom) / 2),
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// Boxes are close enough together that the trunk has no vertical
|
|
1565
|
+
// room (exit and entry jogs sit on the same or adjacent rows) —
|
|
1566
|
+
// anchor on whichever horizontal jog is longer instead.
|
|
1567
|
+
const exitWidth = Math.abs(detour.routeX - detour.fromAnchorX)
|
|
1568
|
+
const entryWidth = Math.abs(detour.toAnchorX - detour.routeX)
|
|
1569
|
+
return entryWidth >= exitWidth
|
|
1570
|
+
? {
|
|
1571
|
+
idealMidX: Math.floor((detour.routeX + detour.toAnchorX) / 2),
|
|
1572
|
+
baseMidY: detour.entryY,
|
|
1573
|
+
}
|
|
1574
|
+
: {
|
|
1575
|
+
idealMidX: Math.floor((detour.fromAnchorX + detour.routeX) / 2),
|
|
1576
|
+
baseMidY: detour.exitY,
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
idealMidX: Math.floor((fromCX + toCX) / 2),
|
|
1581
|
+
baseMidY: Math.floor((fromBY + 1 + toTY - 1) / 2),
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (toP.y + toP.height - 1 < fromP.y) {
|
|
1585
|
+
const toBY = toP.y + toP.height - 1
|
|
1586
|
+
return {
|
|
1587
|
+
idealMidX: Math.floor((fromCX + toCX) / 2),
|
|
1588
|
+
baseMidY: Math.floor((toBY + 1 + fromP.y - 1) / 2),
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// Same level — anchor on the obstruction-aware detour row the connector
|
|
1592
|
+
// itself was routed through (see `sameLevelDetourY` above), not the
|
|
1593
|
+
// naive `Math.max(fromBY, toBY) + 2` recomputation, which ignores any
|
|
1594
|
+
// taller same-row box between `fromP`/`toP` and would place the label
|
|
1595
|
+
// right back inside it (issue #953).
|
|
1596
|
+
return {
|
|
1597
|
+
idealMidX: Math.floor((fromCX + toCX) / 2),
|
|
1598
|
+
// The `??` fallback is unreachable in practice: the main
|
|
1599
|
+
// relationship-drawing pass above runs for every relationship before
|
|
1600
|
+
// this label pass does, and it sets `sameLevelDetourY` for every
|
|
1601
|
+
// relIndex that takes this same "same level" branch — the exact
|
|
1602
|
+
// condition this function just evaluated to reach here. Kept only
|
|
1603
|
+
// because `Map.get` is typed `T | undefined`, not because a real
|
|
1604
|
+
// diagram can actually hit it.
|
|
1605
|
+
/* v8 ignore next */
|
|
1606
|
+
baseMidY:
|
|
1607
|
+
sameLevelDetourY.get(relIndex) ??
|
|
1608
|
+
Math.max(fromBY, toP.y + toP.height - 1) + 2,
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Resolve a label's actual drawn row, starting from `computeLabelAnchor`'s
|
|
1614
|
+
* `baseMidY` and nudging it to the nearest box-free row in the from/to gap
|
|
1615
|
+
* when the ideal row would land inside an intervening box. Shared by the
|
|
1616
|
+
* territory precompute below and the draw pass further down so both agree
|
|
1617
|
+
* on the *same* final row for a given relationship — see the doc comment
|
|
1618
|
+
* on the territory precompute for why that agreement matters (issue #531).
|
|
1619
|
+
*/
|
|
1620
|
+
function resolveLabelFinalY(
|
|
1621
|
+
fromP: PlacedClass,
|
|
1622
|
+
toP: PlacedClass,
|
|
1623
|
+
idealMidX: number,
|
|
1624
|
+
baseMidY: number,
|
|
1625
|
+
maxLabelWidth: number,
|
|
1626
|
+
lineCount: number,
|
|
1627
|
+
excludeIds: Set<string>,
|
|
1628
|
+
): number {
|
|
1629
|
+
let labelY = baseMidY
|
|
1630
|
+
const halfHeight = Math.floor(lineCount / 2)
|
|
1631
|
+
const fromBY = fromP.y + fromP.height - 1
|
|
1632
|
+
const toTY = toP.y
|
|
1633
|
+
|
|
1634
|
+
// Check if any label line would be inside a box
|
|
1635
|
+
let labelInBox = false
|
|
1636
|
+
for (let i = 0; i < lineCount; i++) {
|
|
1637
|
+
const y = labelY - halfHeight + i
|
|
1638
|
+
const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)
|
|
1639
|
+
const labelStart = Math.max(0, idealLabelStart)
|
|
1640
|
+
for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {
|
|
1641
|
+
if (isInsideBox(x, y, excludeIds)) {
|
|
1642
|
+
labelInBox = true
|
|
1643
|
+
break
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (labelInBox) break
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// If label is inside a box, find the gap between boxes
|
|
1650
|
+
if (labelInBox) {
|
|
1651
|
+
const gapTop = fromBY + 1
|
|
1652
|
+
const gapBottom = toTY - 1
|
|
1653
|
+
|
|
1654
|
+
// Place label in the middle of the gap, outside any intermediate box
|
|
1655
|
+
for (let y = gapTop; y <= gapBottom; y++) {
|
|
1656
|
+
let clearRow = true
|
|
1657
|
+
const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)
|
|
1658
|
+
const labelStart = Math.max(0, idealLabelStart)
|
|
1659
|
+
for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {
|
|
1660
|
+
if (isInsideBox(x, y, excludeIds)) {
|
|
1661
|
+
clearRow = false
|
|
1662
|
+
break
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
if (clearRow) {
|
|
1666
|
+
labelY = y
|
|
1667
|
+
break
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
return labelY
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// --- Precompute each label's horizontal territory ---
|
|
1676
|
+
// A label's left/right bound is derived purely from connection-point
|
|
1677
|
+
// geometry — never from draw order or from what another relationship's
|
|
1678
|
+
// label happened to render as. Two labels whose natural (fully centered,
|
|
1679
|
+
// unclamped) spans would actually overlap split the contested space
|
|
1680
|
+
// evenly at the midpoint between their connection columns; labels that
|
|
1681
|
+
// don't naturally overlap are left unconstrained by each other.
|
|
1682
|
+
//
|
|
1683
|
+
// This matters because a *reactive* approach — only checking cells an
|
|
1684
|
+
// earlier iteration already drew text into — cascades: a label pinned
|
|
1685
|
+
// near the canvas's left edge (idealLabelStart < 0) used to be *shifted*
|
|
1686
|
+
// fully into view via `Math.max(0, idealLabelStart)` rather than
|
|
1687
|
+
// *clipped*, which silently ate into its neighbor's rightful column; that
|
|
1688
|
+
// neighbor then had nothing left to reactively claim and vanished
|
|
1689
|
+
// entirely, while labels further along drifted based on whatever was
|
|
1690
|
+
// left over. Precomputing fixed, mutually exclusive territories up front
|
|
1691
|
+
// — before any label is drawn — means every label's placement is
|
|
1692
|
+
// consistent regardless of relationship order, and a genuinely
|
|
1693
|
+
// insufficient column width (e.g. six same-row relationships spaced
|
|
1694
|
+
// closer together than their labels are wide) truncates *all* of them
|
|
1695
|
+
// consistently instead of destroying an arbitrary subset. See issue #447.
|
|
1696
|
+
//
|
|
1697
|
+
// rowStart/rowEnd are derived from `resolveLabelFinalY`'s result, *not*
|
|
1698
|
+
// `computeLabelAnchor`'s raw `baseMidY` — the same "clear box gap" runtime
|
|
1699
|
+
// fallback the draw pass applies can move a label to a row far from its
|
|
1700
|
+
// ideal one (e.g. a detoured relationship whose ideal row lands inside the
|
|
1701
|
+
// box it detoured around). Territories computed from the pre-fallback row
|
|
1702
|
+
// used to miss exactly the overlaps that fallback creates: two
|
|
1703
|
+
// relationships whose *ideal* rows didn't overlap could still both
|
|
1704
|
+
// resolve to the *same actual* row, and with no territory split between
|
|
1705
|
+
// them the later one's unconditional draw silently overwrote the earlier
|
|
1706
|
+
// one's label (issue #531). Resolving here, once, up front — and reusing
|
|
1707
|
+
// the result in the draw pass below via `finalLabelYByRel` — guarantees
|
|
1708
|
+
// territory and drawing always agree on the same row per relationship.
|
|
1709
|
+
//
|
|
1710
|
+
// The allocation itself — splitting contested columns at the midpoint
|
|
1711
|
+
// between two competing labels' idealMidX values — lives in
|
|
1712
|
+
// `allocateTerritory` (`territory.ts`), which is storage-agnostic and
|
|
1713
|
+
// knows nothing about relationships; this pass only supplies the geometry
|
|
1714
|
+
// (issue #618). Its doc comments carry the full rationale summarised
|
|
1715
|
+
// above.
|
|
1716
|
+
interface LabelGeometry {
|
|
1717
|
+
rel: (typeof diagram.relationships)[number]
|
|
1718
|
+
idealMidX: number
|
|
1719
|
+
naturalStart: number
|
|
1720
|
+
naturalEnd: number
|
|
1721
|
+
rowStart: number
|
|
1722
|
+
rowEnd: number
|
|
1723
|
+
}
|
|
1724
|
+
const finalLabelYByRel = new Map<
|
|
1725
|
+
(typeof diagram.relationships)[number],
|
|
1726
|
+
number
|
|
1727
|
+
>()
|
|
1728
|
+
const labelGeometry: LabelGeometry[] = []
|
|
1729
|
+
for (const [relIndex, rel] of diagram.relationships.entries()) {
|
|
1730
|
+
if (!rel.label) continue
|
|
1731
|
+
const fromP = placed.get(rel.from)
|
|
1732
|
+
const toP = placed.get(rel.to)
|
|
1733
|
+
if (!fromP || !toP) continue
|
|
1734
|
+
|
|
1735
|
+
// Same anchor the draw pass below uses — needed so territory splitting
|
|
1736
|
+
// only ever kicks in between labels that could actually land on
|
|
1737
|
+
// overlapping rows. Two relationships can share a similar idealMidX
|
|
1738
|
+
// while being drawn many rows apart (e.g. one class's two separate
|
|
1739
|
+
// outgoing edges to two different targets at different heights) —
|
|
1740
|
+
// splitting their X territory in that case truncates both for no
|
|
1741
|
+
// reason, since they never actually collide.
|
|
1742
|
+
const lines = splitLines(rel.label)
|
|
1743
|
+
const halfHeight = Math.floor(lines.length / 2)
|
|
1744
|
+
|
|
1745
|
+
const width = Math.max(...lines.map(displayWidth)) + 2 // +2 for padding
|
|
1746
|
+
const { idealMidX, baseMidY } = computeLabelAnchor(
|
|
1747
|
+
relIndex,
|
|
1748
|
+
fromP,
|
|
1749
|
+
toP,
|
|
1750
|
+
width,
|
|
1751
|
+
)
|
|
1752
|
+
const excludeIds = new Set([rel.from, rel.to])
|
|
1753
|
+
const labelY = resolveLabelFinalY(
|
|
1754
|
+
fromP,
|
|
1755
|
+
toP,
|
|
1756
|
+
idealMidX,
|
|
1757
|
+
baseMidY,
|
|
1758
|
+
width,
|
|
1759
|
+
lines.length,
|
|
1760
|
+
excludeIds,
|
|
1761
|
+
)
|
|
1762
|
+
finalLabelYByRel.set(rel, labelY)
|
|
1763
|
+
// Clamped the same way the draw loop below clamps it (never negative —
|
|
1764
|
+
// a label can't render left of the canvas edge) so this overlap check
|
|
1765
|
+
// reflects what will actually be drawn. Using the *unclamped* value
|
|
1766
|
+
// here would miss overlaps a left-edge label creates once it's shifted
|
|
1767
|
+
// into view: it would compute this label's territory as if it stayed
|
|
1768
|
+
// off-canvas at its raw, negative position instead of at column 0,
|
|
1769
|
+
// under-detecting a real collision with its neighbor (issue #447).
|
|
1770
|
+
const naturalStart = Math.max(0, idealMidX - Math.floor(width / 2))
|
|
1771
|
+
labelGeometry.push({
|
|
1772
|
+
rel,
|
|
1773
|
+
idealMidX,
|
|
1774
|
+
naturalStart,
|
|
1775
|
+
naturalEnd: naturalStart + width - 1,
|
|
1776
|
+
rowStart: labelY - halfHeight,
|
|
1777
|
+
rowEnd: labelY + halfHeight,
|
|
1778
|
+
})
|
|
1779
|
+
}
|
|
1780
|
+
const territoryByGeometry = allocateTerritory(labelGeometry, (g) => ({
|
|
1781
|
+
idealMid: g.idealMidX,
|
|
1782
|
+
start: g.naturalStart,
|
|
1783
|
+
end: g.naturalEnd,
|
|
1784
|
+
rowStart: g.rowStart,
|
|
1785
|
+
rowEnd: g.rowEnd,
|
|
1786
|
+
}))
|
|
1787
|
+
const territoryByRel = new Map<
|
|
1788
|
+
(typeof diagram.relationships)[number],
|
|
1789
|
+
Territory
|
|
1790
|
+
>()
|
|
1791
|
+
for (const g of labelGeometry) {
|
|
1792
|
+
const territory = territoryByGeometry.get(g)
|
|
1793
|
+
if (territory) territoryByRel.set(g.rel, territory)
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// --- Draw relationship labels ---
|
|
1797
|
+
// Deliberately a separate pass over *all* relationships, run only after
|
|
1798
|
+
// every relationship's lines are drawn above. Labels used to be drawn
|
|
1799
|
+
// inline with each relationship's own lines, which let a *later*
|
|
1800
|
+
// relationship's connector line silently overwrite an *earlier*
|
|
1801
|
+
// relationship's already-drawn label whenever both routes crossed the
|
|
1802
|
+
// same row — e.g. two edges converging on one target box both jog through
|
|
1803
|
+
// the same midpoint row (issue #447, "teaches" truncated to "tea" and
|
|
1804
|
+
// merged into a box-drawing corner). Running all lines first means no
|
|
1805
|
+
// line write can ever land on top of label text again.
|
|
1806
|
+
for (const [relIndex, rel] of diagram.relationships.entries()) {
|
|
1807
|
+
const fromP = placed.get(rel.from)
|
|
1808
|
+
const toP = placed.get(rel.to)
|
|
1809
|
+
if (!fromP || !toP) continue
|
|
1810
|
+
if (!rel.label) continue
|
|
1811
|
+
|
|
1812
|
+
// Draw relationship label at midpoint (supports multi-line)
|
|
1813
|
+
// Add padding around the label for readability
|
|
1814
|
+
{
|
|
1815
|
+
const lines = splitLines(rel.label)
|
|
1816
|
+
const maxLabelWidth = Math.max(...lines.map((l) => displayWidth(l))) + 2 // +2 for padding
|
|
1817
|
+
|
|
1818
|
+
// Calculate ideal label position based on routing direction (and, for
|
|
1819
|
+
// a detoured relationship, its actual routed path — see
|
|
1820
|
+
// `computeLabelAnchor`).
|
|
1821
|
+
const { idealMidX } = computeLabelAnchor(
|
|
1822
|
+
relIndex,
|
|
1823
|
+
fromP,
|
|
1824
|
+
toP,
|
|
1825
|
+
maxLabelWidth,
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
// The row to draw on: resolved once, up front, by the territory
|
|
1829
|
+
// precompute above (via `resolveLabelFinalY`) — reused here rather
|
|
1830
|
+
// than re-resolved so this pass and the territory it reads from
|
|
1831
|
+
// (`territoryByRel`, just below) always agree on the same row for
|
|
1832
|
+
// this relationship. See the precompute's doc comment (issue #531).
|
|
1833
|
+
const labelY = finalLabelYByRel.get(rel)!
|
|
1834
|
+
const halfHeight = Math.floor(lines.length / 2)
|
|
1835
|
+
|
|
1836
|
+
// Center lines vertically around labelY
|
|
1837
|
+
const startY = labelY - halfHeight
|
|
1838
|
+
|
|
1839
|
+
const territory = territoryByRel.get(rel)!
|
|
1840
|
+
|
|
1841
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
1842
|
+
const y = startY + lineIdx
|
|
1843
|
+
const text = lines[lineIdx]!
|
|
1844
|
+
|
|
1845
|
+
// Grid cells, not code units: a wide glyph occupies two columns, so
|
|
1846
|
+
// measuring/writing by code unit would centre the label wrong and
|
|
1847
|
+
// overrun the space cleared for it.
|
|
1848
|
+
const naturalCells = toDisplayCells(` ${text} `) // space padding on both sides
|
|
1849
|
+
// Clamped to never go negative — matches the clamp the territory
|
|
1850
|
+
// precomputation above already applied when deciding whether this
|
|
1851
|
+
// label and its neighbors' natural spans overlap, so the two stay
|
|
1852
|
+
// consistent. `fitLabelToAvailableWidth` still clips in place
|
|
1853
|
+
// rather than shifting for whatever collisions the territory
|
|
1854
|
+
// bounds encode, including one this very clamp creates against a
|
|
1855
|
+
// left neighbor (see the territory precomputation's comment).
|
|
1856
|
+
const naturalStart = Math.max(
|
|
1857
|
+
0,
|
|
1858
|
+
idealMidX - Math.floor(naturalCells.length / 2),
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
const { start: labelStart, cells } = fitLabelToAvailableWidth(
|
|
1862
|
+
naturalStart,
|
|
1863
|
+
naturalCells,
|
|
1864
|
+
text,
|
|
1865
|
+
Math.max(0, territory.left),
|
|
1866
|
+
territory.right,
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
// Ensure canvas is wide enough for the label
|
|
1870
|
+
const labelEnd = labelStart + cells.length
|
|
1871
|
+
if (labelEnd > 0 && y >= 0) {
|
|
1872
|
+
increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1))
|
|
1873
|
+
increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1))
|
|
1874
|
+
}
|
|
1875
|
+
// Clear the area first (overwrite line characters) then draw the padded label
|
|
1876
|
+
for (let i = 0; i < cells.length; i++) {
|
|
1877
|
+
const lx = labelStart + i
|
|
1878
|
+
if (lx >= 0 && y >= 0) {
|
|
1879
|
+
setC(lx, y, cells[i]!, 'text')
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Notes were drawn as plain boxes above; round their corners and join
|
|
1887
|
+
// each attached note to its class. Last, so a connector never lands on
|
|
1888
|
+
// top of a relationship line or label.
|
|
1889
|
+
decorateAsciiNotes(notesById, placed, canvas, useAscii, setC)
|
|
1890
|
+
|
|
1891
|
+
return canvasToString(canvas, {
|
|
1892
|
+
roleCanvas: rc,
|
|
1893
|
+
colorMode,
|
|
1894
|
+
theme,
|
|
1895
|
+
linkCanvas,
|
|
1896
|
+
})
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ============================================================================
|
|
1900
|
+
// Notes — `note "text"` / `note for X "text"` (issue #420)
|
|
1901
|
+
//
|
|
1902
|
+
// A note is laid out as a pseudo-class: it gets a box like any class (a
|
|
1903
|
+
// single header-only section holding the note text, via the same
|
|
1904
|
+
// buildClassSections/drawMultiBox path), and is spliced into
|
|
1905
|
+
// `diagram.classes` immediately after the class it annotates so the row
|
|
1906
|
+
// placement puts it directly to that class's right. A free note, or one
|
|
1907
|
+
// whose class doesn't exist, is appended to the end and sits on the top row.
|
|
1908
|
+
// After all boxes and relationships are drawn, decorateAsciiNotes() swaps
|
|
1909
|
+
// the note's corners for rounded ones — the same rectangle-vs-rounded
|
|
1910
|
+
// distinction the flowchart ASCII renderer draws — and joins an attached
|
|
1911
|
+
// note to its class with a short dashed connector, the ASCII counterpart of
|
|
1912
|
+
// the dotted note→class link Mermaid draws. Class ids can't contain
|
|
1913
|
+
// whitespace, so a pseudo id with a space in it can never collide with one.
|
|
1914
|
+
// ============================================================================
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Return a copy of `parsed` whose `classes` list also contains one
|
|
1918
|
+
* pseudo-class per note, positioned as described above, plus a map from each
|
|
1919
|
+
* pseudo id to its note.
|
|
1920
|
+
*/
|
|
1921
|
+
function withNotePseudoClasses(parsed: ClassDiagram): {
|
|
1922
|
+
diagram: ClassDiagram
|
|
1923
|
+
notesById: Map<string, ClassNote>
|
|
1924
|
+
} {
|
|
1925
|
+
const notesById = new Map<string, ClassNote>()
|
|
1926
|
+
if (parsed.notes.length === 0) return { diagram: parsed, notesById }
|
|
1927
|
+
|
|
1928
|
+
const pseudo = (index: number, note: ClassNote): ClassNode => {
|
|
1929
|
+
const id = `note ${index}`
|
|
1930
|
+
notesById.set(id, note)
|
|
1931
|
+
return { id, label: note.text, attributes: [], methods: [] }
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const classIds = new Set(parsed.classes.map((c) => c.id))
|
|
1935
|
+
const classes: ClassNode[] = []
|
|
1936
|
+
for (const cls of parsed.classes) {
|
|
1937
|
+
classes.push(cls)
|
|
1938
|
+
for (const [i, note] of parsed.notes.entries()) {
|
|
1939
|
+
if (note.forClass === cls.id) classes.push(pseudo(i, note))
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const [i, note] of parsed.notes.entries()) {
|
|
1943
|
+
if (note.forClass === undefined || !classIds.has(note.forClass)) {
|
|
1944
|
+
classes.push(pseudo(i, note))
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
return { diagram: { ...parsed, classes }, notesById }
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/** Put each attached note on the same level as its class. */
|
|
1952
|
+
function pinNoteLevels(
|
|
1953
|
+
notesById: Map<string, ClassNote>,
|
|
1954
|
+
level: Map<string, number>,
|
|
1955
|
+
): void {
|
|
1956
|
+
for (const [id, note] of notesById) {
|
|
1957
|
+
if (note.forClass === undefined) continue
|
|
1958
|
+
const classLevel = level.get(note.forClass)
|
|
1959
|
+
if (classLevel !== undefined) level.set(id, classLevel)
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Round each note box's corners and draw the dashed connector from an
|
|
1965
|
+
* attached note's class to the note. The connector only fills cells that
|
|
1966
|
+
* are still blank, so it can never break a relationship line that happens
|
|
1967
|
+
* to route through the gap.
|
|
1968
|
+
*/
|
|
1969
|
+
function decorateAsciiNotes(
|
|
1970
|
+
notesById: Map<string, ClassNote>,
|
|
1971
|
+
placed: Map<string, PlacedClass>,
|
|
1972
|
+
canvas: Canvas,
|
|
1973
|
+
useAscii: boolean,
|
|
1974
|
+
setC: (x: number, y: number, ch: string, role: CharRole) => void,
|
|
1975
|
+
): void {
|
|
1976
|
+
if (notesById.size === 0) return
|
|
1977
|
+
const corners = getCorners('rounded', useAscii)
|
|
1978
|
+
const dash = useAscii ? '.' : '╌'
|
|
1979
|
+
|
|
1980
|
+
for (const [id, note] of notesById) {
|
|
1981
|
+
const p = placed.get(id)
|
|
1982
|
+
if (!p) continue
|
|
1983
|
+
const right = p.x + p.width - 1
|
|
1984
|
+
const bottom = p.y + p.height - 1
|
|
1985
|
+
setC(p.x, p.y, corners.tl, 'border')
|
|
1986
|
+
setC(right, p.y, corners.tr, 'border')
|
|
1987
|
+
setC(p.x, bottom, corners.bl, 'border')
|
|
1988
|
+
setC(right, bottom, corners.br, 'border')
|
|
1989
|
+
|
|
1990
|
+
if (note.forClass === undefined) continue
|
|
1991
|
+
const cls = placed.get(note.forClass)
|
|
1992
|
+
// By construction the note sits directly right of its class on the same
|
|
1993
|
+
// row; anything else means the layout changed under us — skip the line
|
|
1994
|
+
// rather than draw one through the middle of the diagram.
|
|
1995
|
+
if (!cls || cls.x + cls.width > p.x || cls.y !== p.y) continue
|
|
1996
|
+
const y = cls.y + 1
|
|
1997
|
+
for (let x = cls.x + cls.width; x < p.x; x++) {
|
|
1998
|
+
if (canvas[x]?.[y] === ' ') setC(x, y, dash, 'line')
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|