@likec4/diagram 1.15.1 → 1.17.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.
Files changed (47) hide show
  1. package/package.json +7 -7
  2. package/src/LikeC4CustomColors.tsx +24 -21
  3. package/src/LikeC4Diagram.props.ts +9 -9
  4. package/src/LikeC4Diagram.tsx +25 -26
  5. package/src/StaticLikeC4Diagram.tsx +9 -3
  6. package/src/overlays/OverlayContext.tsx +1 -1
  7. package/src/overlays/Overlays.css.ts +28 -23
  8. package/src/overlays/Overlays.tsx +116 -64
  9. package/src/overlays/edge-details/xyflow/ElementNode.tsx +15 -12
  10. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +7 -3
  11. package/src/overlays/edge-details/xyflow/styles.css.ts +22 -16
  12. package/src/overlays/element-details/ElementDetailsCard.css.ts +322 -0
  13. package/src/overlays/element-details/ElementDetailsCard.tsx +639 -0
  14. package/src/overlays/element-details/TabPanelRelationships.css.ts +106 -0
  15. package/src/overlays/element-details/TabPanelRelationships.tsx +201 -0
  16. package/src/overlays/element-details/TabPanelStructure.css.ts +36 -0
  17. package/src/overlays/element-details/TabPanelStructure.tsx +120 -0
  18. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +185 -0
  19. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +179 -213
  20. package/src/overlays/relationships-of/_types.ts +15 -4
  21. package/src/overlays/relationships-of/use-layouted-relationships.ts +301 -207
  22. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +68 -14
  23. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +54 -14
  24. package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +1 -1
  25. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +51 -23
  26. package/src/overlays/relationships-of/xyflow/styles.css.ts +34 -27
  27. package/src/state/DiagramContext.tsx +15 -8
  28. package/src/state/diagramStore.ts +14 -6
  29. package/src/theme-vars.ts +9 -0
  30. package/src/ui/FramerMotionConfig.tsx +18 -0
  31. package/src/ui/notation/NotationPanel.tsx +3 -3
  32. package/src/xyflow/XYFlow.tsx +18 -35
  33. package/src/xyflow/XYFlowInner.tsx +2 -2
  34. package/src/xyflow/edges/EdgeLabel.tsx +3 -3
  35. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +25 -18
  36. package/src/xyflow/nodes/compound/CompoundNode.css.ts +96 -30
  37. package/src/xyflow/nodes/compound/CompoundNode.tsx +149 -165
  38. package/src/xyflow/nodes/element/ElementDetails.css.ts +101 -0
  39. package/src/xyflow/nodes/element/ElementDetails.tsx +393 -0
  40. package/src/xyflow/nodes/element/ElementNode.tsx +110 -116
  41. package/src/xyflow/nodes/element/element.css.ts +38 -18
  42. package/src/xyflow/nodes/shared/ElementIcon.tsx +43 -0
  43. package/src/xyflow/nodes/shared/Toolbar.tsx +6 -6
  44. package/src/xyflow/{XYFlowEvents.tsx → useXYFlowEvents.ts} +26 -22
  45. package/src/xyflow/utils.ts +12 -3
  46. package/src/LikeC4CustomColors.props.ts +0 -5
  47. /package/src/{mantine → ui}/EnsureMantine.tsx +0 -0
@@ -1,69 +1,84 @@
1
- import dagre, { type GraphLabel, type Label } from '@dagrejs/dagre'
1
+ import dagre, { type EdgeConfig, type GraphLabel } from '@dagrejs/dagre'
2
2
  import {
3
- compareFqnHierarchically,
3
+ compareByFqnHierarchically,
4
+ compareNatural,
5
+ compareRelations,
4
6
  type DiagramNode,
5
7
  type DiagramView,
6
8
  type Fqn,
7
9
  invariant,
8
10
  isAncestor,
9
11
  type LikeC4Model,
10
- type NonEmptyArray,
11
- type Relation
12
+ nonNullable,
13
+ parentFqn,
14
+ type RelationID
12
15
  } from '@likec4/core'
13
16
  import { MarkerType } from '@xyflow/system'
14
17
  import { useMemo } from 'react'
