@likec4/diagram 1.12.2 → 1.13.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 +8 -8
- package/src/LikeC4CustomColors.tsx +2 -2
- package/src/LikeC4Diagram.props.ts +15 -7
- package/src/LikeC4Diagram.tsx +23 -14
- package/src/StaticLikeC4Diagram.tsx +2 -0
- package/src/components/Overlay.css.ts +78 -0
- package/src/components/Overlay.tsx +102 -0
- package/src/components/OverlayContext.tsx +11 -0
- package/src/components/index.ts +2 -0
- package/src/likec4model/index.ts +1 -0
- package/src/likec4model/useLikeC4ElementsTree.ts +44 -0
- package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +13 -0
- package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +37 -0
- package/src/overlays/index.tsx +33 -0
- package/src/overlays/relationships-of/RelationshipsOverlay.tsx +33 -0
- package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +359 -0
- package/src/overlays/relationships-of/SelectElement.css.ts +18 -0
- package/src/overlays/relationships-of/SelectElement.tsx +133 -0
- package/src/overlays/relationships-of/_types.ts +56 -0
- package/src/overlays/relationships-of/styles.css.ts +34 -0
- package/src/overlays/relationships-of/use-layouted-relationships.ts +607 -0
- package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +76 -0
- package/src/overlays/relationships-of/xyflow/ElementNode.tsx +130 -0
- package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +22 -0
- package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +59 -0
- package/src/overlays/relationships-of/xyflow/styles.css.ts +170 -0
- package/src/state/diagram-to-xyflow.ts +1 -1
- package/src/state/diagramStore.ts +119 -27
- package/src/ui/top-left/ChangeAutoLayoutButton.tsx +1 -1
- package/src/ui/top-left/ManualLayoutToolsButton.tsx +110 -0
- package/src/ui/top-left/TopLeftPanel.tsx +15 -31
- package/src/ui/top-left/_shared.ts +2 -2
- package/src/xyflow/EdgeMarkers.tsx +19 -0
- package/src/xyflow/XYFlowEvents.tsx +21 -24
- package/src/xyflow/XYFlowInner.tsx +6 -10
- package/src/xyflow/edges/EdgeLabel.tsx +6 -5
- package/src/xyflow/edges/RelationshipEdge.tsx +4 -3
- package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +16 -0
- package/src/xyflow/edges/edges.css.ts +5 -11
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +17 -40
- package/src/xyflow/nodes/compound/CompoundNode.tsx +219 -28
- package/src/xyflow/nodes/element/ElementLink.tsx +11 -8
- package/src/xyflow/nodes/element/ElementNode.tsx +211 -140
- package/src/xyflow/nodes/element/element.css.ts +22 -33
- package/src/xyflow/nodes/shared/Toolbar.tsx +81 -27
- package/src/xyflow/nodes/use-animate-variants.tsx +64 -0
- package/src/xyflow/useLayoutConstraints.ts +95 -125
- package/src/xyflow/utils.ts +4 -0
- package/src/xyflow/nodes/shared/NavigateToBtn.tsx +0 -32
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import dagre, { type GraphLabel, type Label } from '@dagrejs/dagre'
|
|
2
|
+
import {
|
|
3
|
+
compareFqnHierarchically,
|
|
4
|
+
type DiagramNode,
|
|
5
|
+
type DiagramView,
|
|
6
|
+
type Fqn,
|
|
7
|
+
invariant,
|
|
8
|
+
isAncestor,
|
|
9
|
+
type LikeC4Model,
|
|
10
|
+
type NonEmptyArray,
|
|
11
|
+
type Relation
|
|
12
|
+
} from '@likec4/core'
|
|
13
|
+
import { MarkerType } from '@xyflow/system'
|
|
14
|
+
import { useMemo } from 'react'
|
|
15
|
+
import {
|
|
16
|
+
entries,
|
|
17
|
+
filter,
|
|
18
|
+
find,
|
|
19
|
+
first,
|
|
20
|
+
forEach,
|
|
21
|
+
fromKeys,
|
|
22
|
+
groupBy,
|
|
23
|
+
isTruthy,
|
|
24
|
+
map,
|
|
25
|
+
only,
|
|
26
|
+
pipe,
|
|
27
|
+
prop,
|
|
28
|
+
reverse,
|
|
29
|
+
sort,
|
|
30
|
+
sortBy,
|
|
31
|
+
takeWhile
|
|
32
|
+
} from 'remeda'
|
|
33
|
+
import { useDiagramState } from '../../hooks/useDiagramState'
|
|
34
|
+
import { useLikeC4Model } from '../../likec4model'
|
|
35
|
+
import type { XYFlowTypes } from './_types'
|
|
36
|
+
|
|
37
|
+
const graphColumn = <C extends 'incomers' | 'subject' | 'outgoers'>(c: C) => ({
|
|
38
|
+
name: c,
|
|
39
|
+
id: `__${c}`,
|
|
40
|
+
anchor: `__${c}::anchor`
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* All constants related to the layout
|
|
45
|
+
*/
|
|
46
|
+
const Sizes = {
|
|
47
|
+
dagre: {
|
|
48
|
+
ranksep: 40,
|
|
49
|
+
nodesep: 20,
|
|
50
|
+
edgesep: 20
|
|
51
|
+
} satisfies GraphLabel,
|
|
52
|
+
edgeLabel: {
|
|
53
|
+
width: 50
|
|
54
|
+
} satisfies Label,
|
|
55
|
+
|
|
56
|
+
emptyNodeOffset: 100,
|
|
57
|
+
|
|
58
|
+
nodeWidth: 270,
|
|
59
|
+
hodeHeight: 160,
|
|
60
|
+
|
|
61
|
+
// Spacer between elements in a compound node
|
|
62
|
+
// 0 means no spacer
|
|
63
|
+
spacerHeight: 0,
|
|
64
|
+
|
|
65
|
+
compoundLabelHeight: 5
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const ZIndexes = {
|
|
69
|
+
empty: 2,
|
|
70
|
+
compound: 2,
|
|
71
|
+
edge: 3,
|
|
72
|
+
element: 4,
|
|
73
|
+
|
|
74
|
+
max: 5
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createGraph() {
|
|
78
|
+
const g = new dagre.graphlib.Graph({
|
|
79
|
+
directed: true,
|
|
80
|
+
compound: true
|
|
81
|
+
})
|
|
82
|
+
g.setGraph({
|
|
83
|
+
...Sizes.dagre,
|
|
84
|
+
rankdir: 'LR'
|
|
85
|
+
})
|
|
86
|
+
g.setDefaultEdgeLabel(() => ({ ...Sizes.edgeLabel }))
|
|
87
|
+
g.setDefaultNodeLabel(() => ({}))
|
|
88
|
+
|
|
89
|
+
const columns = ['incomers', 'subject', 'outgoers'] as const
|
|
90
|
+
columns.reduce((prev, column) => {
|
|
91
|
+
const c = graphColumn(column)
|
|
92
|
+
g.setNode(c.id, {})
|
|
93
|
+
g.setNode(c.anchor, { width: 50, height: 5 })
|
|
94
|
+
g.setParent(c.anchor, c.id)
|
|
95
|
+
if (prev) {
|
|
96
|
+
g.setEdge(prev, c.anchor)
|
|
97
|
+
}
|
|
98
|
+
return c.anchor
|
|
99
|
+
}, null as string | null)
|
|
100
|
+
|
|
101
|
+
return g
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type LikeC4ModelElement = LikeC4Model.ElementModel
|
|
105
|
+
// type LikeC4ModelElement = LikeC4ViewModel.Element
|
|
106
|
+
|
|
107
|
+
type Context = {
|
|
108
|
+
scope: 'global' | 'view'
|
|
109
|
+
g: dagre.graphlib.Graph
|
|
110
|
+
subjectId: Fqn
|
|
111
|
+
diagramNodes: Map<Fqn, DiagramNode>
|
|
112
|
+
subjectElement: LikeC4ModelElement
|
|
113
|
+
// likec4model: LikeC4Model
|
|
114
|
+
// subject: XYFlowTypes.ElementNode
|
|
115
|
+
// Model
|
|
116
|
+
// subject: LikeC4ModelElement
|
|
117
|
+
connected: {
|
|
118
|
+
incomers: Set<Fqn>
|
|
119
|
+
outgoers: Set<Fqn>
|
|
120
|
+
}
|
|
121
|
+
columns: {
|
|
122
|
+
// left
|
|
123
|
+
incomers: Map<string, XYFlowTypes.Node>
|
|
124
|
+
// right
|
|
125
|
+
outgoers: Map<string, XYFlowTypes.Node>
|
|
126
|
+
}
|
|
127
|
+
edges: XYFlowTypes.Edge[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ColumnKey = keyof Context['columns']
|
|
131
|
+
|
|
132
|
+
const sized = (height: number = Sizes.hodeHeight) => ({
|
|
133
|
+
width: Sizes.nodeWidth,
|
|
134
|
+
height
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const graphId = (node: XYFlowTypes.Node) => ({
|
|
138
|
+
id: node.id,
|
|
139
|
+
port: node.type === 'compound' ? `${node.id}::port` : node.id,
|
|
140
|
+
// port: `${node.id}::port`,
|
|
141
|
+
// For nested nodes
|
|
142
|
+
body: `${node.id}`,
|
|
143
|
+
spacer: `${node.id}:spacer`
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
function nodeData(
|
|
147
|
+
element: LikeC4Model.ElementModel,
|
|
148
|
+
ctx: Context
|
|
149
|
+
): Omit<XYFlowTypes.NonEmptyNode['data'], 'column'> {
|
|
150
|
+
// We try to inherit style from existing diagram node
|
|
151
|
+
let diagramNode = ctx.diagramNodes.get(element.id)
|
|
152
|
+
|
|
153
|
+
// Ansector separetely, because we want to inherit
|
|
154
|
+
// color from it if there is no diagram node
|
|
155
|
+
const ancestor = diagramNode ?? (ctx.scope === 'view'
|
|
156
|
+
? pipe(
|
|
157
|
+
element.ancestors(),
|
|
158
|
+
map(ancestor => ctx.diagramNodes.get(ancestor.id)),
|
|
159
|
+
filter(isTruthy),
|
|
160
|
+
first()
|
|
161
|
+
)
|
|
162
|
+
: undefined)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
fqn: element.id,
|
|
166
|
+
existsInCurrentView: ctx.diagramNodes.has(element.id),
|
|
167
|
+
element: {
|
|
168
|
+
kind: element.kind,
|
|
169
|
+
title: diagramNode?.title ?? element.title,
|
|
170
|
+
description: diagramNode?.description ?? element.element.description,
|
|
171
|
+
color: diagramNode?.color ?? ancestor?.color ?? element.color,
|
|
172
|
+
shape: diagramNode?.shape ?? element.shape
|
|
173
|
+
},
|
|
174
|
+
navigateTo: diagramNode?.navigateTo ?? first(element.viewsOf())?.id ?? null,
|
|
175
|
+
ports: {
|
|
176
|
+
left: [],
|
|
177
|
+
right: []
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function createEmptyNode(
|
|
183
|
+
column: ColumnKey,
|
|
184
|
+
ctx: Context
|
|
185
|
+
): XYFlowTypes.EmptyNode {
|
|
186
|
+
const id = `${column}::empty`
|
|
187
|
+
const xynodes = ctx.columns[column]
|
|
188
|
+
let node = xynodes.get(id)
|
|
189
|
+
if (node) {
|
|
190
|
+
invariant(node.type === 'empty', 'Node is not empty')
|
|
191
|
+
return node
|
|
192
|
+
}
|
|
193
|
+
const xynode: XYFlowTypes.EmptyNode = {
|
|
194
|
+
type: 'empty',
|
|
195
|
+
id,
|
|
196
|
+
position: { x: 0, y: 0 },
|
|
197
|
+
data: {
|
|
198
|
+
column
|
|
199
|
+
},
|
|
200
|
+
zIndex: ZIndexes.empty
|
|
201
|
+
}
|
|
202
|
+
xynodes.set(id, xynode)
|
|
203
|
+
const k = graphId(xynode)
|
|
204
|
+
|
|
205
|
+
ctx.g.setNode(k.id, {
|
|
206
|
+
width: 230,
|
|
207
|
+
height: 130
|
|
208
|
+
})
|
|
209
|
+
ctx.g.setParent(k.id, graphColumn(column).id)
|
|
210
|
+
return xynode
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createNode(
|
|
214
|
+
column: ColumnKey,
|
|
215
|
+
nodeType: Exclude<XYFlowTypes.Node['type'], 'empty'>,
|
|
216
|
+
element: LikeC4Model.ElementModel,
|
|
217
|
+
ctx: Context
|
|
218
|
+
): XYFlowTypes.ElementNode | XYFlowTypes.CompoundNode {
|
|
219
|
+
const xynodes = ctx.columns[column]
|
|
220
|
+
let node = xynodes.get(element.id)
|
|
221
|
+
if (node) {
|
|
222
|
+
invariant(node.type !== 'empty', `Node ${element.id} is empty`)
|
|
223
|
+
return node
|
|
224
|
+
}
|
|
225
|
+
const g = ctx.g
|
|
226
|
+
|
|
227
|
+
// Create parent node
|
|
228
|
+
const parent = pipe(
|
|
229
|
+
element.ancestors(),
|
|
230
|
+
takeWhile(ancestor => !isAncestor(ancestor.id, ctx.subjectId)),
|
|
231
|
+
find(ancestor =>
|
|
232
|
+
ctx.diagramNodes.has(ancestor.id) || ctx.connected[column].has(ancestor.id)
|
|
233
|
+
|| (ctx.scope === 'global' && ctx.subjectElement.ascendingSiblings().some(s => s.id === ancestor.id))
|
|
234
|
+
),
|
|
235
|
+
found => found ? createNode(column, 'compound', found, ctx) : null
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const xynode: XYFlowTypes.NonEmptyNode = {
|
|
239
|
+
type: nodeType,
|
|
240
|
+
id: `${column}::${element.id}`,
|
|
241
|
+
position: { x: 0, y: 0 },
|
|
242
|
+
data: {
|
|
243
|
+
...nodeData(element, ctx),
|
|
244
|
+
column
|
|
245
|
+
},
|
|
246
|
+
zIndex: ZIndexes[nodeType],
|
|
247
|
+
...(!!parent && { parentId: parent.id })
|
|
248
|
+
}
|
|
249
|
+
xynodes.set(element.id, xynode)
|
|
250
|
+
// There is one-to-one mapping between XYFlow node and dagre node
|
|
251
|
+
// Compound node has two nodes: body and port (for edges)
|
|
252
|
+
|
|
253
|
+
const k = graphId(xynode)
|
|
254
|
+
|
|
255
|
+
g.setNode(k.id, sized())
|
|
256
|
+
if (xynode.type === 'compound') {
|
|
257
|
+
g.setNode(k.id, {
|
|
258
|
+
padding: 40
|
|
259
|
+
})
|
|
260
|
+
g.setNode(k.port, {
|
|
261
|
+
width: Sizes.nodeWidth - Sizes.dagre.ranksep,
|
|
262
|
+
height: Sizes.compoundLabelHeight
|
|
263
|
+
})
|
|
264
|
+
g.setParent(k.port, k.id)
|
|
265
|
+
}
|
|
266
|
+
|
|
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)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return xynode
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Apply dagre layout to the graph
|
|
284
|
+
* And return a function to get node bounds for xyflow
|
|
285
|
+
*/
|
|
286
|
+
function applyDagreLayout(g: dagre.graphlib.Graph) {
|
|
287
|
+
type NodeBounds = Required<Pick<XYFlowTypes.Node, 'position' | 'width' | 'height'>>
|
|
288
|
+
dagre.layout(g)
|
|
289
|
+
return function nodeBounds(nodeId: string, relativeTo?: string): NodeBounds {
|
|
290
|
+
const { x, y, width, height } = g.node(nodeId)
|
|
291
|
+
const pos = {
|
|
292
|
+
position: {
|
|
293
|
+
x: x - Math.round(width / 2),
|
|
294
|
+
y: y - Math.round(height / 2)
|
|
295
|
+
},
|
|
296
|
+
width,
|
|
297
|
+
height
|
|
298
|
+
}
|
|
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
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function addEdge(
|
|
315
|
+
ctx: Context,
|
|
316
|
+
props: {
|
|
317
|
+
source: string
|
|
318
|
+
target: string
|
|
319
|
+
relations: XYFlowTypes.Edge['data']['relations']
|
|
320
|
+
}
|
|
321
|
+
) {
|
|
322
|
+
const { source, target, relations } = props
|
|
323
|
+
const ids = relations.map(r => r.id).join('_')
|
|
324
|
+
const label = only(relations)?.title ?? 'untitled'
|
|
325
|
+
|
|
326
|
+
const isMultiple = relations.length > 1
|
|
327
|
+
const edge: XYFlowTypes.Edge = {
|
|
328
|
+
id: `rel${ctx.edges.length + 1}_${ids}`,
|
|
329
|
+
type: 'relation',
|
|
330
|
+
source,
|
|
331
|
+
target,
|
|
332
|
+
sourceHandle: target,
|
|
333
|
+
targetHandle: source,
|
|
334
|
+
data: {
|
|
335
|
+
relations
|
|
336
|
+
},
|
|
337
|
+
label: isMultiple ? `${relations.length} relationships` : label,
|
|
338
|
+
zIndex: ZIndexes.edge,
|
|
339
|
+
markerEnd: {
|
|
340
|
+
type: MarkerType.ArrowClosed,
|
|
341
|
+
width: isMultiple ? 7 : 9
|
|
342
|
+
},
|
|
343
|
+
style: {
|
|
344
|
+
strokeWidth: isMultiple ? 5 : 2.2,
|
|
345
|
+
strokeDasharray: isMultiple ? undefined : '5, 5'
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
ctx.edges.push(edge)
|
|
349
|
+
}
|
|
350
|
+
|
|
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
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function layout(
|
|
390
|
+
subjectId: Fqn,
|
|
391
|
+
view: DiagramView,
|
|
392
|
+
likec4model: LikeC4Model,
|
|
393
|
+
scope: 'global' | 'view'
|
|
394
|
+
): {
|
|
395
|
+
view: DiagramView
|
|
396
|
+
viewIncludesSubject: boolean
|
|
397
|
+
subject: LikeC4ModelElement
|
|
398
|
+
nodes: XYFlowTypes.Node[]
|
|
399
|
+
edges: XYFlowTypes.Edge[]
|
|
400
|
+
} {
|
|
401
|
+
const diagramNodes = new Map(view.nodes.map(n => [n.id, n]))
|
|
402
|
+
const subjectElement = likec4model.element(subjectId)
|
|
403
|
+
|
|
404
|
+
if (!diagramNodes.has(subjectId)) {
|
|
405
|
+
// Reset scope to global if subject is not in the view
|
|
406
|
+
scope = 'global'
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const g = createGraph()
|
|
410
|
+
|
|
411
|
+
const ctx: Context = {
|
|
412
|
+
scope,
|
|
413
|
+
g,
|
|
414
|
+
diagramNodes,
|
|
415
|
+
subjectId,
|
|
416
|
+
subjectElement,
|
|
417
|
+
connected: fromKeys(
|
|
418
|
+
['incomers', 'outgoers'] as const,
|
|
419
|
+
(column) => new Set(subjectElement[column]().map(m => m.id))
|
|
420
|
+
),
|
|
421
|
+
columns: {
|
|
422
|
+
incomers: new Map(),
|
|
423
|
+
outgoers: new Map()
|
|
424
|
+
},
|
|
425
|
+
edges: []
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const subject: XYFlowTypes.ElementNode = {
|
|
429
|
+
id: subjectElement.id,
|
|
430
|
+
type: 'element',
|
|
431
|
+
position: { x: 0, y: 0 },
|
|
432
|
+
data: {
|
|
433
|
+
...nodeData(subjectElement, ctx),
|
|
434
|
+
column: 'subject'
|
|
435
|
+
},
|
|
436
|
+
zIndex: 3,
|
|
437
|
+
selectable: false,
|
|
438
|
+
focusable: false,
|
|
439
|
+
...sized()
|
|
440
|
+
}
|
|
441
|
+
g.setNode(subject.id, sized())
|
|
442
|
+
g.setParent(subject.id, graphColumn('subject').id)
|
|
443
|
+
|
|
444
|
+
processRelations({
|
|
445
|
+
// Based on the scope we pick relationships
|
|
446
|
+
...(scope === 'global'
|
|
447
|
+
? {
|
|
448
|
+
incoming: subjectElement.incoming().map(r => r.relationship),
|
|
449
|
+
outgoing: subjectElement.outgoing().map(r => r.relationship)
|
|
450
|
+
}
|
|
451
|
+
: {
|
|
452
|
+
incoming: likec4model.view(view.id).element(subjectId)
|
|
453
|
+
.incoming()
|
|
454
|
+
.flatMap(c => c.relationships().map(r => r.relationship)),
|
|
455
|
+
outgoing: likec4model.view(view.id).element(subjectId)
|
|
456
|
+
.outgoing()
|
|
457
|
+
.flatMap(c => c.relationships().map(r => r.relationship))
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
forEachIncoming(sourceId, relations) {
|
|
461
|
+
const source = createNode('incomers', 'element', likec4model.element(sourceId), ctx)
|
|
462
|
+
g.setEdge(graphId(source).port, subject.id, {
|
|
463
|
+
weight: source.type === 'compound' ? 0 : 1
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
const op = source.type === 'compound' ? 'unshift' : 'push'
|
|
467
|
+
|
|
468
|
+
subject.data.ports.left[op]({
|
|
469
|
+
id: source.id,
|
|
470
|
+
type: 'in'
|
|
471
|
+
})
|
|
472
|
+
source.data.ports.right.push({
|
|
473
|
+
id: subject.id,
|
|
474
|
+
type: 'out'
|
|
475
|
+
})
|
|
476
|
+
addEdge(ctx, {
|
|
477
|
+
source: source.id,
|
|
478
|
+
target: subject.id,
|
|
479
|
+
relations
|
|
480
|
+
})
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
forEachOutgoing(targetId, relations) {
|
|
484
|
+
const target = createNode('outgoers', 'element', likec4model.element(targetId), ctx)
|
|
485
|
+
g.setEdge(subjectElement.id, graphId(target).port, {
|
|
486
|
+
weight: target.type === 'compound' ? 2 : 1
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const op = target.type === 'compound' ? 'unshift' : 'push'
|
|
490
|
+
subject.data.ports.right[op]({
|
|
491
|
+
id: target.id,
|
|
492
|
+
type: 'out'
|
|
493
|
+
})
|
|
494
|
+
target.data.ports.left.push({
|
|
495
|
+
id: subject.id,
|
|
496
|
+
type: 'in'
|
|
497
|
+
})
|
|
498
|
+
addEdge(ctx, {
|
|
499
|
+
source: subjectElement.id,
|
|
500
|
+
target: target.id,
|
|
501
|
+
relations
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
if (ctx.columns.incomers.size == 0) {
|
|
507
|
+
const source = createEmptyNode('incomers', ctx)
|
|
508
|
+
g.setEdge(graphId(source).port, subject.id)
|
|
509
|
+
}
|
|
510
|
+
if (ctx.columns.outgoers.size == 0) {
|
|
511
|
+
const target = createEmptyNode('outgoers', ctx)
|
|
512
|
+
g.setEdge(subject.id, graphId(target).port)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const subjectPortsCount = Math.max(subject.data.ports.left.length, subject.data.ports.right.length)
|
|
516
|
+
if (subjectPortsCount > 2) {
|
|
517
|
+
g.node(subjectElement.id).height = Sizes.hodeHeight + (subjectPortsCount - 2) * 1
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const nodebounds = applyDagreLayout(ctx.g)
|
|
521
|
+
|
|
522
|
+
const xynodes = [
|
|
523
|
+
subject,
|
|
524
|
+
...ctx.columns.incomers.values(),
|
|
525
|
+
...ctx.columns.outgoers.values()
|
|
526
|
+
].map((node) => {
|
|
527
|
+
return {
|
|
528
|
+
...node,
|
|
529
|
+
...nodebounds(node.id, node.parentId)
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// Sort ports in subject vertically
|
|
534
|
+
const sortedPorts = (ports: XYFlowTypes.Port[]) => {
|
|
535
|
+
if (ports.length < 2) {
|
|
536
|
+
return ports
|
|
537
|
+
}
|
|
538
|
+
return pipe(
|
|
539
|
+
ports,
|
|
540
|
+
map(port => {
|
|
541
|
+
return {
|
|
542
|
+
port,
|
|
543
|
+
topY: nodebounds(port.id).position.y
|
|
544
|
+
}
|
|
545
|
+
}),
|
|
546
|
+
sortBy(prop('topY')),
|
|
547
|
+
map(prop('port'))
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Sort ports by their position
|
|
552
|
+
for (const node of xynodes) {
|
|
553
|
+
if (node.type === 'empty') {
|
|
554
|
+
const subjectPosition = nodebounds(subject.id)
|
|
555
|
+
node.position.y = subjectPosition.position.y + subjectPosition.height / 2 - node.height / 2
|
|
556
|
+
if (node.data.column === 'incomers') {
|
|
557
|
+
// Place on the left
|
|
558
|
+
node.position.x = subjectPosition.position.x - node.width - Sizes.emptyNodeOffset
|
|
559
|
+
} else {
|
|
560
|
+
// Place on the right
|
|
561
|
+
node.position.x = subjectPosition.position.x + subjectPosition.width + Sizes.emptyNodeOffset
|
|
562
|
+
}
|
|
563
|
+
continue
|
|
564
|
+
}
|
|
565
|
+
if (node.data.ports.left.length > 0) {
|
|
566
|
+
node.data.ports.left = sortedPorts(node.data.ports.left)
|
|
567
|
+
}
|
|
568
|
+
if (node.data.ports.right.length > 0) {
|
|
569
|
+
node.data.ports.right = sortedPorts(node.data.ports.right)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
view: view,
|
|
575
|
+
viewIncludesSubject: diagramNodes.has(subjectId),
|
|
576
|
+
subject: subjectElement,
|
|
577
|
+
edges: ctx.edges,
|
|
578
|
+
nodes: xynodes
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function useLayoutedRelationships(scope: 'global' | 'view') {
|
|
583
|
+
const {
|
|
584
|
+
subjectId,
|
|
585
|
+
view
|
|
586
|
+
} = useDiagramState(s => {
|
|
587
|
+
return {
|
|
588
|
+
subjectId: s.activeOverlay?.relationshipsOf,
|
|
589
|
+
view: s.view
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
invariant(subjectId, 'subject not found')
|
|
593
|
+
const likec4model = useLikeC4Model(true)
|
|
594
|
+
return useMemo(() =>
|
|
595
|
+
layout(
|
|
596
|
+
subjectId,
|
|
597
|
+
view,
|
|
598
|
+
likec4model,
|
|
599
|
+
scope
|
|
600
|
+
), [
|
|
601
|
+
subjectId,
|
|
602
|
+
view,
|
|
603
|
+
likec4model,
|
|
604
|
+
layout,
|
|
605
|
+
scope
|
|
606
|
+
])
|
|
607
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Text as MantineText } from '@mantine/core'
|
|
2
|
+
import { Handle, type NodeProps, Position } from '@xyflow/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { m } from 'framer-motion'
|
|
5
|
+
import type { XYFlowTypes } from '../_types'
|
|
6
|
+
import * as css from './styles.css'
|
|
7
|
+
|
|
8
|
+
const Text = MantineText.withProps({
|
|
9
|
+
component: 'div'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
type CompoundNodeProps = NodeProps<XYFlowTypes.CompoundNode>
|
|
13
|
+
|
|
14
|
+
export function CompoundNode({
|
|
15
|
+
data: {
|
|
16
|
+
element,
|
|
17
|
+
ports,
|
|
18
|
+
...data
|
|
19
|
+
},
|
|
20
|
+
width = 200,
|
|
21
|
+
selectable = true
|
|
22
|
+
}: CompoundNodeProps) {
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<m.div
|
|
26
|
+
className={clsx([
|
|
27
|
+
css.compoundNodeBody,
|
|
28
|
+
'likec4-compound-node'
|
|
29
|
+
])}
|
|
30
|
+
data-compound-depth={2}
|
|
31
|
+
data-likec4-color={element.color}
|
|
32
|
+
animate={{
|
|
33
|
+
opacity: data.dimmed ? 0.15 : 1,
|
|
34
|
+
transition: {
|
|
35
|
+
delay: data.dimmed ? .8 : 0
|
|
36
|
+
}
|
|
37
|
+
}}
|
|
38
|
+
{...(selectable && {
|
|
39
|
+
whileHover: {
|
|
40
|
+
scale: 1.04,
|
|
41
|
+
transition: {
|
|
42
|
+
delay: 0.15
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
whileTap: {
|
|
46
|
+
scale: 1
|
|
47
|
+
}
|
|
48
|
+
})}
|
|
49
|
+
>
|
|
50
|
+
<Text className={css.compoundNodeTitle} maw={width - 20}>{element.title}</Text>
|
|
51
|
+
</m.div>
|
|
52
|
+
{ports.left.map(({ id, type }, i) => (
|
|
53
|
+
<Handle
|
|
54
|
+
key={id}
|
|
55
|
+
id={id}
|
|
56
|
+
type={type === 'in' ? 'target' : 'source'}
|
|
57
|
+
position={Position.Left}
|
|
58
|
+
style={{
|
|
59
|
+
visibility: 'hidden',
|
|
60
|
+
top: `${16 + 20 * i}px`
|
|
61
|
+
}} />
|
|
62
|
+
))}
|
|
63
|
+
{ports.right.map(({ id, type }, i) => (
|
|
64
|
+
<Handle
|
|
65
|
+
key={id}
|
|
66
|
+
id={id}
|
|
67
|
+
type={type === 'in' ? 'target' : 'source'}
|
|
68
|
+
position={Position.Right}
|
|
69
|
+
style={{
|
|
70
|
+
visibility: 'hidden',
|
|
71
|
+
top: `${16 + 20 * i}px`
|
|
72
|
+
}} />
|
|
73
|
+
))}
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|