@likec4/diagram 1.16.0 → 1.17.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/package.json +22 -18
- package/src/LikeC4CustomColors.tsx +24 -21
- package/src/LikeC4Diagram.props.ts +15 -9
- package/src/LikeC4Diagram.tsx +33 -27
- package/src/LikeC4Search.tsx +146 -0
- package/src/StaticLikeC4Diagram.tsx +10 -5
- package/src/overlays/OverlayContext.tsx +1 -1
- package/src/overlays/Overlays.css.ts +28 -23
- package/src/overlays/Overlays.tsx +116 -64
- package/src/overlays/edge-details/xyflow/ElementNode.tsx +15 -12
- package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +7 -3
- package/src/overlays/edge-details/xyflow/styles.css.ts +22 -16
- package/src/overlays/element-details/ElementDetailsCard.css.ts +322 -0
- package/src/overlays/element-details/ElementDetailsCard.tsx +639 -0
- package/src/overlays/element-details/TabPanelRelationships.css.ts +106 -0
- package/src/overlays/element-details/TabPanelRelationships.tsx +201 -0
- package/src/overlays/element-details/TabPanelStructure.css.ts +36 -0
- package/src/overlays/element-details/TabPanelStructure.tsx +120 -0
- package/src/overlays/relationships-of/RelationshipsOverlay.tsx +185 -0
- package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +179 -213
- package/src/overlays/relationships-of/_types.ts +15 -4
- package/src/overlays/relationships-of/use-layouted-relationships.ts +301 -207
- package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +68 -14
- package/src/overlays/relationships-of/xyflow/ElementNode.tsx +54 -14
- package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +1 -1
- package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +51 -23
- package/src/overlays/relationships-of/xyflow/styles.css.ts +34 -27
- package/src/state/DiagramContext.tsx +15 -8
- package/src/state/aligners.ts +473 -0
- package/src/state/diagramStore.layout.ts +67 -0
- package/src/state/diagramStore.ts +18 -73
- package/src/theme-vars.ts +9 -0
- package/src/ui/FramerMotionConfig.tsx +18 -0
- package/src/ui/notation/NotationPanel.tsx +3 -3
- package/src/ui/top-left/ManualLayoutToolsButton.tsx +15 -0
- package/src/ui/top-left/TopLeftPanel.tsx +14 -1
- package/src/xyflow/XYFlow.tsx +18 -35
- package/src/xyflow/XYFlowInner.tsx +2 -2
- package/src/xyflow/edges/EdgeLabel.tsx +3 -3
- package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +25 -18
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +96 -30
- package/src/xyflow/nodes/compound/CompoundNode.tsx +149 -165
- package/src/xyflow/nodes/element/ElementDetails.css.ts +101 -0
- package/src/xyflow/nodes/element/ElementDetails.tsx +393 -0
- package/src/xyflow/nodes/element/ElementNode.tsx +165 -133
- package/src/xyflow/nodes/element/element.css.ts +46 -17
- package/src/xyflow/nodes/shared/ElementIcon.tsx +43 -0
- package/src/xyflow/nodes/shared/Toolbar.tsx +6 -6
- package/src/xyflow/{XYFlowEvents.tsx → useXYFlowEvents.ts} +26 -22
- package/src/xyflow/utils.ts +12 -3
- package/src/LikeC4CustomColors.props.ts +0 -5
- /package/src/{mantine → ui}/EnsureMantine.tsx +0 -0
|
@@ -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
|
+
}
|
|
@@ -49,6 +49,7 @@ import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
|
|
|
49
49
|
import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
|
|
50
50
|
import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
|
|
51
51
|
import { diagramViewToXYFlowData } from './diagram-to-xyflow'
|
|
52
|
+
import { align, type AlignmentMode } from './diagramStore.layout'
|
|
52
53
|
|
|
53
54
|
type RequiredOrNull<T> = {
|
|
54
55
|
[P in keyof T]-?: NonNullable<T[P]> | null
|
|
@@ -60,10 +61,8 @@ export type DiagramInitialState = {
|
|
|
60
61
|
// If LikeC4Model provided
|
|
61
62
|
hasLikeC4Model: boolean
|
|
62
63
|
controls: boolean
|
|
63
|
-
showElementLinks: boolean
|
|
64
64
|
showNavigationButtons: boolean
|
|
65
65
|
showNotations: boolean
|
|
66
|
-
showRelationshipDetails: boolean
|
|
67
66
|
fitViewEnabled: boolean
|
|
68
67
|
fitViewPadding: number
|
|
69
68
|
zoomable: boolean
|
|
@@ -72,7 +71,10 @@ export type DiagramInitialState = {
|
|
|
72
71
|
nodesSelectable: boolean
|
|
73
72
|
experimentalEdgeEditing: boolean
|
|
74
73
|
enableFocusMode: boolean
|
|
75
|
-
|
|
74
|
+
enableElementDetails: boolean
|
|
75
|
+
enableRelationshipBrowser: boolean
|
|
76
|
+
enableRelationshipDetails: boolean
|
|
77
|
+
enableSearch: boolean
|
|
76
78
|
renderIcon: ElementIconRenderer | null
|
|
77
79
|
whereFilter: WhereOperator<string, string> | null
|
|
78
80
|
// If Dynamic View
|
|
@@ -85,8 +87,6 @@ export type DiagramInitialState = {
|
|
|
85
87
|
getContainer: () => HTMLDivElement | null
|
|
86
88
|
} & RequiredOrNull<LikeC4DiagramEventHandlers>
|
|
87
89
|
|
|
88
|
-
type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
|
|
89
|
-
|
|
90
90
|
const StringSet = Set<string>
|
|
91
91
|
|
|
92
92
|
export type DiagramState = Simplify<
|
|
@@ -104,6 +104,7 @@ export type DiagramState = Simplify<
|
|
|
104
104
|
activeOverlay:
|
|
105
105
|
| null
|
|
106
106
|
| RequireExactlyOne<{
|
|
107
|
+
elementDetails: Fqn
|
|
107
108
|
relationshipsOf: Fqn
|
|
108
109
|
edgeDetails: EdgeId
|
|
109
110
|
}>
|
|
@@ -133,7 +134,7 @@ export type DiagramState = Simplify<
|
|
|
133
134
|
lastOnNavigate: null | {
|
|
134
135
|
fromView: ViewID
|
|
135
136
|
toView: ViewID
|
|
136
|
-
fromNode: NodeId
|
|
137
|
+
fromNode: NodeId | null
|
|
137
138
|
}
|
|
138
139
|
navigationHistory: Array<{
|
|
139
140
|
viewId: ViewID
|
|
@@ -776,7 +777,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
776
777
|
}
|
|
777
778
|
const xynode = xynodes.find(({ id }) => id === xynodeId)
|
|
778
779
|
invariant(xynode, `xynode not found: ${xynodeId}`)
|
|
779
|
-
const element =
|
|
780
|
+
const element = xynode.data.element
|
|
780
781
|
invariant(element?.navigateTo, `node is not navigable: ${xynodeId}`)
|
|
781
782
|
cancelSaveManualLayout()
|
|
782
783
|
set(
|
|
@@ -853,7 +854,14 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
853
854
|
if (eq(overlay, get().activeOverlay)) {
|
|
854
855
|
return
|
|
855
856
|
}
|
|
856
|
-
set(
|
|
857
|
+
set(
|
|
858
|
+
{
|
|
859
|
+
activeWalkthrough: null,
|
|
860
|
+
activeOverlay: overlay
|
|
861
|
+
},
|
|
862
|
+
noReplace,
|
|
863
|
+
'openOverlay'
|
|
864
|
+
)
|
|
857
865
|
},
|
|
858
866
|
closeOverlay: () => {
|
|
859
867
|
if (get().activeOverlay !== null) {
|
|
@@ -862,7 +870,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
862
870
|
activeOverlay: null
|
|
863
871
|
},
|
|
864
872
|
noReplace,
|
|
865
|
-
'
|
|
873
|
+
'closeOverlay'
|
|
866
874
|
)
|
|
867
875
|
}
|
|
868
876
|
},
|
|
@@ -1060,70 +1068,7 @@ export function createDiagramStore(props: DiagramInitialState) {
|
|
|
1060
1068
|
return vector(v).mul(scale).add(nodeCenter)
|
|
1061
1069
|
}
|
|
1062
1070
|
},
|
|
1063
|
-
align: (
|
|
1064
|
-
const { scheduleSaveManualLayout, xystore } = get()
|
|
1065
|
-
const { nodeLookup, parentLookup } = xystore.getState()
|
|
1066
|
-
|
|
1067
|
-
const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
|
|
1068
|
-
const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
|
|
1069
|
-
|
|
1070
|
-
if (!hasAtLeast(nodesToAlign, 2)) {
|
|
1071
|
-
console.warn('At least 2 nodes must be selected to align')
|
|
1072
|
-
return
|
|
1073
|
-
}
|
|
1074
|
-
const constraints = createLayoutConstraints(xystore, nodesToAlign)
|
|
1075
|
-
|
|
1076
|
-
let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
|
|
1077
|
-
let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
|
|
1078
|
-
let propertyToEdit: keyof XYPosition
|
|
1079
|
-
|
|
1080
|
-
switch (mode) {
|
|
1081
|
-
case 'Left':
|
|
1082
|
-
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
|
|
1083
|
-
propertyToEdit = 'x'
|
|
1084
|
-
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1085
|
-
break
|
|
1086
|
-
case 'Top':
|
|
1087
|
-
getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
|
|
1088
|
-
propertyToEdit = 'y'
|
|
1089
|
-
getPosition = (alignTo, _) => Math.floor(alignTo)
|
|
1090
|
-
break
|
|
1091
|
-
case 'Right':
|
|
1092
|
-
getEdgePosition = nodes =>
|
|
1093
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
|
|
1094
|
-
propertyToEdit = 'x'
|
|
1095
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
|
|
1096
|
-
break
|
|
1097
|
-
case 'Bottom':
|
|
1098
|
-
getEdgePosition = nodes =>
|
|
1099
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
|
|
1100
|
-
propertyToEdit = 'y'
|
|
1101
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
|
|
1102
|
-
break
|
|
1103
|
-
case 'Center':
|
|
1104
|
-
getEdgePosition = nodes =>
|
|
1105
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
|
|
1106
|
-
propertyToEdit = 'x'
|
|
1107
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
|
|
1108
|
-
break
|
|
1109
|
-
case 'Middle':
|
|
1110
|
-
getEdgePosition = nodes =>
|
|
1111
|
-
Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
|
|
1112
|
-
propertyToEdit = 'y'
|
|
1113
|
-
getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
|
|
1114
|
-
break
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
constraints.onMove((nodes) => {
|
|
1118
|
-
const alignTo = getEdgePosition(nodes.map(({ node }) => node))
|
|
1119
|
-
|
|
1120
|
-
nodes.forEach(({ rect, node }) => {
|
|
1121
|
-
rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
|
|
1122
|
-
})
|
|
1123
|
-
})
|
|
1124
|
-
|
|
1125
|
-
scheduleSaveManualLayout()
|
|
1126
|
-
}
|
|
1071
|
+
align: align(get)
|
|
1127
1072
|
} satisfies DiagramState),
|
|
1128
1073
|
{
|
|
1129
1074
|
name: `${storeDevId} - ${props.view.id}`,
|