15
18
  import {
16
- entries,
17
19
  filter,
18
20
  find,
19
21
  first,
22
+ flatMap,
20
23
  forEach,
21
- fromKeys,
24
+ forEachObj,
22
25
  groupBy,
26
+ isNonNullish,
23
27
  isTruthy,
24
28
  map,
29
+ mapToObj,
25
30
  only,
26
31
  pipe,
27
32
  prop,
33
+ reduce,
28
34
  reverse,
29
35
  sort,
30
36
  sortBy,
31
37
  takeWhile,
32
38
  tap
33
39
  } from 'remeda'
34
- import { useDiagramState } from '../../hooks/useDiagramState'
35
40
  import { useLikeC4Model } from '../../likec4model'
36
41
  import type { XYFlowTypes } from './_types'
37
42
 
38
- const graphColumn = <C extends 'incomers' | 'subject' | 'outgoers'>(c: C) => ({
39
- name: c,
40
- id: `__${c}`,
41
- anchor: `__${c}::anchor`
42
- })
43
+ const columns = ['incomers', 'subjects', 'outgoers'] as const
44
+ type ColumnKey = typeof columns[number]
45
+
46
+ // const graphColumn = <C extends 'incomers' | 'subjects' | 'outgoers'>(c: C) => ({
47
+ // name: c,
48
+ // id: `__${c}`,
49
+ // anchor: `__${c}::anchor`
50
+ // })
43
51
 
44
52
  /**
45
53
  * All constants related to the layout
46
54
  */
47
55
  const Sizes = {
48
56
  dagre: {
49
- ranksep: 40,
50
- nodesep: 20,
51
- edgesep: 20
57
+ ranksep: 60,
58
+ nodesep: 35,
59
+ edgesep: 25
52
60
  } satisfies GraphLabel,
53
61
  edgeLabel: {
54
- width: 65
55
- } satisfies Label,
62
+ width: 120,
63
+ height: 10,
64
+ minlen: 1,
65
+ weight: 1
66
+ } satisfies EdgeConfig,
56
67
 
57
- emptyNodeOffset: 150,
68
+ emptyNodeOffset: 120,
58
69
 
59
- nodeWidth: 270,
60
- hodeHeight: 160,
70
+ nodeWidth: 320,
71
+ hodeHeight: 180,
61
72
 
62
73
  // Spacer between elements in a compound node
63
74
  // 0 means no spacer
64
75
  spacerHeight: 0,
65
76
 
66
- compoundLabelHeight: 1
77
+ compound: {
78
+ labelHeight: 1,
79
+ paddingTop: 50,
80
+ paddingBottom: 32
81
+ }
67
82
  }
68
83
 
