@zombie-mermaid/svg-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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Layer alignment — snap same-layer nodes to a uniform position.
3
+ * Split out of layout-engine.ts as a self-contained post-processing pass
4
+ * over a PositionedGraph's nodes/edges.
5
+ */
6
+
7
+ import type {
8
+ PositionedNode,
9
+ PositionedEdge,
10
+ Direction,
11
+ } from '@zombie-mermaid/core'
12
+ import { DEFAULTS } from './constants.ts'
13
+
14
+ /**
15
+ * ELK's orthogonal edge routing staggers nodes within the same layer to create
16
+ * space for edge bends. This post-processing step groups nodes into layers and
17
+ * snaps them to the same flow-axis coordinate (Y for TD/TB, X for LR/RL).
18
+ *
19
+ * Grouping uses proximity along the flow axis: within a layer, ELK's stagger
20
+ * is always less than layerSpacing (bounded by edge routing channels), while
21
+ * adjacent layers are separated by at least layerSpacing + nodeHeight.
22
+ * A threshold of 0.75 * layerSpacing cleanly separates these cases.
23
+ *
24
+ * Directly connected nodes (sharing an edge) are never merged into the same
25
+ * layer group as an additional safety check.
26
+ *
27
+ * Edge endpoints connected to shifted nodes are adjusted proportionally.
28
+ * Intermediate bend points are left unchanged — edge bundling or clipping
29
+ * will recalculate them afterwards.
30
+ */
31
+ // Audited for issue #100 (non-null assertions): every `!` in this file is
32
+ // a bounds-checked array access, each backed by an explicit check earlier
33
+ // in the same scope — `sorted[0]!` and the `sorted[i]!`/`sorted[i - 1]!`
34
+ // loop-index accesses below are guaranteed by the `nodes.length === 0`
35
+ // early return plus the `for (let i = 1; i < sorted.length; ...)` loop
36
+ // bound; `edge.points[0]!`, `edge.points[1]!`, and the
37
+ // `edge.points[edge.points.length - 1]!` / `[edge.points.length - 2]!`
38
+ // pairs are guaranteed by the `edge.points.length < 2` early `continue`
39
+ // (and, redundantly, the `edge.points.length > 1` checks right above each
40
+ // access). This is the same idiom already accepted as justified elsewhere
41
+ // in this codebase (see src/parser.ts, PR #158, and this directory's
42
+ // edge-bundling.ts/from-elk.ts audit, PR #150, which didn't reach this
43
+ // file). `noUncheckedIndexedAccess` can't see any of these guarantees,
44
+ // but removing the `!` would only replace a proven-safe assertion with an
45
+ // unreachable guard. Left as-is; no behavior change.
46
+ export function alignLayerNodes(
47
+ nodes: PositionedNode[],
48
+ edges: PositionedEdge[],
49
+ direction: Direction,
50
+ ): void {
51
+ if (nodes.length === 0) return
52
+
53
+ const isHorizontal = direction === 'LR' || direction === 'RL'
54
+
55
+ // Build set of directly-connected node pairs.
56
+ // Nodes connected by an edge must not be merged into the same layer.
57
+ const connectedPairs = new Set<string>()
58
+ for (const edge of edges) {
59
+ connectedPairs.add(`${edge.source}:${edge.target}`)
60
+ connectedPairs.add(`${edge.target}:${edge.source}`)
61
+ }
62
+
63
+ // ELK's stagger creates small gaps between adjacent nodes in the same layer
64
+ // (typically edgeEdge spacing = 12px per routing channel). Adjacent layers
65
+ // are separated by at least layerSpacing (48px). We use single-linkage
66
+ // clustering: a node joins the current layer if the gap from the previous
67
+ // node (in sorted order) is within threshold, AND it has no direct edge to
68
+ // any node already in the layer.
69
+ const THRESHOLD = DEFAULTS.layerSpacing * 0.6
70
+
71
+ // Sort nodes by flow-axis position
72
+ const sorted = [...nodes].sort((a, b) =>
73
+ isHorizontal ? a.x - b.x : a.y - b.y,
74
+ )
75
+
76
+ const layers: PositionedNode[][] = []
77
+ let currentLayer: PositionedNode[] = [sorted[0]!]
78
+
79
+ for (let i = 1; i < sorted.length; i++) {
80
+ const pos = isHorizontal ? sorted[i]!.x : sorted[i]!.y
81
+ const prevPos = isHorizontal ? sorted[i - 1]!.x : sorted[i - 1]!.y
82
+ // Single-linkage: compare with previous node, not layer start
83
+ const gap = pos - prevPos
84
+ // Check if this node is connected to any node already in the current layer
85
+ const hasEdgeToLayer = currentLayer.some((n) =>
86
+ connectedPairs.has(`${n.id}:${sorted[i]!.id}`),
87
+ )
88
+ if (gap <= THRESHOLD && !hasEdgeToLayer) {
89
+ currentLayer.push(sorted[i]!)
90
+ } else {
91
+ layers.push(currentLayer)
92
+ currentLayer = [sorted[i]!]
93
+ }
94
+ }
95
+ layers.push(currentLayer)
96
+
97
+ // Snap each layer's nodes to the layer's center position
98
+ const deltas = new Map<string, number>() // nodeId → shift amount
99
+
100
+ for (const layer of layers) {
101
+ if (layer.length <= 1) continue
102
+
103
+ const positions = layer.map((n) => (isHorizontal ? n.x : n.y))
104
+ const min = Math.min(...positions)
105
+ const max = Math.max(...positions)
106
+ if (max - min <= 1) continue // Already aligned
107
+
108
+ // Use the center of the range as the snap target
109
+ const target = (min + max) / 2
110
+
111
+ for (const node of layer) {
112
+ const oldPos = isHorizontal ? node.x : node.y
113
+ const delta = target - oldPos
114
+ if (Math.abs(delta) > 0.5) {
115
+ if (isHorizontal) {
116
+ node.x = target
117
+ } else {
118
+ node.y = target
119
+ }
120
+ deltas.set(node.id, delta)
121
+ }
122
+ }
123
+ }
124
+
125
+ if (deltas.size === 0) return
126
+
127
+ // Adjust edge endpoints to match shifted node positions
128
+ for (const edge of edges) {
129
+ if (edge.points.length < 2) continue
130
+
131
+ const srcDelta = deltas.get(edge.source)
132
+ const tgtDelta = deltas.get(edge.target)
133
+
134
+ if (srcDelta != null) {
135
+ // Shift first point and any subsequent points in the initial vertical/horizontal run
136
+ const first = edge.points[0]!
137
+ if (isHorizontal) {
138
+ first.x += srcDelta
139
+ // Shift second point if it's part of a straight vertical exit
140
+ if (
141
+ edge.points.length > 1 &&
142
+ edge.points[1]!.x === first.x - srcDelta
143
+ ) {
144
+ edge.points[1]!.x += srcDelta
145
+ }
146
+ } else {
147
+ first.y += srcDelta
148
+ if (
149
+ edge.points.length > 1 &&
150
+ edge.points[1]!.y === first.y - srcDelta
151
+ ) {
152
+ edge.points[1]!.y += srcDelta
153
+ }
154
+ }
155
+ }
156
+
157
+ if (tgtDelta != null) {
158
+ const last = edge.points[edge.points.length - 1]!
159
+ if (isHorizontal) {
160
+ last.x += tgtDelta
161
+ if (edge.points.length > 1) {
162
+ const prev = edge.points[edge.points.length - 2]!
163
+ if (prev.x === last.x - tgtDelta) prev.x += tgtDelta
164
+ }
165
+ } else {
166
+ last.y += tgtDelta
167
+ if (edge.points.length > 1) {
168
+ const prev = edge.points[edge.points.length - 2]!
169
+ if (prev.y === last.y - tgtDelta) prev.y += tgtDelta
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }