@likec4/diagram 1.17.0 → 1.18.0
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/package.json +24 -18
- package/src/LikeC4Diagram.props.ts +27 -12
- package/src/LikeC4Diagram.tsx +14 -9
- package/src/LikeC4Search.tsx +149 -0
- package/src/StaticLikeC4Diagram.tsx +1 -0
- package/src/hooks/useDiagramState.ts +8 -3
- package/src/hooks/useXYFlow.ts +2 -1
- package/src/likec4model/useLikeC4ElementsTree.ts +6 -13
- package/src/likec4model/useLikeC4Model.ts +21 -11
- package/src/overlays/Overlays.tsx +96 -63
- package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +3 -1
- package/src/overlays/edge-details/_types.ts +7 -3
- package/src/overlays/edge-details/use-layouted-edge-details.ts +21 -14
- package/src/overlays/edge-details/xyflow/ElementNode.tsx +4 -2
- package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +4 -4
- package/src/overlays/element-details/ElementDetailsCard.tsx +94 -81
- package/src/overlays/element-details/TabPanelDeployments.css.ts +69 -0
- package/src/overlays/element-details/TabPanelDeployments.tsx +217 -0
- package/src/overlays/element-details/TabPanelRelationships.tsx +8 -9
- package/src/overlays/element-details/TabPanelStructure.tsx +10 -10
- package/src/overlays/relationships-of/RelationshipsOverlay.tsx +1 -1
- package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +3 -11
- package/src/overlays/relationships-of/SelectElement.tsx +2 -2
- package/src/overlays/relationships-of/_types.ts +2 -2
- package/src/overlays/relationships-of/use-layouted-relationships.ts +18 -19
- package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +1 -3
- package/src/overlays/relationships-of/xyflow/ElementNode.tsx +12 -6
- package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +0 -1
- package/src/state/aligners.ts +473 -0
- package/src/state/diagramStore.layout.ts +67 -0
- package/src/state/diagramStore.ts +31 -77
- package/src/ui/DiagramTitlePanel.tsx +2 -29
- package/src/ui/Link.tsx +40 -0
- package/src/ui/notation/NotationPanel.tsx +4 -2
- package/src/ui/top-left/ChangeAutoLayoutButton.tsx +2 -2
- package/src/ui/top-left/ManualLayoutToolsButton.tsx +15 -0
- package/src/ui/top-left/TopLeftPanel.tsx +16 -3
- package/src/xyflow/edges/EdgeLabel.tsx +4 -4
- package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +20 -7
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +28 -13
- package/src/xyflow/nodes/compound/CompoundNode.tsx +51 -19
- package/src/xyflow/nodes/element/ElementNode.tsx +153 -92
- package/src/xyflow/nodes/element/element.css.ts +53 -11
- package/src/xyflow/nodes/shared/ElementIcon.tsx +1 -1
- package/src/xyflow/nodes/shared/Toolbar.tsx +24 -8
- package/src/xyflow/useLayoutConstraints.ts +1 -1
- package/src/xyflow/useXYFlowEvents.ts +18 -5
- package/src/ui/OptionsPanel.css.ts +0 -9
- package/src/ui/OptionsPanel.tsx +0 -70
- package/src/ui/options/EdgeOptions.tsx +0 -142
- package/src/ui/options/NodeOptions.tsx +0 -362
- package/src/xyflow/nodes/element/ElementDetails.css.ts +0 -101
- package/src/xyflow/nodes/element/ElementDetails.tsx +0 -393
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { invariant, type NonEmptyArray } from '@likec4/core'
|
|
2
|
+
import type { Rect, XYPosition } from '@xyflow/react'
|
|
3
|
+
import { map, pick, pipe, reduce, sortBy } from 'remeda'
|
|
4
|
+
|
|
5
|
+
export type GridAlignmentMode = 'Column' | 'Row'
|
|
6
|
+
export type LinearAlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
|
|
7
|
+
|
|
8
|
+
export abstract class Aligner {
|
|
9
|
+
abstract computeLayout(nodes: NodeRect[]): void
|
|
10
|
+
abstract applyPosition(node: NodeRect): Partial<XYPosition>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LinearAligner extends Aligner {
|
|
14
|
+
private alignTo: number | undefined
|
|
15
|
+
constructor(
|
|
16
|
+
private getEdgePosition: (nodes: NodeRect[]) => number,
|
|
17
|
+
private computePosition: (alignTo: number, node: NodeRect) => number,
|
|
18
|
+
private propertyToEdit: keyof XYPosition
|
|
19
|
+
) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override computeLayout(nodes: NodeRect[]) {
|
|
24
|
+
this.alignTo = this.getEdgePosition(nodes)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override applyPosition(node: NodeRect): Partial<XYPosition> {
|
|
28
|
+
return {
|
|
29
|
+
[this.propertyToEdit]: this.computePosition(this.alignTo!, node)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type NodeRect = Rect & { id: string }
|
|
35
|
+
type Layer = {
|
|
36
|
+
primaryAxisSize: number
|
|
37
|
+
nodes: NodeRect[]
|
|
38
|
+
occupiedSpace: number
|
|
39
|
+
layout: Layout | null
|
|
40
|
+
}
|
|
41
|
+
type Layout = {
|
|
42
|
+
nodePositions: Map<string, XYPosition>
|
|
43
|
+
refLayer: Layer | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class GridAligner extends Aligner {
|
|
47
|
+
private layout: Map<string, XYPosition> = new Map()
|
|
48
|
+
private axisPreset: {
|
|
49
|
+
primaryAxisDimension: 'height' | 'width'
|
|
50
|
+
secondaryAxisDimension: 'height' | 'width'
|
|
51
|
+
primaryAxisCoord: 'x' | 'y'
|
|
52
|
+
secondaryAxisCoord: 'x' | 'y'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private get primaryAxisCoord() {
|
|
56
|
+
return this.axisPreset.primaryAxisCoord
|
|
57
|
+
}
|
|
58
|
+
private get secondaryAxisCoord() {
|
|
59
|
+
return this.axisPreset.secondaryAxisCoord
|
|
60
|
+
}
|
|
61
|
+
private get primaryAxisDimension() {
|
|
62
|
+
return this.axisPreset.primaryAxisDimension
|
|
63
|
+
}
|
|
64
|
+
private get secondaryAxisDimension() {
|
|
65
|
+
return this.axisPreset.secondaryAxisDimension
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
constructor(alignmentMode: GridAlignmentMode) {
|
|
69
|
+
super()
|
|
70
|
+
|
|
71
|
+
this.axisPreset = alignmentMode === 'Column'
|
|
72
|
+
? {
|
|
73
|
+
primaryAxisDimension: 'width',
|
|
74
|
+
secondaryAxisDimension: 'height',
|
|
75
|
+
primaryAxisCoord: 'x',
|
|
76
|
+
secondaryAxisCoord: 'y'
|
|
77
|
+
}
|
|
78
|
+
: {
|
|
79
|
+
primaryAxisDimension: 'height',
|
|
80
|
+
secondaryAxisDimension: 'width',
|
|
81
|
+
primaryAxisCoord: 'y',
|
|
82
|
+
secondaryAxisCoord: 'x'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override applyPosition(node: NodeRect): Partial<XYPosition> {
|
|
87
|
+
return this.layout?.get(node.id) ?? {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override computeLayout(nodes: NodeRect[]) {
|
|
91
|
+
// Sort by primary axis
|
|
92
|
+
const sortedNodeRects = pipe(
|
|
93
|
+
nodes,
|
|
94
|
+
sortBy(r => r[this.primaryAxisCoord])
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const layoutRect = this.getLayoutRect(sortedNodeRects)
|
|
98
|
+
|
|
99
|
+
const layers = this.getLayers(sortedNodeRects)
|
|
100
|
+
|
|
101
|
+
this.layout = this.buildLayout(layers, layoutRect, sortedNodeRects)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private getLayoutRect(nodeRects: NodeRect[]): Rect {
|
|
105
|
+
const x = Math.min(...nodeRects.map(n => n.x))
|
|
106
|
+
const y = Math.min(...nodeRects.map(n => n.y))
|
|
107
|
+
const right = Math.max(...nodeRects.map(n => n.x + n.width))
|
|
108
|
+
const bottom = Math.max(...nodeRects.map(n => n.y + n.height))
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
x,
|
|
112
|
+
y,
|
|
113
|
+
width: right - x,
|
|
114
|
+
height: bottom - y
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private getLayers(sortedNodeRects: NodeRect[]): NonEmptyArray<Layer> {
|
|
119
|
+
const layers: Layer[] = []
|
|
120
|
+
let layerEnd = 0
|
|
121
|
+
let layer = null
|
|
122
|
+
|
|
123
|
+
for (let node of sortedNodeRects) {
|
|
124
|
+
if (!!layer && node[this.primaryAxisCoord] < layerEnd) {
|
|
125
|
+
layer.nodes.push(node)
|
|
126
|
+
layer.primaryAxisSize = Math.max(layer.primaryAxisSize, node[this.primaryAxisDimension])
|
|
127
|
+
layer.occupiedSpace += node[this.secondaryAxisDimension]
|
|
128
|
+
layerEnd = Math.max(
|
|
129
|
+
node[this.primaryAxisCoord] + node[this.primaryAxisDimension],
|
|
130
|
+
layerEnd
|
|
131
|
+
)
|
|
132
|
+
} else {
|
|
133
|
+
layer = {
|
|
134
|
+
primaryAxisSize: node[this.primaryAxisDimension],
|
|
135
|
+
nodes: [node],
|
|
136
|
+
occupiedSpace: node[this.secondaryAxisDimension],
|
|
137
|
+
layout: null
|
|
138
|
+
}
|
|
139
|
+
layers.push(layer)
|
|
140
|
+
layerEnd = node[this.primaryAxisCoord] + node[this.primaryAxisDimension]
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
layers.forEach(l =>
|
|
146
|
+
l.nodes.sort((a, b) => a[this.secondaryAxisCoord] - b[this.secondaryAxisCoord])
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return layers as NonEmptyArray<Layer>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private buildLayout(layers: NonEmptyArray<Layer>, layoutRect: Rect, nodeRects: NodeRect[]): Map<string, XYPosition> {
|
|
153
|
+
const nodeMap = new Map(nodeRects.map(n => [n.id, n]))
|
|
154
|
+
const layout: [string, XYPosition][] = []
|
|
155
|
+
const occupiedSpace = layers.reduce((a, b) => a + b.primaryAxisSize, 0)
|
|
156
|
+
const rowMargin = layers.length > 1
|
|
157
|
+
? (layoutRect[this.primaryAxisDimension] - occupiedSpace) / (layers.length - 1)
|
|
158
|
+
: 0
|
|
159
|
+
|
|
160
|
+
// Find the widest layer and layout diagram from there
|
|
161
|
+
const baseLayerIndex = layers.reduce(
|
|
162
|
+
(widestLayerIndex, layer, i) =>
|
|
163
|
+
layers[widestLayerIndex]!.occupiedSpace < layer.occupiedSpace ? i : widestLayerIndex,
|
|
164
|
+
0
|
|
165
|
+
)
|
|
166
|
+
const baseLayer = layers[baseLayerIndex]!
|
|
167
|
+
const baseLayerPosition = layers.slice(0, baseLayerIndex).reduce(
|
|
168
|
+
(a, layer) => a + layer.primaryAxisSize + rowMargin,
|
|
169
|
+
layoutRect[this.primaryAxisCoord]
|
|
170
|
+
)
|
|
171
|
+
const baseLayerLayout = this.buildLayerLayout(
|
|
172
|
+
baseLayer,
|
|
173
|
+
layoutRect,
|
|
174
|
+
baseLayerPosition,
|
|
175
|
+
nodeMap,
|
|
176
|
+
null
|
|
177
|
+
)
|
|
178
|
+
baseLayer.layout = baseLayerLayout
|
|
179
|
+
layout.push(...baseLayerLayout.nodePositions)
|
|
180
|
+
|
|
181
|
+
// Layout layers after the base layer
|
|
182
|
+
let placeNextLayerAt = baseLayerPosition + baseLayer.primaryAxisSize + rowMargin
|
|
183
|
+
let refLayer = baseLayer
|
|
184
|
+
for (let i = baseLayerIndex + 1; i < layers.length; i++) {
|
|
185
|
+
const layer = layers[i]!
|
|
186
|
+
layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
|
|
187
|
+
|
|
188
|
+
layout.push(...layer.layout.nodePositions)
|
|
189
|
+
refLayer = layer.layout.refLayer ?? layer
|
|
190
|
+
placeNextLayerAt += layer.primaryAxisSize + rowMargin
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Layout layers before the base layer
|
|
194
|
+
placeNextLayerAt = baseLayerPosition
|
|
195
|
+
refLayer = baseLayer
|
|
196
|
+
for (let i = baseLayerIndex - 1; i >= 0; i--) {
|
|
197
|
+
const layer = layers[i]!
|
|
198
|
+
placeNextLayerAt -= layer.primaryAxisSize + rowMargin
|
|
199
|
+
|
|
200
|
+
layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
|
|
201
|
+
|
|
202
|
+
layout.push(...layer.layout.nodePositions)
|
|
203
|
+
refLayer = layer.layout.refLayer ?? layer
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return new Map(layout)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private buildLayerLayout(
|
|
210
|
+
layer: Layer,
|
|
211
|
+
layoutRect: Rect,
|
|
212
|
+
placeNextLayerAt: number,
|
|
213
|
+
nodeMap: Map<string, NodeRect>,
|
|
214
|
+
refLayer: Layer | null
|
|
215
|
+
): Layout {
|
|
216
|
+
let bestLayerLayout = this.scoreLayout(
|
|
217
|
+
this.spaceAround(layer, layoutRect, placeNextLayerAt),
|
|
218
|
+
nodeMap
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (layer.nodes.length != 1) {
|
|
222
|
+
const currentlayerLayout = this.scoreLayout(
|
|
223
|
+
this.spaceBetween(layer, layoutRect, placeNextLayerAt),
|
|
224
|
+
nodeMap
|
|
225
|
+
)
|
|
226
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (refLayer && refLayer.nodes.length - 1 >= layer.nodes.length) {
|
|
230
|
+
const currentlayerLayout = this.scoreLayout(
|
|
231
|
+
this.placeInGaps(layer, placeNextLayerAt, refLayer),
|
|
232
|
+
nodeMap
|
|
233
|
+
)
|
|
234
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (refLayer && refLayer.nodes.length >= layer.nodes.length) {
|
|
238
|
+
const currentlayerLayout = this.scoreLayout(
|
|
239
|
+
this.placeInCells(layer, placeNextLayerAt, refLayer),
|
|
240
|
+
nodeMap
|
|
241
|
+
)
|
|
242
|
+
bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return bestLayerLayout[1]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private spaceBetween(
|
|
249
|
+
layer: Layer,
|
|
250
|
+
layoutRect: Rect,
|
|
251
|
+
placeNextLayerAt: number
|
|
252
|
+
): Layout {
|
|
253
|
+
const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
|
|
254
|
+
const margin = freeSpace / (layer.nodes.length - 1)
|
|
255
|
+
|
|
256
|
+
let placeNextNodeAt = layoutRect[this.secondaryAxisCoord]
|
|
257
|
+
const result = new Map<string, XYPosition>()
|
|
258
|
+
|
|
259
|
+
let i = 0
|
|
260
|
+
for (let node of layer.nodes) {
|
|
261
|
+
result.set(node.id, {
|
|
262
|
+
[this.secondaryAxisCoord]: placeNextNodeAt,
|
|
263
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
264
|
+
} as XYPosition)
|
|
265
|
+
placeNextNodeAt += node[this.secondaryAxisDimension] + margin
|
|
266
|
+
i++
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { nodePositions: result, refLayer: null }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private spaceAround(
|
|
273
|
+
layer: Layer,
|
|
274
|
+
layoutRect: Rect,
|
|
275
|
+
placeNextLayerAt: number
|
|
276
|
+
): Layout {
|
|
277
|
+
const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
|
|
278
|
+
const margin = freeSpace / (layer.nodes.length + 1)
|
|
279
|
+
|
|
280
|
+
let placeNextNodeAt = layoutRect[this.secondaryAxisCoord] + margin
|
|
281
|
+
const result = new Map<string, XYPosition>()
|
|
282
|
+
|
|
283
|
+
for (let node of sortBy(layer.nodes, n => n[this.secondaryAxisCoord])) {
|
|
284
|
+
result.set(node.id, {
|
|
285
|
+
[this.secondaryAxisCoord]: placeNextNodeAt,
|
|
286
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
287
|
+
} as XYPosition)
|
|
288
|
+
placeNextNodeAt += node[this.secondaryAxisDimension] + margin
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { nodePositions: result, refLayer: null }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private placeInGaps(
|
|
295
|
+
layer: Layer,
|
|
296
|
+
placeNextLayerAt: number,
|
|
297
|
+
refLayer: Layer
|
|
298
|
+
): Layout {
|
|
299
|
+
const result = new Map<string, XYPosition>()
|
|
300
|
+
|
|
301
|
+
const nodes = layer.nodes
|
|
302
|
+
const placementOptions = this.getGapsPositions(refLayer)
|
|
303
|
+
|
|
304
|
+
let optionIndex = 0
|
|
305
|
+
for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
|
|
306
|
+
const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
|
|
307
|
+
|
|
308
|
+
let bestOffset = Infinity
|
|
309
|
+
while (optionIndex - i <= placementOptions.length - nodes.length) {
|
|
310
|
+
let position = placementOptions[optionIndex]!
|
|
311
|
+
const offset = position - nodeCenter
|
|
312
|
+
|
|
313
|
+
if (Math.abs(offset) < Math.abs(bestOffset)) {
|
|
314
|
+
bestOffset = offset
|
|
315
|
+
|
|
316
|
+
optionIndex++
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
result.set(node.id, {
|
|
324
|
+
[this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
|
|
325
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
326
|
+
} as XYPosition)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { nodePositions: result, refLayer }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private placeInCells(
|
|
333
|
+
layer: Layer,
|
|
334
|
+
placeNextLayerAt: number,
|
|
335
|
+
refLayer: Layer
|
|
336
|
+
): Layout {
|
|
337
|
+
const result = new Map<string, XYPosition>()
|
|
338
|
+
|
|
339
|
+
const nodes = layer.nodes
|
|
340
|
+
const placementOptions = this.getNodePositions(refLayer)
|
|
341
|
+
|
|
342
|
+
let optionIndex = 0
|
|
343
|
+
for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
|
|
344
|
+
const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
|
|
345
|
+
|
|
346
|
+
let bestOffset = Infinity
|
|
347
|
+
|
|
348
|
+
while (optionIndex - i <= placementOptions.length - nodes.length) {
|
|
349
|
+
let position = placementOptions[optionIndex]!
|
|
350
|
+
const offset = position - nodeCenter
|
|
351
|
+
|
|
352
|
+
if (Math.abs(offset) < Math.abs(bestOffset)) {
|
|
353
|
+
bestOffset = offset
|
|
354
|
+
|
|
355
|
+
optionIndex++
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
result.set(node.id, {
|
|
363
|
+
[this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
|
|
364
|
+
[this.primaryAxisCoord]: placeNextLayerAt
|
|
365
|
+
} as XYPosition)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { nodePositions: result, refLayer }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private scoreLayout(
|
|
372
|
+
layout: Layout,
|
|
373
|
+
originalRects: Map<string, NodeRect>
|
|
374
|
+
): [number, Layout] {
|
|
375
|
+
return [
|
|
376
|
+
pipe(
|
|
377
|
+
Array.from(layout.nodePositions),
|
|
378
|
+
map(([id, position]) => {
|
|
379
|
+
const originalRect = originalRects.get(id)
|
|
380
|
+
invariant(originalRect, `Could not find original rect for node ${id}`)
|
|
381
|
+
return [pick(originalRect, ['x', 'y']), position]
|
|
382
|
+
}),
|
|
383
|
+
map(([original, suggested]) =>
|
|
384
|
+
Math.abs(original![this.secondaryAxisCoord] - suggested![this.secondaryAxisCoord])
|
|
385
|
+
),
|
|
386
|
+
reduce((a, b) => a + b, 0)
|
|
387
|
+
),
|
|
388
|
+
layout
|
|
389
|
+
]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private getGapsPositions(layer: Layer): number[] {
|
|
393
|
+
const result = []
|
|
394
|
+
const { layout, nodes } = layer
|
|
395
|
+
|
|
396
|
+
invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
|
|
397
|
+
|
|
398
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
399
|
+
const previousNode = nodes[i - 1]!
|
|
400
|
+
const currentNode = nodes[i]!
|
|
401
|
+
const previousNodePosition = layout.nodePositions.get(previousNode.id)!
|
|
402
|
+
const currentNodePosition = layout.nodePositions.get(currentNode.id)!
|
|
403
|
+
|
|
404
|
+
result.push(
|
|
405
|
+
(currentNodePosition[this.secondaryAxisCoord]
|
|
406
|
+
+ previousNodePosition[this.secondaryAxisCoord]
|
|
407
|
+
+ previousNode[this.secondaryAxisDimension]) / 2
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return result
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private getNodePositions(layer: Layer): number[] {
|
|
415
|
+
const result = []
|
|
416
|
+
const { layout, nodes } = layer
|
|
417
|
+
|
|
418
|
+
invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
421
|
+
const node = nodes[i]!
|
|
422
|
+
const nodePosition = layout.nodePositions.get(node.id)!
|
|
423
|
+
|
|
424
|
+
result.push(
|
|
425
|
+
nodePosition[this.secondaryAxisCoord]
|
|
426
|
+
+ node[this.secondaryAxisDimension] / 2
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return result
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function getLinearAligner(mode: LinearAlignmentMode): Aligner {
|
|
435
|
+
switch (mode) {
|
|
436
|
+
case 'Left':
|
|
437
|
+
return new LinearAligner(
|
|
438
|
+
nodes => Math.min(...nodes.map(n => n.x)),
|
|
439
|
+
(alignTo, _) => Math.floor(alignTo),
|
|
440
|
+
'x'
|
|
441
|
+
)
|
|
442
|
+
case 'Top':
|
|
443
|
+
return new LinearAligner(
|
|
444
|
+
nodes => Math.min(...nodes.map(n => n.y)),
|
|
445
|
+
(alignTo, _) => Math.floor(alignTo),
|
|
446
|
+
'y'
|
|
447
|
+
)
|
|
448
|
+
case 'Right':
|
|
449
|
+
return new LinearAligner(
|
|
450
|
+
nodes => Math.max(...nodes.map(n => n.x + n.width)),
|
|
451
|
+
(alignTo, node) => Math.floor(alignTo - node.width!),
|
|
452
|
+
'x'
|
|
453
|
+
)
|
|
454
|
+
case 'Bottom':
|
|
455
|
+
return new LinearAligner(
|
|
456
|
+
nodes => Math.max(...nodes.map(n => n.y + n.height)),
|
|
457
|
+
(alignTo, node) => Math.floor(alignTo - node.height!),
|
|
458
|
+
'y'
|
|
459
|
+
)
|
|
460
|
+
case 'Center':
|
|
461
|
+
return new LinearAligner(
|
|
462
|
+
nodes => Math.min(...nodes.map(n => n.x + n.width / 2)),
|
|
463
|
+
(alignTo, node) => Math.floor(alignTo - node.width / 2),
|
|
464
|
+
'x'
|
|
465
|
+
)
|
|
466
|
+
case 'Middle':
|
|
467
|
+
return new LinearAligner(
|
|
468
|
+
nodes => Math.min(...nodes.map(n => n.y + n.height / 2)),
|
|
469
|
+
(alignTo, node) => Math.floor(alignTo - node.height / 2),
|
|
470
|
+
'y'
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { nonexhaustive } from '@likec4/core'
|
|
2
|
+
import type { InternalNode } from '@xyflow/react'
|
|
3
|
+
import { getNodeDimensions } from '@xyflow/system'
|
|
4
|
+
import { hasAtLeast } from 'remeda'
|
|
5
|
+
import type { DiagramState } from '../hooks'
|
|
6
|
+
import type { XYFlowNode } from '../xyflow/types'
|
|
7
|
+
import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
|
|
8
|
+
import { type Aligner, getLinearAligner, GridAligner, type GridAlignmentMode, type LinearAlignmentMode, type NodeRect } from './aligners'
|
|
9
|
+
|
|
10
|
+
export type AlignmentMode = LinearAlignmentMode | GridAlignmentMode
|
|
11
|
+
|
|
12
|
+
export function align(get: () => DiagramState) {
|
|
13
|
+
return (mode: AlignmentMode) => {
|
|
14
|
+
const { scheduleSaveManualLayout, xystore } = get()
|
|
15
|
+
const { nodeLookup, parentLookup } = xystore.getState()
|
|
16
|
+
|
|
17
|
+
const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
|
|
18
|
+
const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
|
|
19
|
+
|
|
20
|
+
if (!hasAtLeast(nodesToAlign, 2)) {
|
|
21
|
+
console.warn('At least 2 nodes must be selected to align')
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const constraints = createLayoutConstraints(xystore, nodesToAlign)
|
|
25
|
+
|
|
26
|
+
const aligner = getAligner(mode)
|
|
27
|
+
|
|
28
|
+
constraints.onMove(nodes => {
|
|
29
|
+
aligner.computeLayout(nodes.map(({ node }) => toNodeRect(node)))
|
|
30
|
+
|
|
31
|
+
nodes.forEach(({ rect, node }) => {
|
|
32
|
+
rect.positionAbsolute = {
|
|
33
|
+
...rect.positionAbsolute,
|
|
34
|
+
...aligner.applyPosition(toNodeRect(node))
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
scheduleSaveManualLayout()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toNodeRect(node: InternalNode<XYFlowNode>): NodeRect {
|
|
44
|
+
return {
|
|
45
|
+
...node.internals.positionAbsolute,
|
|
46
|
+
id: node.id,
|
|
47
|
+
width: getNodeDimensions(node).width,
|
|
48
|
+
height: getNodeDimensions(node).height
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getAligner(mode: AlignmentMode): Aligner {
|
|
53
|
+
switch (mode) {
|
|
54
|
+
case 'Left':
|
|
55
|
+
case 'Right':
|
|
56
|
+
case 'Top':
|
|
57
|
+
case 'Bottom':
|
|
58
|
+
case 'Center':
|
|
59
|
+
case 'Middle':
|
|
60
|
+
return getLinearAligner(mode)
|
|
61
|
+
case 'Column':
|
|
62
|
+
case 'Row':
|
|
63
|
+
return new GridAligner(mode)
|
|
64
|
+
default:
|
|
65
|
+
nonexhaustive(mode)
|
|
66
|
+
}
|
|
67
|
+
}
|