@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -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
|
+
}
|