69
84
  export const ZIndexes = {
@@ -87,19 +102,18 @@ function createGraph() {
87
102
  g.setDefaultEdgeLabel(() => ({ ...Sizes.edgeLabel }))
88
103
  g.setDefaultNodeLabel(() => ({}))
89
104
 
90
- const columns = ['incomers', 'subject', 'outgoers'] as const
91
- columns.reduce((prev, column) => {
92
- const c = graphColumn(column)
93
- g.setNode(c.id, {})
94
- g.setNode(c.anchor, { width: 40, height: 2 })
95
- g.setParent(c.anchor, c.id)
96
- if (prev) {
97
- g.setEdge(prev, c.anchor, {
98
- width: 0
99
- })
100
- }
101
- return c.anchor
102
- }, null as string | null)
105
+ // columns.reduce((prev, column) => {
106
+ // const c = graphColumn(column)
107
+ // g.setNode(c.id, {})
108
+ // g.setNode(c.anchor, { width: 40, height: 2 })
109
+ // g.setParent(c.anchor, c.id)
110
+ // if (prev) {
111
+ // g.setEdge(prev, c.anchor, {
112
+ // width: 0
113
+ // })
114
+ // }
115
+ // return c.anchor
116
+ // }, null as string | null)
103
117
 
104
118
  return g
105
119
  }
@@ -119,19 +133,17 @@ type Context = {
119
133
  // subject: LikeC4ModelElement
120
134
  connected: {
121
135
  incomers: Set<Fqn>
136
+ subjects: Set<Fqn>
122
137
  outgoers: Set<Fqn>
123
138
  }
124
139
  columns: {
125
- // left
126
- incomers: Map<string, XYFlowTypes.Node>
127
- // right
128
- outgoers: Map<string, XYFlowTypes.Node>
140
+ incomers: Map<Fqn, XYFlowTypes.Node>
141
+ subjects: Map<Fqn, XYFlowTypes.Node>
142
+ outgoers: Map<Fqn, XYFlowTypes.Node>
129
143
  }
130
144
  edges: XYFlowTypes.Edge[]
131
145
  }
132
146
 
133
- type ColumnKey = keyof Context['columns']
134
-
135
147
  const sized = (height: number = Sizes.hodeHeight) => ({
136
148
  width: Sizes.nodeWidth,
137
149
  height
@@ -140,8 +152,6 @@ const sized = (height: number = Sizes.hodeHeight) => ({
140
152
  const graphId = (node: XYFlowTypes.Node) => ({
141
153
  id: node.id,
142
154
  port: node.type === 'compound' ? `${node.id}::port` : node.id,
143
- // port: `${node.id}::port`,
144
- // For nested nodes
145
155
  body: `${node.id}`,
146
156
  spacer: `${node.id}:spacer`
147
157
  })
@@ -186,7 +196,7 @@ function createEmptyNode(
186
196
  column: ColumnKey,
187
197
  ctx: Context
188
198
  ): XYFlowTypes.EmptyNode {
189
- const id = `${column}::empty`
199
+ const id = `${column}__empty` as Fqn
190
200
  const xynodes = ctx.columns[column]
191
201
  let node = xynodes.get(id)
192
202
  if (node) {
@@ -205,11 +215,13 @@ function createEmptyNode(
205
215
  xynodes.set(id, xynode)
206
216
  const k = graphId(xynode)
207
217
 
218
+ const emptyContainer = id + '__container'
219
+ ctx.g.setNode(emptyContainer, {})
208
220
  ctx.g.setNode(k.id, {
209
- width: 230,
210
- height: 130
221
+ width: Sizes.nodeWidth - 20,
222
+ height: Sizes.hodeHeight
211
223
  })
212
- ctx.g.setParent(k.id, graphColumn(column).id)
224
+ ctx.g.setParent(k.id, emptyContainer)
213
225
  return xynode
214
226
  }
215
227
 
@@ -217,12 +229,16 @@ function createNode(
217
229
  column: ColumnKey,
218
230
  nodeType: Exclude<XYFlowTypes.Node['type'], 'empty'>,
219
231
  element: LikeC4Model.ElementModel,
220
- ctx: Context
232
+ ctx: Context,
233
+ depth: number = 0
221
234
  ): XYFlowTypes.ElementNode | XYFlowTypes.CompoundNode {
222
235
  const xynodes = ctx.columns[column]
223
236
  let node = xynodes.get(element.id)
224
237
  if (node) {
225
- invariant(node.type !== 'empty', `Node ${element.id} is empty`)
238
+ invariant(node.type !== 'empty', `Unexpected empty Node type ${element.id}: ${node.type}, expect ${nodeType}`)
239
+ if (node.type === 'element' && nodeType === 'compound') {
240
+ throw new Error(`Unexpected Node type ${element.id}: ${node.type}, expect ${nodeType}`)
241
+ }
226
242
  return node
227
243
  }
228
244
  const g = ctx.g
@@ -235,7 +251,7 @@ function createNode(
235
251
  ctx.diagramNodes.has(ancestor.id) || ctx.connected[column].has(ancestor.id)
236
252
  || (ctx.scope === 'global' && ctx.subjectElement.ascendingSiblings().some(s => s.id === ancestor.id))
237
253
  ),
238
- found => found ? createNode(column, 'compound', found, ctx) : null
254
+ found => found ? createNode(column, 'compound', found, ctx, depth + 2) : null
239
255
  )
240
256
 
241
257
  const xynode: XYFlowTypes.NonEmptyNode = {
@@ -259,23 +275,15 @@ function createNode(
259
275
  if (xynode.type === 'compound') {
260
276
  g.setNode(k.port, {
261
277
  width: Sizes.nodeWidth - Sizes.dagre.ranksep,
262
- height: Sizes.compoundLabelHeight
278
+ height: Sizes.compound.labelHeight
263
279
  })
264
280
  g.setParent(k.port, k.id)
265
281
  }
266
282
 
267
- const parentGraphId = parent ? graphId(parent).body : graphColumn(column).id
268
- g.setParent(k.id, parentGraphId)
269
-
270
- // Add spacer after the last element
271
- if (Sizes.spacerHeight > 0 && parent) {
272
- g.setNode(k.spacer, {
273
- width: Sizes.nodeWidth - Sizes.dagre.ranksep,
274
- height: Sizes.compoundLabelHeight
275
- })
276
- g.setParent(k.spacer, parentGraphId)
283
+ if (parent) {
284
+ parent.data.depth = Math.min(Math.max(parent.data.depth ?? 0, depth + 1), 6)
285
+ g.setParent(k.id, graphId(parent).body)
277
286
  }
278
-
279
287
  return xynode
280
288
  }
281
289
 
@@ -286,9 +294,9 @@ function createNode(
286
294
  function applyDagreLayout(g: dagre.graphlib.Graph) {
287
295
  type NodeBounds = Required<Pick<XYFlowTypes.Node, 'position' | 'width' | 'height'>>
288
296
  dagre.layout(g)
289
- return function nodeBounds(nodeId: string, relativeTo?: string): NodeBounds {
297
+ return function nodeBounds(nodeId: string): NodeBounds {
290
298
  const { x, y, width, height } = g.node(nodeId)
291
- const pos = {
299
+ return {
292
300
  position: {
293
301
  x: x - Math.round(width / 2),
294
302
  y: y - Math.round(height / 2)
@@ -296,30 +304,19 @@ function applyDagreLayout(g: dagre.graphlib.Graph) {
296
304
  width,
297
305
  height
298
306
  }
299
- if (!relativeTo) {
300
- return pos
301
- }
302
- const offset = nodeBounds(relativeTo).position
303
- return {
304
- position: {
305
- x: pos.position.x - offset.x,
306
- y: pos.position.y - offset.y
307
- },
308
- width: pos.width,
309
- height: pos.height
310
- }
311
307
  }
312
308
  }
313
309
 
314
310
  function addEdge(
315
311
  ctx: Context,
316
312
  props: {
313
+ includedInCurrentView: boolean
317
314
  source: string
318
315
  target: string
319
316
  relations: XYFlowTypes.Edge['data']['relations']
320
317
  }
321
318
  ) {
322
- const { source, target, relations } = props
319
+ const { source, target, relations, includedInCurrentView } = props
323
320
  const ids = relations.map(r => r.id).join('_')
324
321
  const label = only(relations)?.title ?? 'untitled'
325
322
 
@@ -332,6 +329,7 @@ function addEdge(
332
329
  sourceHandle: target,
333
330
  targetHandle: source,
334
331
  data: {
332
+ includedInCurrentView,
335
333
  relations
336
334
  },
337
335
  label: isMultiple ? `${relations.length} relationships` : label,
@@ -341,49 +339,20 @@ function addEdge(
341
339
  width: isMultiple ? 7 : 9
342
340
  },
343
341
  style: {
344
- strokeWidth: isMultiple ? 5 : 2.2,
342
+ strokeWidth: isMultiple ? 5 : 2.8,
345
343
  strokeDasharray: isMultiple ? undefined : '5, 5'
346
344
  }
347
345
  }
348
346
  ctx.edges.push(edge)
349
347
  }
350
348
 
351
- function processRelations(props: {
352
- incoming: Relation[]
353
- forEachIncoming: (sourceId: string, relations: NonEmptyArray<Relation>) => void
354
- outgoing: Relation[]
355
- forEachOutgoing: (targetId: string, relations: NonEmptyArray<Relation>) => void
356
- }) {
357
- // Group by source, because might be multiple relations between the same elements
358
- pipe(
359
- props.incoming,
360
- groupBy(r => r.source),
361
- entries(),
362
- sort((a, b) => compareFqnHierarchically(a[0], b[0])),
363
- reverse(),
364
- forEach(([sourceId, relations]) => {
365
- try {
366
- props.forEachIncoming(sourceId, relations)
367
- } catch (e) {
368
- console.error('Error in forEachIncoming', e)
369
- }
370
- })
371
- )
372
- // Group by target
373
- pipe(
374
- props.outgoing,
375
- groupBy(r => r.target),
376
- entries(),
377
- sort((a, b) => compareFqnHierarchically(a[0], b[0])),
378
- reverse(),
379
- forEach(([targetId, relations]) => {
380
- try {
381
- props.forEachOutgoing(targetId, relations)
382
- } catch (e) {
383
- console.error('Error in forEachOutgoing', e)
384
- }
385
- })
386
- )
349
+ function findNodeOrFirstAncestor<N>(fqn: Fqn, nodes: Map<Fqn, N>) {
350
+ let node = nodes.get(fqn)
351
+ let parent: Fqn | null = fqn
352
+ while (!node && !!(parent = parentFqn(parent))) {
353
+ node = nodes.get(parent)
354
+ }
355
+ return node
387
356
  }
388
357
 
389
358
  function layout(
@@ -392,8 +361,8 @@ function layout(
392
361
  likec4model: LikeC4Model,
393
362
  scope: 'global' | 'view'
394
363
  ): {
395
- view: DiagramView
396
364
  viewIncludesSubject: boolean
365
+ notIncludedRelations: number
397
366
  subject: LikeC4ModelElement
398
367
  nodes: XYFlowTypes.Node[]
399
368
  edges: XYFlowTypes.Edge[]
@@ -406,10 +375,43 @@ function layout(
406
375
  } {
407
376
  const diagramNodes = new Map(view.nodes.map(n => [n.id, n]))
408
377
  const subjectElement = likec4model.element(subjectId)
378
+ const viewIncludesSubject = diagramNodes.has(subjectId)
379
+
380
+ const viewRelationships = new Set(
381
+ view.edges.flatMap(e => e.relations)
382
+ )
383
+ // Relations that are not included in the view
384
+ const notIncludedRelations = new Set<RelationID>()
409
385
 
410
- if (!diagramNodes.has(subjectId)) {
386
+ if (!viewIncludesSubject) {
411
387
  // Reset scope to global if subject is not in the view
412
388
  scope = 'global'
389
+ } else {
390
+ forEach([
391
+ ...subjectElement.incoming().map(r => r.id),
392
+ ...subjectElement.outgoing().map(r => r.id)
393
+ ], (relationId) => {
394
+ if (!viewRelationships.has(relationId)) {
395
+ notIncludedRelations.add(relationId)
396
+ }
397
+ })
398
+ }
399
+
400
+ let relationships
401
+
402
+ if (scope === 'global') {
403
+ relationships = {
404
+ incoming: subjectElement.incoming().map(r => r.relationship),
405
+ outgoing: subjectElement.outgoing().map(r => r.relationship)
406
+ }
407
+ } else {
408
+ const subjectViewModel = likec4model.view(view.id).element(subjectId)
409
+ relationships = {
410
+ incoming: subjectViewModel.incoming()
411
+ .flatMap(c => c.relationships().map(r => r.relationship)),
412
+ outgoing: subjectViewModel.outgoing()
413
+ .flatMap(c => c.relationships().map(r => r.relationship))
414
+ }
413
415
  }
414
416
 
415
417
  const g = createGraph()
@@ -418,134 +420,222 @@ function layout(
418
420
  scope,
419
421
  g,
420
422
  diagramNodes,
421
- subjectId,
422
423
  subjectElement,
423
- connected: fromKeys(
424
- ['incomers', 'outgoers'] as const,
425
- (column) => new Set(subjectElement[column]().map(m => m.id))
426
- ),
424
+ subjectId,
425
+ connected: {
426
+ incomers: new Set<Fqn>(),
427
+ outgoers: new Set<Fqn>(),
428
+ subjects: new Set<Fqn>([subjectId])
429
+ },
427
430
  columns: {
428
431
  incomers: new Map(),
432
+ subjects: new Map(),
429
433
  outgoers: new Map()
430
434
  },
431
435
  edges: []
432
436
  }
433
437
 
434
- const subject: XYFlowTypes.ElementNode = {
435
- id: subjectElement.id,
436
- type: 'element',
437
- position: { x: 0, y: 0 },
438
- data: {
439
- ...nodeData(subjectElement, ctx),
440
- column: 'subject'
441
- },
442
- zIndex: 3,
443
- selectable: false,
444
- focusable: false,
445
- ...sized()
438
+ relationships.incoming.forEach(incoming => {
439
+ ctx.connected.incomers.add(incoming.source)
440
+ ctx.connected.subjects.add(incoming.target)
441
+ })
442
+ relationships.outgoing.forEach(outgoing => {
443
+ ctx.connected.subjects.add(outgoing.source)
444
+ ctx.connected.outgoers.add(outgoing.target)
445
+ })
446
+
447
+ if (viewIncludesSubject) {
448
+ const subjectViewModel = likec4model.view(view.id).element(subjectId)
449
+ subjectViewModel.incomers().forEach(incomer => {
450
+ ctx.connected.incomers.add(incomer.id)
451
+ })
452
+ subjectViewModel.outgoers().forEach(outgoer => {
453
+ ctx.connected.outgoers.add(outgoer.id)
454
+ })
446
455
  }
447
- g.setNode(subject.id, sized())
448
- g.setParent(subject.id, graphColumn('subject').id)
449
-
450
- processRelations({
451
- // Based on the scope we pick relationships
452
- ...(scope === 'global'
453
- ? {
454
- incoming: subjectElement.incoming().map(r => r.relationship),
455
- outgoing: subjectElement.outgoing().map(r => r.relationship)
456
- }
457
- : {
458
- incoming: likec4model.view(view.id).element(subjectId)
459
- .incoming()
460
- .flatMap(c => c.relationships().map(r => r.relationship)),
461
- outgoing: likec4model.view(view.id).element(subjectId)
462
- .outgoing()
463
- .flatMap(c => c.relationships().map(r => r.relationship))
464
- }),
465
456
 
466
- forEachIncoming(sourceId, relations) {
467
- const source = createNode('incomers', 'element', likec4model.element(sourceId), ctx)
468
- g.setEdge(graphId(source).port, subject.id, {
469
- width: Sizes.edgeLabel.width,
470
- weight: source.type === 'compound' ? 0 : 1
457
+ // Created nodes per column
458
+ columns.forEach(column => {
459
+ pipe(
460
+ [...ctx.connected[column].values()],
461
+ map(id => likec4model.element(id)),
462
+ sort((a, b) => -1 * compareNatural(a.title, b.title)),
463
+ sort(compareByFqnHierarchically),
464
+ reverse(),
465
+ forEach(element => {
466
+ createNode(column, 'element', element, ctx)
471
467
  })
468
+ )
469
+ })
472
470
 
473
- subject.data.ports.left.push({
474
- id: source.id,
475
- type: 'in'
476
- })
477
- source.data.ports.right.push({
478
- id: subject.id,
479
- type: 'out'
480
- })
481
- addEdge(ctx, {
482
- source: source.id,
483
- target: subject.id,
484
- relations
485
- })
486
- },
487
-
488
- forEachOutgoing(targetId, relations) {
489
- const target = createNode('outgoers', 'element', likec4model.element(targetId), ctx)
490
- g.setEdge(subjectElement.id, graphId(target).port, {
491
- width: Sizes.edgeLabel.width,
492
- weight: target.type === 'compound' ? 1 : 2
471
+ pipe(
472
+ [
473
+ // Process incoming
474
+ {
475
+ sources: ctx.columns.incomers,
476
+ targets: ctx.columns.subjects,
477
+ relationships: relationships.incoming.sort(compareRelations).reverse()
478
+ },
479
+ // Process outgoing
480
+ {
481
+ sources: ctx.columns.subjects,
482
+ targets: ctx.columns.outgoers,
483
+ relationships: relationships.outgoing.sort(compareRelations).reverse()
484
+ }
485
+ ],
486
+ /**
487
+ * We select relationships, sources and targets
488
+ * If sourece or target of the relationship is not found - take first ancestor
489
+ */
490
+ flatMap(({ sources, targets, relationships }) => {
491
+ return relationships.map(relation => {
492
+ const source = findNodeOrFirstAncestor(relation.source, sources)
493
+ if (!source || source.type === 'empty') {
494
+ return null
495
+ }
496
+ const target = findNodeOrFirstAncestor(relation.target, targets)
497
+ if (!target || target.type === 'empty') {
498
+ return null
499
+ }
500
+ return ({
501
+ relation,
502
+ source,
503
+ target,
504
+ includedInCurrentView: viewRelationships.has(relation.id),
505
+ id: `${source.id}:${target.id}`
506
+ })
493
507
  })
508
+ }),
509
+ filter(isNonNullish),
510
+ // Group relations with saame source and target - make them one edge
511
+ groupBy(prop('id')),
512
+ forEachObj((grouped) => {
513
+ const { source, target } = grouped[0]
514
+ const relations = map(grouped, g => g.relation)
494
515
 
495
- subject.data.ports.right.push({
516
+ source.data.ports.right.push({
496
517
  id: target.id,
497
518
  type: 'out'
498
519
  })
499
520
  target.data.ports.left.push({
500
- id: subject.id,
521
+ id: source.id,
501
522
  type: 'in'
502
523
  })
524
+
525
+ const isAnyCompound = source.type === 'compound' || target.type === 'compound'
526
+
527
+ g.setEdge(graphId(source).port, graphId(target).port, {
528
+ ...Sizes.edgeLabel
529
+ // weight: isAnyCompound ? 1 : 2
530
+ })
531
+
503
532
  addEdge(ctx, {
504
- source: subjectElement.id,
533
+ // if view does not include subject - do not highlight
534
+ includedInCurrentView: !viewIncludesSubject || grouped.every(g => g.includedInCurrentView),
535
+ source: source.id,
505
536
  target: target.id,
506
537
  relations
507
538
  })
508
- }
509
- })
539
+ })
540
+ )
541
+
542
+ // Create empty nodes if there are no incomers or outgoers
543
+ const subjectPort = graphId(ctx.columns.subjects.get(subjectId)!).port
510
544
 
511
545
  if (ctx.columns.incomers.size == 0) {
512
546
  const source = createEmptyNode('incomers', ctx)
513
- g.setEdge(graphId(source).port, subject.id)
547
+ g.setEdge(graphId(source).port, subjectPort)
514
548
  }
515
549
  if (ctx.columns.outgoers.size == 0) {
516
550
  const target = createEmptyNode('outgoers', ctx)
517
- g.setEdge(subject.id, graphId(target).port)
551
+ g.setEdge(subjectPort, graphId(target).port)
518
552
  }
519
553
 
520
- const subjectPortsCount = Math.max(subject.data.ports.left.length, subject.data.ports.right.length)
521
- if (subjectPortsCount > 3) {
522
- g.node(subjectElement.id).height = Sizes.hodeHeight + (subjectPortsCount - 3) * 5
554
+ // Grow nodes with more than 2 ports
555
+ for (const subject of ctx.columns.subjects.values()) {
556
+ if (subject.type !== 'element') {
557
+ continue
558
+ }
559
+ const subjectPortsCount = Math.max(subject.data.ports.left.length, subject.data.ports.right.length)
560
+ if (subjectPortsCount > 2) {
561
+ g.node(subject.id).height = Sizes.hodeHeight + (subjectPortsCount - 3) * 14
562
+ }
523
563
  }
524
564
 
525
- const nodebounds = applyDagreLayout(ctx.g)
565
+ //
566
+ const dagreBounds = applyDagreLayout(ctx.g)
526
567
 
527
568
  const xynodes = [
528
- subject,
529
569
  ...ctx.columns.incomers.values(),
570
+ ...ctx.columns.subjects.values(),
530
571
  ...ctx.columns.outgoers.values()
531
- ].map((node) => {
532
- return {
533
- ...node,
534
- ...nodebounds(node.id, node.parentId)
572
+ ]
573
+
574
+ // Calculate bounds for all nodes except compounds
575
+ // We shrink compounds to fit their children
576
+ const _calculatedNodeBounds = pipe(
577
+ xynodes,
578
+ filter(n => n.type !== 'compound'),
579
+ mapToObj(n => [n.id, dagreBounds(n.id)])
580
+ )
581
+
582
+ function nodeBounds(nodeId: string): ReturnType<typeof dagreBounds> {
583
+ return _calculatedNodeBounds[nodeId] ??= pipe(
584
+ xynodes,
585
+ filter(n => n.parentId === nodeId),
586
+ map(n => nodeBounds(n.id)),
587
+ tap(bounds => {
588
+ invariant(bounds.length > 0, `Node ${nodeId} has no nested nodes`)
589
+ }),
590
+ reduce((acc, bounds) => {
591
+ return {
592
+ minY: Math.min(acc.minY, bounds.position.y),
593
+ maxY: Math.max(acc.maxY, bounds.position.y + bounds.height)
594
+ }
595
+ }, { minY: Infinity, maxY: -Infinity }),
596
+ ({ minY, maxY }) => {
597
+ const {
598
+ position: { x },
599
+ width
600
+ } = dagreBounds(nodeId)
601
+ minY = minY - Sizes.compound.paddingTop
602
+ maxY = maxY + Sizes.compound.paddingBottom
603
+
604
+ return {
605
+ position: {
606
+ x,
607
+ y: minY
608
+ },
609
+ width,
610
+ height: maxY - minY
611
+ }
612
+ }
613
+ )
614
+ }
615
+
616
+ // update xynodes
617
+ for (const node of xynodes) {
618
+ const { position, width, height } = nodeBounds(node.id)
619
+ node.width = width
620
+ node.height = height
621
+ node.position = position
622
+ if (node.parentId) {
623
+ const parentPos = nodeBounds(node.parentId).position
624
+ node.position = {
625
+ x: position.x - parentPos.x,
626
+ y: position.y - parentPos.y
627
+ }
535
628
  }
536
- })
629
+ }
537
630
 
538
631
  // Sort ports in subject vertically
539
632
  const sortedPorts = (ports: XYFlowTypes.Port[]) => {
540
- if (ports.length < 2) {
541
- return ports
542
- }
543
633
  return pipe(
544
634
  ports,
545
635
  map(port => {
546
636
  return {
547
637
  port,
548
- topY: nodebounds(port.id).position.y
638
+ topY: nodeBounds(port.id).position.y
549
639
  }
550
640
  }),
551
641
  sortBy(prop('topY')),
@@ -556,13 +646,14 @@ function layout(
556
646
  for (const node of xynodes) {
557
647
  // Grow empty nodes to fill the space
558
648
  if (node.type === 'empty') {
559
- const subjectPosition = nodebounds(subject.id)
560
- node.position.y = subjectPosition.position.y + subjectPosition.height / 2 - node.height / 2
649
+ const subjectBounds = nodeBounds(nonNullable(ctx.columns.subjects.get(subjectId), 'Subject node is missing').id)
650
+ node.height = Math.min(subjectBounds.height, 300)
651
+ node.position.y = subjectBounds.position.y + subjectBounds.height / 2 - node.height / 2
561
652
  if (node.data.column === 'incomers') {
562
- node.width = subjectPosition.position.x - Sizes.emptyNodeOffset - node.position.x
653
+ node.width = subjectBounds.position.x - Sizes.emptyNodeOffset - node.position.x
563
654
  } else {
564
- const rightX = node.position.x + node.width
565
- node.position.x = subjectPosition.position.x + subjectPosition.width + Sizes.emptyNodeOffset
655
+ const rightX = node.position.x + node.width!
656
+ node.position.x = subjectBounds.position.x + subjectBounds.width + Sizes.emptyNodeOffset
566
657
  node.width = rightX - node.position.x
567
658
  }
568
659
  continue
@@ -577,22 +668,25 @@ function layout(
577
668
  }
578
669
 
579
670
  return {
580
- view: view,
581
- viewIncludesSubject: diagramNodes.has(subjectId),
671
+ viewIncludesSubject,
672
+ notIncludedRelations: notIncludedRelations.size,
582
673
  subject: subjectElement,
583
674
  edges: ctx.edges,
584
675
  nodes: xynodes,
585
676
  bounds: {
586
677
  x: 0,
587
678
  y: 0,
588
- width: g.graph().width ?? 0,
589
- height: g.graph().height ?? 0
679
+ width: g.graph().width ?? 100,
680
+ height: g.graph().height ?? 100
590
681
  }
591
682
  }
592
683
  }
593
684
 
594
- export function useLayoutedRelationships(subjectId: Fqn, scope: 'global' | 'view') {
595
- const view = useDiagramState(s => s.view)
685
+ export function useLayoutedRelationships(
686
+ subjectId: Fqn,
687
+ view: DiagramView,
688
+ scope: 'global' | 'view'
689
+ ) {
596
690
  const likec4model = useLikeC4Model(true)
597
691
  return useMemo(() =>
598
692
  layout(