@likec4/diagram 1.16.0 → 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.
- package/package.json +7 -7
- package/src/LikeC4CustomColors.tsx +24 -21
- package/src/LikeC4Diagram.props.ts +9 -9
- package/src/LikeC4Diagram.tsx +25 -26
- package/src/StaticLikeC4Diagram.tsx +9 -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/diagramStore.ts +14 -6
- package/src/theme-vars.ts +9 -0
- package/src/ui/FramerMotionConfig.tsx +18 -0
- package/src/ui/notation/NotationPanel.tsx +3 -3
- 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 +110 -116
- package/src/xyflow/nodes/element/element.css.ts +38 -18
- 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
|
@@ -1,69 +1,84 @@
|
|
|
1
|
-
import dagre, { type
|
|
1
|
+
import dagre, { type EdgeConfig, type GraphLabel } from '@dagrejs/dagre'
|
|
2
2
|
import {
|
|
3
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
50
|
-
nodesep:
|
|
51
|
-
edgesep:
|
|
57
|
+
ranksep: 60,
|
|
58
|
+
nodesep: 35,
|
|
59
|
+
edgesep: 25
|
|
52
60
|
} satisfies GraphLabel,
|
|
53
61
|
edgeLabel: {
|
|
54
|
-
width:
|
|
55
|
-
|
|
62
|
+
width: 120,
|
|
63
|
+
height: 10,
|
|
64
|
+
minlen: 1,
|
|
65
|
+
weight: 1
|
|
66
|
+
} satisfies EdgeConfig,
|
|
56
67
|
|
|
57
|
-
emptyNodeOffset:
|
|
68
|
+
emptyNodeOffset: 120,
|
|
58
69
|
|
|
59
|
-
nodeWidth:
|
|
60
|
-
hodeHeight:
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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}
|
|
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:
|
|
210
|
-
height:
|
|
221
|
+
width: Sizes.nodeWidth - 20,
|
|
222
|
+
height: Sizes.hodeHeight
|
|
211
223
|
})
|
|
212
|
-
ctx.g.setParent(k.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}
|
|
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.
|
|
278
|
+
height: Sizes.compound.labelHeight
|
|
263
279
|
})
|
|
264
280
|
g.setParent(k.port, k.id)
|
|
265
281
|
}
|
|
266
282
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
297
|
+
return function nodeBounds(nodeId: string): NodeBounds {
|
|
290
298
|
const { x, y, width, height } = g.node(nodeId)
|
|
291
|
-
|
|
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.
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
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 (!
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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(
|
|
551
|
+
g.setEdge(subjectPort, graphId(target).port)
|
|
518
552
|
}
|
|
519
553
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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:
|
|
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
|
|
560
|
-
node.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
581
|
-
|
|
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 ??
|
|
589
|
-
height: g.graph().height ??
|
|
679
|
+
width: g.graph().width ?? 100,
|
|
680
|
+
height: g.graph().height ?? 100
|
|
590
681
|
}
|
|
591
682
|
}
|
|
592
683
|
}
|
|
593
684
|
|
|
594
|
-
export function useLayoutedRelationships(
|
|
595
|
-
|
|
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(
|