@likec4/diagram 1.13.0 → 1.15.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 +9 -9
- package/src/LikeC4CustomColors.tsx +6 -3
- package/src/LikeC4Diagram.css.ts +4 -0
- package/src/LikeC4Diagram.tsx +9 -7
- package/src/overlays/Overlays.css.ts +124 -0
- package/src/overlays/Overlays.tsx +210 -0
- package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +247 -0
- package/src/overlays/edge-details/SelectEdge.css.ts +99 -0
- package/src/overlays/edge-details/SelectEdge.tsx +153 -0
- package/src/overlays/edge-details/_types.ts +38 -0
- package/src/overlays/edge-details/use-layouted-edge-details.ts +356 -0
- package/src/overlays/edge-details/xyflow/CompoundNode.tsx +80 -0
- package/src/overlays/edge-details/xyflow/ElementNode.tsx +154 -0
- package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +81 -0
- package/src/overlays/edge-details/xyflow/styles.css.ts +196 -0
- package/src/overlays/index.tsx +1 -33
- package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +124 -63
- package/src/overlays/relationships-of/_types.ts +13 -2
- package/src/overlays/relationships-of/use-layouted-relationships.ts +37 -37
- package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +2 -2
- package/src/overlays/relationships-of/xyflow/ElementNode.tsx +47 -26
- package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +58 -22
- package/src/overlays/relationships-of/xyflow/styles.css.ts +53 -16
- package/src/state/DiagramContext.tsx +23 -5
- package/src/state/diagram-to-xyflow.ts +31 -20
- package/src/state/diagramStore.ts +30 -23
- package/src/xyflow/XYFlow.tsx +2 -1
- package/src/xyflow/XYFlowEvents.tsx +13 -1
- package/src/xyflow/edges/RelationshipEdge.tsx +10 -4
- package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +24 -21
- package/src/xyflow/nodes/compound/CompoundNode.css.ts +1 -0
- package/src/xyflow/nodes/compound/CompoundNode.tsx +11 -11
- package/src/xyflow/nodes/shared/Toolbar.tsx +1 -1
- package/src/xyflow/types.ts +1 -0
- package/src/xyflow/useLayoutConstraints.ts +38 -19
- package/src/components/Overlay.css.ts +0 -78
- package/src/components/Overlay.tsx +0 -102
- package/src/components/index.ts +0 -2
- package/src/overlays/edge-details/EdgeDetailsOverlay.css.ts +0 -13
- package/src/overlays/edge-details/EdgeDetailsOverlay.tsx +0 -37
- package/src/overlays/relationships-of/RelationshipsOverlay.tsx +0 -33
- package/src/overlays/relationships-of/styles.css.ts +0 -34
- /package/src/{components → overlays}/OverlayContext.tsx +0 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import dagre, { type GraphLabel, type Label } from '@dagrejs/dagre'
|
|
2
|
+
import {
|
|
3
|
+
compareFqnHierarchically,
|
|
4
|
+
compareRelations,
|
|
5
|
+
type DiagramEdge,
|
|
6
|
+
type DiagramNode,
|
|
7
|
+
type DiagramView,
|
|
8
|
+
type EdgeId,
|
|
9
|
+
type Fqn,
|
|
10
|
+
invariant,
|
|
11
|
+
isAncestor,
|
|
12
|
+
type LikeC4Model
|
|
13
|
+
} from '@likec4/core'
|
|
14
|
+
import { MarkerType } from '@xyflow/system'
|
|
15
|
+
import { useMemo } from 'react'
|
|
16
|
+
import { filter, first, forEach, isTruthy, map, pipe, prop, reverse, sort, sortBy, takeWhile } from 'remeda'
|
|
17
|
+
import { useDiagramState } from '../../hooks/useDiagramState'
|
|
18
|
+
import { useLikeC4Model } from '../../likec4model'
|
|
19
|
+
import type { XYFlowTypes } from './_types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* All constants related to the layout
|
|
23
|
+
*/
|
|
24
|
+
const Sizes = {
|
|
25
|
+
dagre: {
|
|
26
|
+
ranksep: 40,
|
|
27
|
+
nodesep: 25,
|
|
28
|
+
edgesep: 15
|
|
29
|
+
} satisfies GraphLabel,
|
|
30
|
+
edgeLabel: {
|
|
31
|
+
width: 120
|
|
32
|
+
} satisfies Label,
|
|
33
|
+
|
|
34
|
+
nodeWidth: 270,
|
|
35
|
+
hodeHeight: 160,
|
|
36
|
+
|
|
37
|
+
// Spacer between elements in a compound node
|
|
38
|
+
// 0 means no spacer
|
|
39
|
+
spacerHeight: 0,
|
|
40
|
+
|
|
41
|
+
compoundLabelHeight: 5
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ZIndexes = {
|
|
45
|
+
compound: 2,
|
|
46
|
+
edge: 3,
|
|
47
|
+
element: 4,
|
|
48
|
+
|
|
49
|
+
max: 5
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createGraph() {
|
|
53
|
+
const g = new dagre.graphlib.Graph({
|
|
54
|
+
directed: true,
|
|
55
|
+
compound: true
|
|
56
|
+
})
|
|
57
|
+
g.setGraph({
|
|
58
|
+
...Sizes.dagre,
|
|
59
|
+
rankdir: 'LR'
|
|
60
|
+
})
|
|
61
|
+
g.setDefaultEdgeLabel(() => ({ ...Sizes.edgeLabel }))
|
|
62
|
+
g.setDefaultNodeLabel(() => ({}))
|
|
63
|
+
|
|
64
|
+
return g
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Context = {
|
|
68
|
+
g: dagre.graphlib.Graph
|
|
69
|
+
diagramNodes: Map<Fqn, DiagramNode>
|
|
70
|
+
xynodes: Map<Fqn, XYFlowTypes.Node>
|
|
71
|
+
edge: DiagramEdge
|
|
72
|
+
edges: XYFlowTypes.Edge[]
|
|
73
|
+
}
|
|
74
|
+
const sized = (height: number = Sizes.hodeHeight) => ({
|
|
75
|
+
width: Sizes.nodeWidth,
|
|
76
|
+
height
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const graphId = (node: XYFlowTypes.Node) => ({
|
|
80
|
+
id: node.id,
|
|
81
|
+
port: node.type === 'compound' ? `${node.id}::port` : node.id,
|
|
82
|
+
body: `${node.id}`,
|
|
83
|
+
spacer: `${node.id}:spacer`
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function nodeData(
|
|
87
|
+
element: LikeC4Model.ElementModel,
|
|
88
|
+
ctx: Context
|
|
89
|
+
): Omit<XYFlowTypes.Node['data'], 'column'> {
|
|
90
|
+
// We try to inherit style from existing diagram node
|
|
91
|
+
let diagramNode = ctx.diagramNodes.get(element.id)
|
|
92
|
+
|
|
93
|
+
// Ansector separetely, because we want to inherit
|
|
94
|
+
// color from it if there is no diagram node
|
|
95
|
+
const ancestor = diagramNode ?? pipe(
|
|
96
|
+
element.ancestors(),
|
|
97
|
+
map(ancestor => ctx.diagramNodes.get(ancestor.id)),
|
|
98
|
+
filter(isTruthy),
|
|
99
|
+
first()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
fqn: element.id,
|
|
104
|
+
element: {
|
|
105
|
+
kind: element.kind,
|
|
106
|
+
title: diagramNode?.title ?? element.title,
|
|
107
|
+
description: diagramNode?.description ?? element.element.description,
|
|
108
|
+
color: diagramNode?.color ?? ancestor?.color ?? element.color,
|
|
109
|
+
shape: diagramNode?.shape ?? element.shape
|
|
110
|
+
},
|
|
111
|
+
navigateTo: diagramNode?.navigateTo ?? first(element.viewsOf())?.id ?? null,
|
|
112
|
+
ports: {
|
|
113
|
+
in: [],
|
|
114
|
+
out: []
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createNode(
|
|
120
|
+
nodeType: XYFlowTypes.Node['type'],
|
|
121
|
+
element: LikeC4Model.ElementModel,
|
|
122
|
+
ctx: Context
|
|
123
|
+
): XYFlowTypes.Node {
|
|
124
|
+
let node = ctx.xynodes.get(element.id)
|
|
125
|
+
if (node) {
|
|
126
|
+
return node
|
|
127
|
+
}
|
|
128
|
+
const g = ctx.g
|
|
129
|
+
|
|
130
|
+
// Create parent node
|
|
131
|
+
const parent = pipe(
|
|
132
|
+
element.ancestors(),
|
|
133
|
+
takeWhile(ancestor => !isAncestor(ancestor.id, ctx.edge.source) && !isAncestor(ancestor.id, ctx.edge.target)),
|
|
134
|
+
first(),
|
|
135
|
+
found => found ? createNode('compound', found, ctx) : null
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const xynode: XYFlowTypes.Node = {
|
|
139
|
+
type: nodeType,
|
|
140
|
+
id: element.id,
|
|
141
|
+
position: { x: 0, y: 0 },
|
|
142
|
+
data: {
|
|
143
|
+
...nodeData(element, ctx)
|
|
144
|
+
},
|
|
145
|
+
zIndex: ZIndexes[nodeType],
|
|
146
|
+
...(!!parent && { parentId: parent.id })
|
|
147
|
+
}
|
|
148
|
+
ctx.xynodes.set(element.id, xynode)
|
|
149
|
+
const k = graphId(xynode)
|
|
150
|
+
g.setNode(k.id, sized())
|
|
151
|
+
|
|
152
|
+
// There is one-to-one mapping between XYFlow node and dagre node
|
|
153
|
+
// Compound node has two nodes: body and port (for edges)
|
|
154
|
+
if (xynode.type === 'compound') {
|
|
155
|
+
g.setNode(k.port, {
|
|
156
|
+
width: Sizes.nodeWidth - Sizes.dagre.ranksep,
|
|
157
|
+
height: Sizes.compoundLabelHeight
|
|
158
|
+
})
|
|
159
|
+
g.setParent(k.port, k.id)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (parent) {
|
|
163
|
+
const parentGraphId = graphId(parent).body
|
|
164
|
+
g.setParent(k.id, parentGraphId)
|
|
165
|
+
|
|
166
|
+
// Add spacer after the last element
|
|
167
|
+
if (Sizes.spacerHeight > 0) {
|
|
168
|
+
g.setNode(k.spacer, {
|
|
169
|
+
width: Sizes.nodeWidth - Sizes.dagre.ranksep,
|
|
170
|
+
height: Sizes.compoundLabelHeight
|
|
171
|
+
})
|
|
172
|
+
g.setParent(k.spacer, parentGraphId)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return xynode
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Apply dagre layout to the graph
|
|
181
|
+
* And return a function to get node bounds for xyflow
|
|
182
|
+
*/
|
|
183
|
+
function applyDagreLayout(g: dagre.graphlib.Graph) {
|
|
184
|
+
type NodeBounds = Required<Pick<XYFlowTypes.Node, 'position' | 'width' | 'height'>>
|
|
185
|
+
dagre.layout(g)
|
|
186
|
+
return function nodeBounds(nodeId: string, relativeTo?: string): NodeBounds {
|
|
187
|
+
const { x, y, width, height } = g.node(nodeId)
|
|
188
|
+
const pos = {
|
|
189
|
+
position: {
|
|
190
|
+
x: x - Math.round(width / 2),
|
|
191
|
+
y: y - Math.round(height / 2)
|
|
192
|
+
},
|
|
193
|
+
width,
|
|
194
|
+
height
|
|
195
|
+
}
|
|
196
|
+
if (!relativeTo) {
|
|
197
|
+
return pos
|
|
198
|
+
}
|
|
199
|
+
const offset = nodeBounds(relativeTo).position
|
|
200
|
+
return {
|
|
201
|
+
position: {
|
|
202
|
+
x: pos.position.x - offset.x,
|
|
203
|
+
y: pos.position.y - offset.y
|
|
204
|
+
},
|
|
205
|
+
width: pos.width,
|
|
206
|
+
height: pos.height
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function layout(
|
|
212
|
+
edgeId: EdgeId,
|
|
213
|
+
view: DiagramView,
|
|
214
|
+
likec4model: LikeC4Model
|
|
215
|
+
): {
|
|
216
|
+
view: DiagramView
|
|
217
|
+
edge: DiagramEdge
|
|
218
|
+
nodes: XYFlowTypes.Node[]
|
|
219
|
+
edges: XYFlowTypes.Edge[]
|
|
220
|
+
bounds: { x: number; y: number; width: number; height: number }
|
|
221
|
+
} {
|
|
222
|
+
const edge = view.edges.find(e => e.id === edgeId)
|
|
223
|
+
invariant(edge, 'edge not found')
|
|
224
|
+
const all = new Set([edge.source, edge.target])
|
|
225
|
+
|
|
226
|
+
const relations = edge.relations
|
|
227
|
+
.map(r => {
|
|
228
|
+
const relation = likec4model.relationship(r).relationship
|
|
229
|
+
all.add(relation.source)
|
|
230
|
+
all.add(relation.target)
|
|
231
|
+
return relation
|
|
232
|
+
})
|
|
233
|
+
.sort(compareRelations)
|
|
234
|
+
.reverse()
|
|
235
|
+
|
|
236
|
+
const diagramNodes = new Map(view.nodes.map(n => [n.id, n]))
|
|
237
|
+
const g = createGraph()
|
|
238
|
+
|
|
239
|
+
const ctx: Context = {
|
|
240
|
+
g,
|
|
241
|
+
xynodes: new Map(),
|
|
242
|
+
edge,
|
|
243
|
+
diagramNodes,
|
|
244
|
+
edges: []
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
pipe(
|
|
248
|
+
[...all],
|
|
249
|
+
sort(compareFqnHierarchically),
|
|
250
|
+
reverse(),
|
|
251
|
+
forEach(id => {
|
|
252
|
+
const element = likec4model.element(id)
|
|
253
|
+
createNode('element', element, ctx)
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if (relations.length === 1 && all.size < 4) {
|
|
258
|
+
g.setDefaultEdgeLabel(() => ({ width: 250 }))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const relation of relations) {
|
|
262
|
+
const source = ctx.xynodes.get(relation.source)
|
|
263
|
+
invariant(source, 'source node not found')
|
|
264
|
+
const target = ctx.xynodes.get(relation.target)
|
|
265
|
+
invariant(target, 'target node not found')
|
|
266
|
+
|
|
267
|
+
source.data.ports.out.push(target.id)
|
|
268
|
+
target.data.ports.in.push(source.id)
|
|
269
|
+
|
|
270
|
+
g.setEdge(graphId(source).port, graphId(target).port)
|
|
271
|
+
const edge: XYFlowTypes.Edge = {
|
|
272
|
+
id: relation.id,
|
|
273
|
+
type: 'relation',
|
|
274
|
+
source: source.id,
|
|
275
|
+
target: target.id,
|
|
276
|
+
sourceHandle: target.id,
|
|
277
|
+
targetHandle: source.id,
|
|
278
|
+
data: {
|
|
279
|
+
relation
|
|
280
|
+
},
|
|
281
|
+
label: relation.title,
|
|
282
|
+
zIndex: ZIndexes.edge,
|
|
283
|
+
markerEnd: {
|
|
284
|
+
type: MarkerType.ArrowClosed,
|
|
285
|
+
width: 10
|
|
286
|
+
},
|
|
287
|
+
style: {
|
|
288
|
+
strokeWidth: 2.2,
|
|
289
|
+
strokeDasharray: '5, 5'
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
ctx.edges.push(edge)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const nodebounds = applyDagreLayout(ctx.g)
|
|
296
|
+
|
|
297
|
+
// Sort ports
|
|
298
|
+
const sortedPorts = (ports: string[]) => {
|
|
299
|
+
if (ports.length < 2) {
|
|
300
|
+
return ports
|
|
301
|
+
}
|
|
302
|
+
return pipe(
|
|
303
|
+
ports,
|
|
304
|
+
map(port => {
|
|
305
|
+
return {
|
|
306
|
+
port,
|
|
307
|
+
topY: nodebounds(port).position.y
|
|
308
|
+
}
|
|
309
|
+
}),
|
|
310
|
+
sortBy(prop('topY')),
|
|
311
|
+
map(prop('port'))
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const xynodes = [...ctx.xynodes.values()].map((node) => {
|
|
316
|
+
if (node.data.ports.in.length > 1) {
|
|
317
|
+
node.data.ports.in = sortedPorts(node.data.ports.in)
|
|
318
|
+
}
|
|
319
|
+
if (node.data.ports.out.length > 1) {
|
|
320
|
+
node.data.ports.out = sortedPorts(node.data.ports.out)
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...node,
|
|
324
|
+
...nodebounds(node.id, node.parentId)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
view,
|
|
330
|
+
edge,
|
|
331
|
+
edges: ctx.edges,
|
|
332
|
+
nodes: xynodes,
|
|
333
|
+
bounds: {
|
|
334
|
+
x: 0,
|
|
335
|
+
y: 0,
|
|
336
|
+
width: g.graph().width ?? 0,
|
|
337
|
+
height: g.graph().height ?? 0
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function useLayoutedEdgeDetails(edgeId: EdgeId) {
|
|
343
|
+
const view = useDiagramState(s => s.view)
|
|
344
|
+
const likec4model = useLikeC4Model(true)
|
|
345
|
+
return useMemo(() =>
|
|
346
|
+
layout(
|
|
347
|
+
edgeId,
|
|
348
|
+
view,
|
|
349
|
+
likec4model
|
|
350
|
+
), [
|
|
351
|
+
edgeId,
|
|
352
|
+
view,
|
|
353
|
+
likec4model,
|
|
354
|
+
layout
|
|
355
|
+
])
|
|
356
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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={3}
|
|
31
|
+
data-likec4-color={element.color}
|
|
32
|
+
animate={{
|
|
33
|
+
opacity: data.dimmed ? 0.15 : 1,
|
|
34
|
+
transition: {
|
|
35
|
+
delay: data.dimmed === true ? .4 : 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.out.map((id, i) => (
|
|
53
|
+
<Handle
|
|
54
|
+
key={id}
|
|
55
|
+
id={id}
|
|
56
|
+
type={'source'}
|
|
57
|
+
position={Position.Right}
|
|
58
|
+
style={{
|
|
59
|
+
visibility: 'hidden',
|
|
60
|
+
top: `${16 + 20 * i}px`
|
|
61
|
+
}} />
|
|
62
|
+
))}
|
|
63
|
+
{ports.in.map((id, i) => (
|
|
64
|
+
<Handle
|
|
65
|
+
key={id}
|
|
66
|
+
id={id}
|
|
67
|
+
type={'target'}
|
|
68
|
+
position={Position.Left}
|
|
69
|
+
style={{
|
|
70
|
+
visibility: 'hidden',
|
|
71
|
+
top: `${16 + 20 * i}px`
|
|
72
|
+
}} />
|
|
73
|
+
))}
|
|
74
|
+
{
|
|
75
|
+
/* <Handle type="target" position={Position.Left}/>
|
|
76
|
+
<Handle type="source" position={Position.Right}/> */
|
|
77
|
+
}
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ActionIcon, Box, Group, Text as MantineText } from '@mantine/core'
|
|
2
|
+
import { IconFileSymlink, IconTransform, IconZoomScan } from '@tabler/icons-react'
|
|
3
|
+
import { Handle, type NodeProps, Position } from '@xyflow/react'
|
|
4
|
+
import clsx from 'clsx'
|
|
5
|
+
import { m } from 'framer-motion'
|
|
6
|
+
import { type DiagramState, useDiagramState } from '../../../hooks'
|
|
7
|
+
import { ElementShapeSvg } from '../../../xyflow/nodes/element/ElementShapeSvg'
|
|
8
|
+
import { stopPropagation } from '../../../xyflow/utils'
|
|
9
|
+
import { useOverlayDialog } from '../../OverlayContext'
|
|
10
|
+
import type { XYFlowTypes } from '../_types'
|
|
11
|
+
import * as css from './styles.css'
|
|
12
|
+
|
|
13
|
+
const Action = ActionIcon.withProps({
|
|
14
|
+
className: 'nodrag nopan ' + css.navigateBtn,
|
|
15
|
+
radius: 'md',
|
|
16
|
+
role: 'button',
|
|
17
|
+
onDoubleClick: stopPropagation,
|
|
18
|
+
onPointerDownCapture: stopPropagation
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const Text = MantineText.withProps({
|
|
22
|
+
component: 'div'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
type ElementNodeProps = NodeProps<XYFlowTypes.ElementNode>
|
|
26
|
+
|
|
27
|
+
function selector(s: DiagramState) {
|
|
28
|
+
return {
|
|
29
|
+
currentViewId: s.view.id,
|
|
30
|
+
onNavigateTo: s.onNavigateTo,
|
|
31
|
+
onOpenSource: s.onOpenSourceElement
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ElementNode({
|
|
36
|
+
data: {
|
|
37
|
+
element,
|
|
38
|
+
ports,
|
|
39
|
+
navigateTo,
|
|
40
|
+
...data
|
|
41
|
+
},
|
|
42
|
+
selectable = true,
|
|
43
|
+
width: w = 100,
|
|
44
|
+
height: h = 100
|
|
45
|
+
}: ElementNodeProps) {
|
|
46
|
+
const overlay = useOverlayDialog()
|
|
47
|
+
const {
|
|
48
|
+
currentViewId,
|
|
49
|
+
onNavigateTo,
|
|
50
|
+
onOpenSource
|
|
51
|
+
} = useDiagramState(selector)
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<m.div
|
|
55
|
+
className={clsx([
|
|
56
|
+
css.elementNode,
|
|
57
|
+
'likec4-element-node'
|
|
58
|
+
])}
|
|
59
|
+
data-likec4-color={element.color}
|
|
60
|
+
data-likec4-shape={element.shape}
|
|
61
|
+
animate={{
|
|
62
|
+
opacity: data.dimmed ? 0.15 : 1,
|
|
63
|
+
transition: {
|
|
64
|
+
delay: data.dimmed === true ? .4 : 0
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
{...(selectable && {
|
|
68
|
+
whileHover: {
|
|
69
|
+
scale: 1.045,
|
|
70
|
+
transition: {
|
|
71
|
+
delay: 0.15
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
whileTap: {
|
|
75
|
+
scale: 0.97
|
|
76
|
+
}
|
|
77
|
+
})}
|
|
78
|
+
>
|
|
79
|
+
<svg
|
|
80
|
+
className={clsx(
|
|
81
|
+
css.cssShapeSvg
|
|
82
|
+
)}
|
|
83
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
84
|
+
width={w}
|
|
85
|
+
height={h}
|
|
86
|
+
>
|
|
87
|
+
<ElementShapeSvg shape={element.shape} w={w} h={h} />
|
|
88
|
+
</svg>
|
|
89
|
+
<Box className={css.elementNodeContent}>
|
|
90
|
+
<Text className={css.elementNodeTitle} lineClamp={2}>{element.title}</Text>
|
|
91
|
+
{element.description && (
|
|
92
|
+
<Text className={css.elementNodeDescription} lineClamp={4}>{element.description}</Text>
|
|
93
|
+
)}
|
|
94
|
+
</Box>
|
|
95
|
+
<Group className={css.navigateBtnBox}>
|
|
96
|
+
{navigateTo && onNavigateTo && navigateTo !== currentViewId && (
|
|
97
|
+
<Action
|
|
98
|
+
onClick={(event) => {
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
setTimeout(() => onNavigateTo(navigateTo), 100)
|
|
101
|
+
overlay.close()
|
|
102
|
+
}}>
|
|
103
|
+
<IconZoomScan stroke={1.8} style={{ width: '75%' }} />
|
|
104
|
+
</Action>
|
|
105
|
+
)}
|
|
106
|
+
<Action
|
|
107
|
+
onClick={(event) => {
|
|
108
|
+
event.stopPropagation()
|
|
109
|
+
overlay.openOverlay({
|
|
110
|
+
relationshipsOf: data.fqn
|
|
111
|
+
})
|
|
112
|
+
}}>
|
|
113
|
+
<IconTransform stroke={1.8} style={{ width: '72%' }} />
|
|
114
|
+
</Action>
|
|
115
|
+
{onOpenSource && (
|
|
116
|
+
<Action
|
|
117
|
+
onClick={(event) => {
|
|
118
|
+
event.stopPropagation()
|
|
119
|
+
onOpenSource(data.fqn)
|
|
120
|
+
}}>
|
|
121
|
+
<IconFileSymlink stroke={1.8} style={{ width: '72%' }} />
|
|
122
|
+
</Action>
|
|
123
|
+
)}
|
|
124
|
+
</Group>
|
|
125
|
+
</m.div>
|
|
126
|
+
{ports.out.map((id, i) => (
|
|
127
|
+
<Handle
|
|
128
|
+
key={id}
|
|
129
|
+
id={id}
|
|
130
|
+
type="source"
|
|
131
|
+
position={Position.Right}
|
|
132
|
+
style={{
|
|
133
|
+
visibility: 'hidden',
|
|
134
|
+
top: `${15 + (i + 1) * ((h - 30) / (ports.out.length + 1))}px`
|
|
135
|
+
}} />
|
|
136
|
+
))}
|
|
137
|
+
{ports.in.map((id, i) => (
|
|
138
|
+
<Handle
|
|
139
|
+
key={id}
|
|
140
|
+
id={id}
|
|
141
|
+
type="target"
|
|
142
|
+
position={Position.Left}
|
|
143
|
+
style={{
|
|
144
|
+
visibility: 'hidden',
|
|
145
|
+
top: `${15 + (i + 1) * ((h - 30) / (ports.in.length + 1))}px`
|
|
146
|
+
}} />
|
|
147
|
+
))}
|
|
148
|
+
{
|
|
149
|
+
/* <Handle type="target" position={Position.Left} />
|
|
150
|
+
<Handle type="source" position={Position.Right} /> */
|
|
151
|
+
}
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ActionIcon, Box, Stack, Text } from '@mantine/core'
|
|
2
|
+
import { IconZoomScan } from '@tabler/icons-react'
|
|
3
|
+
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getBezierPath } from '@xyflow/react'
|
|
4
|
+
import clsx from 'clsx'
|
|
5
|
+
import { useDiagramState } from '../../../hooks/useDiagramState'
|
|
6
|
+
import { stopPropagation } from '../../../xyflow/utils'
|
|
7
|
+
import type { XYFlowTypes } from '../_types'
|
|
8
|
+
import { ZIndexes } from '../use-layouted-edge-details'
|
|
9
|
+
import * as css from './styles.css'
|
|
10
|
+
|
|
11
|
+
export function RelationshipEdge({
|
|
12
|
+
data,
|
|
13
|
+
label,
|
|
14
|
+
...props
|
|
15
|
+
}: EdgeProps<XYFlowTypes.Edge>) {
|
|
16
|
+
const onNavigateTo = useDiagramState(s => s.onNavigateTo)
|
|
17
|
+
const [edgePath, labelX, labelY] = getBezierPath(props)
|
|
18
|
+
const navigateTo = data.relation.navigateTo
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<g
|
|
22
|
+
className={css.edgePath}
|
|
23
|
+
data-edge-dimmed={data.dimmed}
|
|
24
|
+
data-edge-hovered={data.hovered}
|
|
25
|
+
>
|
|
26
|
+
<BaseEdge
|
|
27
|
+
path={edgePath}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
</g>
|
|
31
|
+
<EdgeLabelRenderer>
|
|
32
|
+
<Stack
|
|
33
|
+
gap={2}
|
|
34
|
+
style={{
|
|
35
|
+
position: 'absolute',
|
|
36
|
+
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
|
37
|
+
maxWidth: Math.abs(props.targetX - props.sourceX - 40),
|
|
38
|
+
zIndex: ZIndexes.max
|
|
39
|
+
}}
|
|
40
|
+
className={clsx([
|
|
41
|
+
css.edgeLabel,
|
|
42
|
+
'nodrag nopan'
|
|
43
|
+
])}
|
|
44
|
+
data-edge-dimmed={data.dimmed}
|
|
45
|
+
data-edge-hovered={data.hovered}
|
|
46
|
+
>
|
|
47
|
+
{label && (
|
|
48
|
+
<Text component={'div'} className={css.edgeLabelText} lineClamp={3}>
|
|
49
|
+
{label}
|
|
50
|
+
</Text>
|
|
51
|
+
)}
|
|
52
|
+
{data.relation.technology && (
|
|
53
|
+
<Text component={'div'} className={css.edgeLabelTechnology}>
|
|
54
|
+
{'[ '}
|
|
55
|
+
{data.relation.technology}
|
|
56
|
+
{' ]'}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
59
|
+
{navigateTo && onNavigateTo && (
|
|
60
|
+
<Box ta={'center'} mt={4}>
|
|
61
|
+
<ActionIcon
|
|
62
|
+
variant="default"
|
|
63
|
+
size={'sm'}
|
|
64
|
+
radius="sm"
|
|
65
|
+
onPointerDownCapture={stopPropagation}
|
|
66
|
+
onClick={event => {
|
|
67
|
+
event.stopPropagation()
|
|
68
|
+
onNavigateTo(navigateTo, event)
|
|
69
|
+
}}
|
|
70
|
+
role="button"
|
|
71
|
+
onDoubleClick={stopPropagation}
|
|
72
|
+
>
|
|
73
|
+
<IconZoomScan />
|
|
74
|
+
</ActionIcon>
|
|
75
|
+
</Box>
|
|
76
|
+
)}
|
|
77
|
+
</Stack>
|
|
78
|
+
</EdgeLabelRenderer>
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|