@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.
Files changed (52) hide show
  1. package/package.json +22 -18
  2. package/src/LikeC4CustomColors.tsx +24 -21
  3. package/src/LikeC4Diagram.props.ts +15 -9
  4. package/src/LikeC4Diagram.tsx +33 -27
  5. package/src/LikeC4Search.tsx +146 -0
  6. package/src/StaticLikeC4Diagram.tsx +10 -5
  7. package/src/overlays/OverlayContext.tsx +1 -1
  8. package/src/overlays/Overlays.css.ts +28 -23
  9. package/src/overlays/Overlays.tsx +116 -64
  10. package/src/overlays/edge-details/xyflow/ElementNode.tsx +15 -12
  11. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +7 -3
  12. package/src/overlays/edge-details/xyflow/styles.css.ts +22 -16
  13. package/src/overlays/element-details/ElementDetailsCard.css.ts +322 -0
  14. package/src/overlays/element-details/ElementDetailsCard.tsx +639 -0
  15. package/src/overlays/element-details/TabPanelRelationships.css.ts +106 -0
  16. package/src/overlays/element-details/TabPanelRelationships.tsx +201 -0
  17. package/src/overlays/element-details/TabPanelStructure.css.ts +36 -0
  18. package/src/overlays/element-details/TabPanelStructure.tsx +120 -0
  19. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +185 -0
  20. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +179 -213
  21. package/src/overlays/relationships-of/_types.ts +15 -4
  22. package/src/overlays/relationships-of/use-layouted-relationships.ts +301 -207
  23. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +68 -14
  24. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +54 -14
  25. package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +1 -1
  26. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +51 -23
  27. package/src/overlays/relationships-of/xyflow/styles.css.ts +34 -27
  28. package/src/state/DiagramContext.tsx +15 -8
  29. package/src/state/aligners.ts +473 -0
  30. package/src/state/diagramStore.layout.ts +67 -0
  31. package/src/state/diagramStore.ts +18 -73
  32. package/src/theme-vars.ts +9 -0
  33. package/src/ui/FramerMotionConfig.tsx +18 -0
  34. package/src/ui/notation/NotationPanel.tsx +3 -3
  35. package/src/ui/top-left/ManualLayoutToolsButton.tsx +15 -0
  36. package/src/ui/top-left/TopLeftPanel.tsx +14 -1
  37. package/src/xyflow/XYFlow.tsx +18 -35
  38. package/src/xyflow/XYFlowInner.tsx +2 -2
  39. package/src/xyflow/edges/EdgeLabel.tsx +3 -3
  40. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +25 -18
  41. package/src/xyflow/nodes/compound/CompoundNode.css.ts +96 -30
  42. package/src/xyflow/nodes/compound/CompoundNode.tsx +149 -165
  43. package/src/xyflow/nodes/element/ElementDetails.css.ts +101 -0
  44. package/src/xyflow/nodes/element/ElementDetails.tsx +393 -0
  45. package/src/xyflow/nodes/element/ElementNode.tsx +165 -133
  46. package/src/xyflow/nodes/element/element.css.ts +46 -17
  47. package/src/xyflow/nodes/shared/ElementIcon.tsx +43 -0
  48. package/src/xyflow/nodes/shared/Toolbar.tsx +6 -6
  49. package/src/xyflow/{XYFlowEvents.tsx → useXYFlowEvents.ts} +26 -22
  50. package/src/xyflow/utils.ts +12 -3
  51. package/src/LikeC4CustomColors.props.ts +0 -5
  52. /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
- enableRelationshipsBrowser: boolean
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 = view.nodes.find(({ id }) => id === xynodeId)
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({ activeOverlay: overlay }, noReplace, 'openOverlay')
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
- 'openOverlay'
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: (mode: AlignmentMode) => {
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}`,