@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.
Files changed (49) hide show
  1. package/package.json +8 -8
  2. package/src/LikeC4CustomColors.tsx +2 -2
  3. package/src/LikeC4Diagram.props.ts +15 -7
  4. package/src/LikeC4Diagram.tsx +23 -14
  5. package/src/StaticLikeC4Diagram.tsx +2 -0
  6. package/src/components/Overlay.css.ts +78 -0
  7. package/src/components/Overlay.tsx +102 -0
  8. package/src/components/OverlayContext.tsx +11 -0
  9. package/src/components/index.ts +2 -0
  10. package/src/likec4model/index.ts +1 -0
  11. package/src/likec4model/useLikeC4ElementsTree.ts +44 -0
  12. package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +13 -0
  13. package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +37 -0
  14. package/src/overlays/index.tsx +33 -0
  15. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +33 -0
  16. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +359 -0
  17. package/src/overlays/relationships-of/SelectElement.css.ts +18 -0
  18. package/src/overlays/relationships-of/SelectElement.tsx +133 -0
  19. package/src/overlays/relationships-of/_types.ts +56 -0
  20. package/src/overlays/relationships-of/styles.css.ts +34 -0
  21. package/src/overlays/relationships-of/use-layouted-relationships.ts +607 -0
  22. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +76 -0
  23. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +130 -0
  24. package/src/overlays/relationships-of/xyflow/EmptyNode.tsx +22 -0
  25. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +59 -0
  26. package/src/overlays/relationships-of/xyflow/styles.css.ts +170 -0
  27. package/src/state/diagram-to-xyflow.ts +1 -1
  28. package/src/state/diagramStore.ts +119 -27
  29. package/src/ui/top-left/ChangeAutoLayoutButton.tsx +1 -1
  30. package/src/ui/top-left/ManualLayoutToolsButton.tsx +110 -0
  31. package/src/ui/top-left/TopLeftPanel.tsx +15 -31
  32. package/src/ui/top-left/_shared.ts +2 -2
  33. package/src/xyflow/EdgeMarkers.tsx +19 -0
  34. package/src/xyflow/XYFlowEvents.tsx +21 -24
  35. package/src/xyflow/XYFlowInner.tsx +6 -10
  36. package/src/xyflow/edges/EdgeLabel.tsx +6 -5
  37. package/src/xyflow/edges/RelationshipEdge.tsx +4 -3
  38. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +16 -0
  39. package/src/xyflow/edges/edges.css.ts +5 -11
  40. package/src/xyflow/nodes/compound/CompoundNode.css.ts +17 -40
  41. package/src/xyflow/nodes/compound/CompoundNode.tsx +219 -28
  42. package/src/xyflow/nodes/element/ElementLink.tsx +11 -8
  43. package/src/xyflow/nodes/element/ElementNode.tsx +211 -140
  44. package/src/xyflow/nodes/element/element.css.ts +22 -33
  45. package/src/xyflow/nodes/shared/Toolbar.tsx +81 -27
  46. package/src/xyflow/nodes/use-animate-variants.tsx +64 -0
  47. package/src/xyflow/useLayoutConstraints.ts +95 -125
  48. package/src/xyflow/utils.ts +4 -0
  49. 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
+ }