@truedat/core 8.4.7 → 8.4.9
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 -4
- package/src/components/Graph.js +693 -57
- package/src/components/__tests__/Graph.spec.js +87 -0
- package/src/components/graph/ColoredEdge.js +166 -0
- package/src/components/graph/ConceptNode.js +19 -0
- package/src/components/graph/ConceptNode.less +64 -0
- package/src/components/graph/edgeLayout.js +11 -0
- package/src/styles/graph.less +30 -0
- package/src/styles/graphTokens.less +26 -0
- package/src/styles/lineageTokens.less +12 -0
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/core",
|
|
3
|
-
"version": "8.4.
|
|
3
|
+
"version": "8.4.9",
|
|
4
4
|
"description": "Truedat Web Core",
|
|
5
|
-
"sideEffects":
|
|
5
|
+
"sideEffects": [
|
|
6
|
+
"**/*.css",
|
|
7
|
+
"**/*.less"
|
|
8
|
+
],
|
|
6
9
|
"module": "src/index.js",
|
|
7
10
|
"files": [
|
|
8
11
|
"src",
|
|
@@ -51,7 +54,7 @@
|
|
|
51
54
|
"@testing-library/jest-dom": "^6.6.3",
|
|
52
55
|
"@testing-library/react": "^16.3.0",
|
|
53
56
|
"@testing-library/user-event": "^14.6.1",
|
|
54
|
-
"@truedat/test": "8.4.
|
|
57
|
+
"@truedat/test": "8.4.9",
|
|
55
58
|
"identity-obj-proxy": "^3.0.0",
|
|
56
59
|
"jest": "^29.7.0",
|
|
57
60
|
"redux-saga-test-plan": "^4.0.6"
|
|
@@ -97,5 +100,5 @@
|
|
|
97
100
|
"swr": "^2.3.3",
|
|
98
101
|
"turndown": "^7.2.2"
|
|
99
102
|
},
|
|
100
|
-
"gitHead": "
|
|
103
|
+
"gitHead": "9fd4bd2126a33342009194c8ae1829cd3c617a48"
|
|
101
104
|
}
|
package/src/components/Graph.js
CHANGED
|
@@ -1,69 +1,561 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useIntl } from "react-intl";
|
|
3
3
|
import PropTypes from "prop-types";
|
|
4
|
+
import { Modal, Popup } from "semantic-ui-react";
|
|
4
5
|
import {
|
|
5
6
|
ReactFlow,
|
|
6
7
|
ReactFlowProvider,
|
|
7
8
|
useNodesState,
|
|
8
9
|
useEdgesState,
|
|
9
10
|
useReactFlow,
|
|
11
|
+
getNodesBounds,
|
|
12
|
+
getViewportForBounds,
|
|
10
13
|
Position,
|
|
11
14
|
MarkerType,
|
|
15
|
+
Controls,
|
|
16
|
+
ControlButton,
|
|
17
|
+
Background,
|
|
18
|
+
BackgroundVariant,
|
|
12
19
|
} from "@xyflow/react";
|
|
13
20
|
import ELK from "elkjs/lib/elk.bundled.js";
|
|
21
|
+
import ConceptNode from "./graph/ConceptNode";
|
|
22
|
+
import ColoredEdge from "./graph/ColoredEdge";
|
|
23
|
+
import { getTargetSlotOffset } from "./graph/edgeLayout";
|
|
14
24
|
import "@xyflow/react/dist/style.css";
|
|
15
25
|
|
|
26
|
+
const nodeTypes = { concept: ConceptNode };
|
|
27
|
+
const edgeTypes = { colored: ColoredEdge };
|
|
28
|
+
|
|
16
29
|
const elk = new ELK();
|
|
30
|
+
const DEFAULT_NODE_WIDTH = 130;
|
|
31
|
+
const DEFAULT_NODE_HEIGHT = 44;
|
|
32
|
+
const TRANSLATE_EXTENT_PADDING = 400;
|
|
33
|
+
const DEFAULT_GRAPH_HEIGHT = "640px";
|
|
34
|
+
const EXPANDED_GRAPH_HEIGHT = "82vh";
|
|
35
|
+
const DEFAULT_MIN_ZOOM = 0.1;
|
|
36
|
+
const DEFAULT_MAX_ZOOM = 2;
|
|
37
|
+
const FIT_VIEW_PADDING = 0.08;
|
|
38
|
+
const DEFAULT_TRANSLATE_EXTENT = [
|
|
39
|
+
[-800, -600],
|
|
40
|
+
[800, 600],
|
|
41
|
+
];
|
|
42
|
+
const CONTROL_TOOLTIP_DELAY = 120;
|
|
43
|
+
const DEFAULT_EDGE_STROKE = "var(--td-graph-edge-stroke, #b0b8c8)";
|
|
44
|
+
const MIN_NODE_VERTICAL_GAP = 12;
|
|
17
45
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
const getIncomingEdgeGroupKey = (edge, stroke) =>
|
|
47
|
+
`${edge.target}::${stroke || DEFAULT_EDGE_STROKE}`;
|
|
48
|
+
|
|
49
|
+
const getNodeRelationGroupKey = (edge) =>
|
|
50
|
+
edge?.data?.primaryType || edge?.style?.stroke || DEFAULT_EDGE_STROKE;
|
|
51
|
+
|
|
52
|
+
const buildSignedNodeLevels = (nodes = [], edges = [], rootNodeId) => {
|
|
53
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
54
|
+
|
|
55
|
+
if (!rootNodeId || !nodeIds.has(rootNodeId)) return new Map();
|
|
56
|
+
|
|
57
|
+
const adjacency = new Map();
|
|
58
|
+
|
|
59
|
+
edges.forEach((edge) => {
|
|
60
|
+
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return;
|
|
61
|
+
|
|
62
|
+
const sourceNeighbours = adjacency.get(edge.source) || [];
|
|
63
|
+
const targetNeighbours = adjacency.get(edge.target) || [];
|
|
64
|
+
|
|
65
|
+
sourceNeighbours.push({ id: edge.target, delta: 1 });
|
|
66
|
+
targetNeighbours.push({ id: edge.source, delta: -1 });
|
|
67
|
+
|
|
68
|
+
adjacency.set(edge.source, sourceNeighbours);
|
|
69
|
+
adjacency.set(edge.target, targetNeighbours);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const levels = new Map([[rootNodeId, 0]]);
|
|
73
|
+
const queue = [rootNodeId];
|
|
74
|
+
|
|
75
|
+
while (queue.length) {
|
|
76
|
+
const nodeId = queue.shift();
|
|
77
|
+
const currentLevel = levels.get(nodeId) || 0;
|
|
78
|
+
|
|
79
|
+
(adjacency.get(nodeId) || []).forEach(({ id, delta }) => {
|
|
80
|
+
const nextLevel = currentLevel + delta;
|
|
81
|
+
const previousLevel = levels.get(id);
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
previousLevel === undefined ||
|
|
85
|
+
Math.abs(nextLevel) < Math.abs(previousLevel)
|
|
86
|
+
) {
|
|
87
|
+
levels.set(id, nextLevel);
|
|
88
|
+
queue.push(id);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return levels;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getNodeOrderingMeta = (node, levels, edges) => {
|
|
97
|
+
const rank = levels.get(node.id) || 0;
|
|
98
|
+
const connectedEdges = edges.filter(
|
|
99
|
+
(edge) => edge.source === node.id || edge.target === node.id
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const preferredEdges = connectedEdges.filter((edge) => {
|
|
103
|
+
if (rank < 0 && edge.source === node.id) {
|
|
104
|
+
const neighbourRank = levels.get(edge.target);
|
|
105
|
+
return neighbourRank !== undefined && Math.abs(neighbourRank) < Math.abs(rank);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (rank > 0 && edge.target === node.id) {
|
|
109
|
+
const neighbourRank = levels.get(edge.source);
|
|
110
|
+
return neighbourRank !== undefined && Math.abs(neighbourRank) < Math.abs(rank);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const orderingEdges = preferredEdges.length ? preferredEdges : connectedEdges;
|
|
117
|
+
const anchorIds = orderingEdges
|
|
118
|
+
.map((edge) => (edge.source === node.id ? edge.target : edge.source))
|
|
119
|
+
.sort();
|
|
120
|
+
const relationGroups = orderingEdges
|
|
121
|
+
.map((edge) => getNodeRelationGroupKey(edge))
|
|
122
|
+
.sort();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
rank,
|
|
126
|
+
anchorId: anchorIds[0] || "",
|
|
127
|
+
relationGroup: relationGroups[0] || "",
|
|
128
|
+
label: node?.data?.label || "",
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const orderNodesByRelationGroup = (nodes = [], edges = [], rootNodeId) => {
|
|
133
|
+
if (!rootNodeId) return nodes;
|
|
134
|
+
|
|
135
|
+
const levels = buildSignedNodeLevels(nodes, edges, rootNodeId);
|
|
136
|
+
|
|
137
|
+
if (!levels.size) return nodes;
|
|
138
|
+
|
|
139
|
+
return [...nodes].sort((nodeA, nodeB) => {
|
|
140
|
+
const metaA = getNodeOrderingMeta(nodeA, levels, edges);
|
|
141
|
+
const metaB = getNodeOrderingMeta(nodeB, levels, edges);
|
|
142
|
+
|
|
143
|
+
if (metaA.rank !== metaB.rank) return metaA.rank - metaB.rank;
|
|
144
|
+
if (metaA.anchorId !== metaB.anchorId) {
|
|
145
|
+
return metaA.anchorId.localeCompare(metaB.anchorId);
|
|
146
|
+
}
|
|
147
|
+
if (metaA.relationGroup !== metaB.relationGroup) {
|
|
148
|
+
return metaA.relationGroup.localeCompare(metaB.relationGroup);
|
|
149
|
+
}
|
|
150
|
+
if (metaA.label !== metaB.label) return metaA.label.localeCompare(metaB.label);
|
|
151
|
+
|
|
152
|
+
return nodeA.id.localeCompare(nodeB.id);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const getNodeHeight = (node) =>
|
|
157
|
+
node.height ?? node.measured?.height ?? DEFAULT_NODE_HEIGHT;
|
|
158
|
+
|
|
159
|
+
const getNodeCenterY = (node) => (node.y ?? node.position?.y ?? 0) + getNodeHeight(node) / 2;
|
|
160
|
+
|
|
161
|
+
const alignNodesToConnectionPoints = (nodes = [], edges = [], rootNodeId) => {
|
|
162
|
+
if (!rootNodeId) return nodes;
|
|
163
|
+
|
|
164
|
+
const levels = buildSignedNodeLevels(nodes, edges, rootNodeId);
|
|
165
|
+
|
|
166
|
+
if (!levels.size) return nodes;
|
|
167
|
+
|
|
168
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
169
|
+
const desiredCentersById = new Map();
|
|
170
|
+
|
|
171
|
+
nodes.forEach((node) => {
|
|
172
|
+
const rank = levels.get(node.id) || 0;
|
|
173
|
+
|
|
174
|
+
if (rank === 0) return;
|
|
175
|
+
|
|
176
|
+
const preferredEdges = edges.filter((edge) => {
|
|
177
|
+
if (rank < 0 && edge.source === node.id) {
|
|
178
|
+
const neighbourRank = levels.get(edge.target);
|
|
179
|
+
return neighbourRank !== undefined && Math.abs(neighbourRank) < Math.abs(rank);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (rank > 0 && edge.target === node.id) {
|
|
183
|
+
const neighbourRank = levels.get(edge.source);
|
|
184
|
+
return neighbourRank !== undefined && Math.abs(neighbourRank) < Math.abs(rank);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return false;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (preferredEdges.length !== 1) return;
|
|
191
|
+
|
|
192
|
+
const edge = preferredEdges[0];
|
|
193
|
+
const slotOffset = getTargetSlotOffset(
|
|
194
|
+
edge.data?.targetSlotIndex,
|
|
195
|
+
edge.data?.targetSlotCount
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (rank < 0) {
|
|
199
|
+
const targetNode = nodeById.get(edge.target);
|
|
200
|
+
|
|
201
|
+
if (!targetNode) return;
|
|
202
|
+
|
|
203
|
+
desiredCentersById.set(node.id, getNodeCenterY(targetNode) + slotOffset);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sourceNode = nodeById.get(edge.source);
|
|
208
|
+
|
|
209
|
+
if (!sourceNode) return;
|
|
210
|
+
|
|
211
|
+
desiredCentersById.set(node.id, getNodeCenterY(sourceNode) - slotOffset);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const nodesByRank = new Map();
|
|
215
|
+
|
|
216
|
+
nodes.forEach((node) => {
|
|
217
|
+
const rank = levels.get(node.id) || 0;
|
|
218
|
+
const bucket = nodesByRank.get(rank) || [];
|
|
219
|
+
|
|
220
|
+
bucket.push(node);
|
|
221
|
+
nodesByRank.set(rank, bucket);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const alignedNodes = new Map();
|
|
225
|
+
|
|
226
|
+
nodesByRank.forEach((rankNodes) => {
|
|
227
|
+
const orderedNodes = [...rankNodes].sort((nodeA, nodeB) => {
|
|
228
|
+
const desiredA = desiredCentersById.get(nodeA.id) ?? getNodeCenterY(nodeA);
|
|
229
|
+
const desiredB = desiredCentersById.get(nodeB.id) ?? getNodeCenterY(nodeB);
|
|
230
|
+
|
|
231
|
+
if (desiredA !== desiredB) return desiredA - desiredB;
|
|
232
|
+
|
|
233
|
+
return (nodeA.y ?? nodeA.position?.y ?? 0) - (nodeB.y ?? nodeB.position?.y ?? 0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
let previousBottom = Number.NEGATIVE_INFINITY;
|
|
237
|
+
|
|
238
|
+
orderedNodes.forEach((node) => {
|
|
239
|
+
const height = getNodeHeight(node);
|
|
240
|
+
const desiredCenter = desiredCentersById.get(node.id) ?? getNodeCenterY(node);
|
|
241
|
+
const desiredTop = desiredCenter - height / 2;
|
|
242
|
+
const nextTop = Math.max(desiredTop, previousBottom + MIN_NODE_VERTICAL_GAP);
|
|
243
|
+
|
|
244
|
+
previousBottom = nextTop + height;
|
|
245
|
+
alignedNodes.set(node.id, {
|
|
246
|
+
...node,
|
|
247
|
+
y: nextTop,
|
|
248
|
+
position: { ...(node.position || {}), x: node.x, y: nextTop },
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return nodes.map((node) => alignedNodes.get(node.id) || node);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const getMinZoomForDiagram = (
|
|
257
|
+
nodes = [],
|
|
258
|
+
viewportWidth,
|
|
259
|
+
viewportHeight,
|
|
260
|
+
padding = FIT_VIEW_PADDING
|
|
261
|
+
) => {
|
|
262
|
+
if (!nodes.length || !viewportWidth || !viewportHeight) return DEFAULT_MIN_ZOOM;
|
|
263
|
+
|
|
264
|
+
const bounds = getNodesBounds(nodes);
|
|
265
|
+
|
|
266
|
+
if (!bounds.width || !bounds.height) return DEFAULT_MIN_ZOOM;
|
|
267
|
+
|
|
268
|
+
return Math.max(
|
|
269
|
+
getViewportForBounds(
|
|
270
|
+
bounds,
|
|
271
|
+
viewportWidth,
|
|
272
|
+
viewportHeight,
|
|
273
|
+
DEFAULT_MIN_ZOOM,
|
|
274
|
+
DEFAULT_MAX_ZOOM,
|
|
275
|
+
padding
|
|
276
|
+
).zoom,
|
|
277
|
+
DEFAULT_MIN_ZOOM
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const GraphControlTooltip = ({ content, children }) => (
|
|
282
|
+
<Popup
|
|
283
|
+
content={content}
|
|
284
|
+
position="left center"
|
|
285
|
+
mouseEnterDelay={CONTROL_TOOLTIP_DELAY}
|
|
286
|
+
mouseLeaveDelay={0}
|
|
287
|
+
trigger={<span>{children}</span>}
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
GraphControlTooltip.propTypes = {
|
|
292
|
+
content: PropTypes.string.isRequired,
|
|
293
|
+
children: PropTypes.node.isRequired,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const getTranslateExtent = (nodes = []) => {
|
|
297
|
+
if (!nodes.length) return DEFAULT_TRANSLATE_EXTENT;
|
|
298
|
+
|
|
299
|
+
const bounds = nodes.reduce(
|
|
300
|
+
(acc, node) => {
|
|
301
|
+
const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH;
|
|
302
|
+
const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT;
|
|
303
|
+
const x = node.position?.x ?? node.x ?? 0;
|
|
304
|
+
const y = node.position?.y ?? node.y ?? 0;
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
minX: Math.min(acc.minX, x),
|
|
308
|
+
minY: Math.min(acc.minY, y),
|
|
309
|
+
maxX: Math.max(acc.maxX, x + width),
|
|
310
|
+
maxY: Math.max(acc.maxY, y + height),
|
|
311
|
+
};
|
|
35
312
|
},
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
313
|
+
{
|
|
314
|
+
minX: Number.POSITIVE_INFINITY,
|
|
315
|
+
minY: Number.POSITIVE_INFINITY,
|
|
316
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
317
|
+
maxY: Number.NEGATIVE_INFINITY,
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
return [
|
|
322
|
+
[
|
|
323
|
+
bounds.minX - TRANSLATE_EXTENT_PADDING,
|
|
324
|
+
bounds.minY - TRANSLATE_EXTENT_PADDING,
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
bounds.maxX + TRANSLATE_EXTENT_PADDING,
|
|
328
|
+
bounds.maxY + TRANSLATE_EXTENT_PADDING,
|
|
329
|
+
],
|
|
330
|
+
];
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const LayoutFlow = ({
|
|
334
|
+
initialNodes,
|
|
335
|
+
initialEdges,
|
|
336
|
+
onNodeClick,
|
|
337
|
+
onOpenExpanded,
|
|
338
|
+
containerRef,
|
|
339
|
+
rootNodeId,
|
|
340
|
+
}) => {
|
|
341
|
+
const { fitView, zoomIn, zoomOut, getNodes } = useReactFlow();
|
|
342
|
+
const { formatMessage } = useIntl();
|
|
343
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
344
|
+
const [minZoom, setMinZoom] = useState(DEFAULT_MIN_ZOOM);
|
|
345
|
+
const layoutRunId = useRef(0);
|
|
346
|
+
const fitViewFrameId = useRef();
|
|
347
|
+
const lastFitSignature = useRef("");
|
|
348
|
+
const parsedNodes = useMemo(
|
|
349
|
+
() =>
|
|
350
|
+
initialNodes.map((node) => ({
|
|
52
351
|
...node,
|
|
53
|
-
|
|
54
|
-
|
|
352
|
+
position: { x: 0, y: 0 },
|
|
353
|
+
sourcePosition: Position.Right,
|
|
354
|
+
targetPosition: Position.Left,
|
|
55
355
|
})),
|
|
56
|
-
|
|
57
|
-
|
|
356
|
+
[initialNodes]
|
|
357
|
+
);
|
|
358
|
+
const parsedEdges = useMemo(
|
|
359
|
+
() => {
|
|
360
|
+
const edgesWithStroke = initialEdges.map((edge) => {
|
|
361
|
+
const stroke =
|
|
362
|
+
edge.style?.stroke || DEFAULT_EDGE_STROKE;
|
|
363
|
+
return {
|
|
364
|
+
...edge,
|
|
365
|
+
style: {
|
|
366
|
+
strokeWidth: 1.5,
|
|
367
|
+
...edge.style,
|
|
368
|
+
stroke,
|
|
369
|
+
},
|
|
370
|
+
data: {
|
|
371
|
+
...edge.data,
|
|
372
|
+
edgeStroke: stroke,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const groupsByTarget = new Map();
|
|
378
|
+
|
|
379
|
+
edgesWithStroke.forEach((edge) => {
|
|
380
|
+
const stroke = edge.style?.stroke || DEFAULT_EDGE_STROKE;
|
|
381
|
+
const groupKey = getIncomingEdgeGroupKey(edge, stroke);
|
|
382
|
+
const targetGroups = groupsByTarget.get(edge.target) || new Map();
|
|
383
|
+
const group = targetGroups.get(groupKey) || [];
|
|
384
|
+
|
|
385
|
+
group.push(edge.id);
|
|
386
|
+
targetGroups.set(groupKey, group);
|
|
387
|
+
groupsByTarget.set(edge.target, targetGroups);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const groupMetaByEdgeId = new Map();
|
|
391
|
+
|
|
392
|
+
groupsByTarget.forEach((targetGroups) => {
|
|
393
|
+
const orderedGroups = Array.from(targetGroups.entries())
|
|
394
|
+
.sort(([groupKeyA], [groupKeyB]) => groupKeyA.localeCompare(groupKeyB));
|
|
395
|
+
|
|
396
|
+
orderedGroups.forEach(([groupKey, edgeIds], groupIndex) => {
|
|
397
|
+
edgeIds.forEach((edgeId, edgeIndex) => {
|
|
398
|
+
groupMetaByEdgeId.set(edgeId, {
|
|
399
|
+
targetSlotCount: orderedGroups.length,
|
|
400
|
+
targetSlotIndex: groupIndex,
|
|
401
|
+
showTargetArrow: edgeIndex === edgeIds.length - 1,
|
|
402
|
+
incomingGroupKey: groupKey,
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return edgesWithStroke.map((edge) => ({
|
|
409
|
+
...edge,
|
|
410
|
+
markerEnd: edge.markerEnd || {
|
|
411
|
+
type: MarkerType.ArrowClosed,
|
|
412
|
+
width: 12,
|
|
413
|
+
height: 12,
|
|
414
|
+
color: edge.style?.stroke || DEFAULT_EDGE_STROKE,
|
|
415
|
+
},
|
|
416
|
+
data: {
|
|
417
|
+
...edge.data,
|
|
418
|
+
...groupMetaByEdgeId.get(edge.id),
|
|
419
|
+
},
|
|
420
|
+
}));
|
|
421
|
+
},
|
|
422
|
+
[initialEdges]
|
|
423
|
+
);
|
|
424
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(parsedNodes);
|
|
425
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(parsedEdges);
|
|
426
|
+
const translateExtent = useMemo(() => getTranslateExtent(nodes), [nodes]);
|
|
427
|
+
const layoutInput = useMemo(() => {
|
|
428
|
+
const safeNodes = orderNodesByRelationGroup(
|
|
429
|
+
parsedNodes.filter((node) => node && node.id),
|
|
430
|
+
parsedEdges,
|
|
431
|
+
rootNodeId
|
|
432
|
+
);
|
|
433
|
+
const safeNodeIds = new Set(safeNodes.map((node) => node.id));
|
|
434
|
+
const safeEdges = parsedEdges.filter(
|
|
435
|
+
(edge) =>
|
|
436
|
+
edge &&
|
|
437
|
+
edge.id &&
|
|
438
|
+
edge.source &&
|
|
439
|
+
edge.target &&
|
|
440
|
+
safeNodeIds.has(edge.source) &&
|
|
441
|
+
safeNodeIds.has(edge.target)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return { nodes: safeNodes, edges: safeEdges };
|
|
445
|
+
}, [parsedNodes, parsedEdges, rootNodeId]);
|
|
58
446
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
447
|
+
const getLayoutedElements = useCallback(
|
|
448
|
+
({ nodes: nodesToLayout, edges: edgesToLayout }) => {
|
|
449
|
+
const scheduleFitView = (nodesForFit, edgesForFit) => {
|
|
450
|
+
const signature = `${nodesForFit
|
|
451
|
+
.map((node) => node.id)
|
|
452
|
+
.join("|")}::${edgesForFit.map((edge) => edge.id).join("|")}`;
|
|
453
|
+
const changed = signature !== lastFitSignature.current;
|
|
454
|
+
lastFitSignature.current = signature;
|
|
455
|
+
|
|
456
|
+
if (fitViewFrameId.current) {
|
|
457
|
+
cancelAnimationFrame(fitViewFrameId.current);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fitViewFrameId.current = requestAnimationFrame(() => {
|
|
461
|
+
const measuredNodes = getNodes();
|
|
462
|
+
const nextMinZoom = getMinZoomForDiagram(
|
|
463
|
+
measuredNodes.length ? measuredNodes : nodesForFit,
|
|
464
|
+
containerRef.current?.clientWidth,
|
|
465
|
+
containerRef.current?.clientHeight
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
setMinZoom(nextMinZoom);
|
|
469
|
+
fitView({
|
|
470
|
+
padding: FIT_VIEW_PADDING,
|
|
471
|
+
duration: changed ? 300 : 0,
|
|
472
|
+
minZoom: nextMinZoom,
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
if (!nodesToLayout.length) {
|
|
478
|
+
setNodes([]);
|
|
479
|
+
setEdges([]);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const currentLayoutRunId = layoutRunId.current + 1;
|
|
484
|
+
layoutRunId.current = currentLayoutRunId;
|
|
485
|
+
const layoutOptions = {
|
|
486
|
+
"elk.algorithm": "layered",
|
|
487
|
+
"elk.direction": "RIGHT",
|
|
488
|
+
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
489
|
+
"elk.layered.crossingMinimization.forceNodeModelOrder": true,
|
|
490
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": 60,
|
|
491
|
+
"elk.spacing.nodeNode": 40,
|
|
492
|
+
};
|
|
493
|
+
const graph = {
|
|
494
|
+
id: "root",
|
|
495
|
+
layoutOptions: layoutOptions,
|
|
496
|
+
children: nodesToLayout.map((node) => ({
|
|
497
|
+
...node,
|
|
498
|
+
width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH,
|
|
499
|
+
height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT,
|
|
500
|
+
})),
|
|
501
|
+
edges: edgesToLayout,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
elk.layout(graph).then(({ children = [] }) => {
|
|
505
|
+
if (currentLayoutRunId !== layoutRunId.current) return;
|
|
506
|
+
children.forEach((node) => {
|
|
507
|
+
node.position = { x: node.x, y: node.y };
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const alignedChildren = alignNodesToConnectionPoints(
|
|
511
|
+
children,
|
|
512
|
+
edgesToLayout,
|
|
513
|
+
rootNodeId
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
setNodes(alignedChildren);
|
|
517
|
+
scheduleFitView(alignedChildren, edgesToLayout);
|
|
518
|
+
}).catch(() => {
|
|
519
|
+
if (currentLayoutRunId !== layoutRunId.current) return;
|
|
520
|
+
|
|
521
|
+
setNodes(nodesToLayout);
|
|
522
|
+
setEdges(edgesToLayout);
|
|
523
|
+
scheduleFitView(nodesToLayout, edgesToLayout);
|
|
62
524
|
});
|
|
525
|
+
},
|
|
526
|
+
[containerRef, fitView, getNodes, rootNodeId, setEdges, setNodes]
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const onInit = useCallback(() => {
|
|
530
|
+
setIsInitialized(true);
|
|
531
|
+
setNodes(layoutInput.nodes);
|
|
532
|
+
setEdges(layoutInput.edges);
|
|
533
|
+
getLayoutedElements(layoutInput);
|
|
534
|
+
}, [getLayoutedElements, layoutInput, setEdges, setNodes]);
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
if (!isInitialized) return;
|
|
63
538
|
|
|
64
|
-
|
|
65
|
-
|
|
539
|
+
setNodes(layoutInput.nodes);
|
|
540
|
+
setEdges(layoutInput.edges);
|
|
541
|
+
const animationFrameId = requestAnimationFrame(() => {
|
|
542
|
+
getLayoutedElements(layoutInput);
|
|
66
543
|
});
|
|
544
|
+
|
|
545
|
+
return () => cancelAnimationFrame(animationFrameId);
|
|
546
|
+
}, [
|
|
547
|
+
isInitialized,
|
|
548
|
+
layoutInput,
|
|
549
|
+
setNodes,
|
|
550
|
+
setEdges,
|
|
551
|
+
getLayoutedElements,
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
useEffect(() => () => {
|
|
555
|
+
if (fitViewFrameId.current) {
|
|
556
|
+
cancelAnimationFrame(fitViewFrameId.current);
|
|
557
|
+
}
|
|
558
|
+
layoutRunId.current += 1;
|
|
67
559
|
}, []);
|
|
68
560
|
|
|
69
561
|
return (
|
|
@@ -72,30 +564,174 @@ const LayoutFlow = ({ initialNodes, initialEdges, onNodeClick }) => {
|
|
|
72
564
|
edges={edges}
|
|
73
565
|
onNodesChange={onNodesChange}
|
|
74
566
|
onEdgesChange={onEdgesChange}
|
|
75
|
-
onInit={
|
|
76
|
-
fitView
|
|
567
|
+
onInit={onInit}
|
|
77
568
|
onNodeClick={onNodeClick}
|
|
78
569
|
nodesConnectable={false}
|
|
79
|
-
|
|
570
|
+
translateExtent={translateExtent}
|
|
571
|
+
nodeTypes={nodeTypes}
|
|
572
|
+
edgeTypes={edgeTypes}
|
|
573
|
+
attributionPosition="bottom-left"
|
|
574
|
+
minZoom={minZoom}
|
|
575
|
+
>
|
|
576
|
+
<Background
|
|
577
|
+
variant={BackgroundVariant.Dots}
|
|
578
|
+
gap={20}
|
|
579
|
+
size={1}
|
|
580
|
+
color="var(--td-graph-node-border, #d0d5e0)"
|
|
581
|
+
/>
|
|
582
|
+
<Controls
|
|
583
|
+
position="top-right"
|
|
584
|
+
orientation="horizontal"
|
|
585
|
+
showZoom={false}
|
|
586
|
+
showFitView={false}
|
|
587
|
+
showInteractive={false}
|
|
588
|
+
>
|
|
589
|
+
<GraphControlTooltip content={formatMessage({ id: "graph.controls.zoom_in" })}>
|
|
590
|
+
<ControlButton onClick={() => zoomIn({ duration: 200 })} aria-label={formatMessage({ id: "graph.controls.zoom_in" })}>
|
|
591
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
592
|
+
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
593
|
+
</svg>
|
|
594
|
+
</ControlButton>
|
|
595
|
+
</GraphControlTooltip>
|
|
596
|
+
<GraphControlTooltip content={formatMessage({ id: "graph.controls.zoom_out" })}>
|
|
597
|
+
<ControlButton onClick={() => zoomOut({ duration: 200 })} aria-label={formatMessage({ id: "graph.controls.zoom_out" })}>
|
|
598
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
599
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
600
|
+
</svg>
|
|
601
|
+
</ControlButton>
|
|
602
|
+
</GraphControlTooltip>
|
|
603
|
+
<GraphControlTooltip content={formatMessage({ id: "graph.controls.fit_view" })}>
|
|
604
|
+
<ControlButton
|
|
605
|
+
onClick={() =>
|
|
606
|
+
fitView({ padding: FIT_VIEW_PADDING, duration: 300, minZoom })
|
|
607
|
+
}
|
|
608
|
+
aria-label={formatMessage({ id: "graph.controls.fit_view" })}
|
|
609
|
+
>
|
|
610
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
611
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
|
612
|
+
<path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
613
|
+
</svg>
|
|
614
|
+
</ControlButton>
|
|
615
|
+
</GraphControlTooltip>
|
|
616
|
+
{onOpenExpanded ? (
|
|
617
|
+
<GraphControlTooltip content={formatMessage({ id: "graph.controls.open_expanded" })}>
|
|
618
|
+
<ControlButton
|
|
619
|
+
onClick={onOpenExpanded}
|
|
620
|
+
aria-label={formatMessage({ id: "graph.controls.open_expanded" })}
|
|
621
|
+
>
|
|
622
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
623
|
+
<path d="M15 3h6v6" />
|
|
624
|
+
<path d="M9 21H3v-6" />
|
|
625
|
+
<path d="M21 3l-7 7" />
|
|
626
|
+
<path d="M3 21l7-7" />
|
|
627
|
+
</svg>
|
|
628
|
+
</ControlButton>
|
|
629
|
+
</GraphControlTooltip>
|
|
630
|
+
) : null}
|
|
631
|
+
</Controls>
|
|
632
|
+
</ReactFlow>
|
|
80
633
|
);
|
|
81
634
|
};
|
|
82
635
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
636
|
+
LayoutFlow.propTypes = {
|
|
637
|
+
initialNodes: PropTypes.array,
|
|
638
|
+
initialEdges: PropTypes.array,
|
|
639
|
+
onNodeClick: PropTypes.func,
|
|
640
|
+
onOpenExpanded: PropTypes.func,
|
|
641
|
+
containerRef: PropTypes.object,
|
|
642
|
+
rootNodeId: PropTypes.string,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const GraphCanvas = ({
|
|
646
|
+
nodes,
|
|
647
|
+
edges,
|
|
648
|
+
onNodeClick,
|
|
649
|
+
height,
|
|
650
|
+
onOpenExpanded,
|
|
651
|
+
rootNodeId,
|
|
652
|
+
}) => {
|
|
653
|
+
const containerRef = useRef(null);
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<div className="td-graph-container" style={{ "--td-graph-container-height": height }} ref={containerRef}>
|
|
657
|
+
<ReactFlowProvider>
|
|
658
|
+
<LayoutFlow
|
|
659
|
+
initialNodes={nodes}
|
|
660
|
+
initialEdges={edges}
|
|
661
|
+
onNodeClick={onNodeClick}
|
|
662
|
+
onOpenExpanded={onOpenExpanded}
|
|
663
|
+
containerRef={containerRef}
|
|
664
|
+
rootNodeId={rootNodeId}
|
|
665
|
+
/>
|
|
666
|
+
</ReactFlowProvider>
|
|
667
|
+
</div>
|
|
668
|
+
);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
GraphCanvas.propTypes = {
|
|
672
|
+
nodes: PropTypes.array,
|
|
673
|
+
edges: PropTypes.array,
|
|
674
|
+
onNodeClick: PropTypes.func,
|
|
675
|
+
height: PropTypes.string,
|
|
676
|
+
onOpenExpanded: PropTypes.func,
|
|
677
|
+
rootNodeId: PropTypes.string,
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
export const Graph = ({
|
|
681
|
+
nodes,
|
|
682
|
+
edges,
|
|
683
|
+
onNodeClick,
|
|
684
|
+
allowExpandedView = true,
|
|
685
|
+
rootNodeId,
|
|
686
|
+
}) => {
|
|
687
|
+
const { formatMessage } = useIntl();
|
|
688
|
+
const [expandedOpen, setExpandedOpen] = useState(false);
|
|
689
|
+
|
|
690
|
+
const openExpanded = useCallback(() => setExpandedOpen(true), []);
|
|
691
|
+
const closeExpanded = useCallback(() => setExpandedOpen(false), []);
|
|
692
|
+
|
|
693
|
+
return (
|
|
694
|
+
<>
|
|
695
|
+
<GraphCanvas
|
|
696
|
+
nodes={nodes}
|
|
697
|
+
edges={edges}
|
|
89
698
|
onNodeClick={onNodeClick}
|
|
699
|
+
height={DEFAULT_GRAPH_HEIGHT}
|
|
700
|
+
onOpenExpanded={allowExpandedView ? openExpanded : undefined}
|
|
701
|
+
rootNodeId={rootNodeId}
|
|
90
702
|
/>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
703
|
+
{allowExpandedView ? (
|
|
704
|
+
<Modal
|
|
705
|
+
open={expandedOpen}
|
|
706
|
+
onClose={closeExpanded}
|
|
707
|
+
closeIcon
|
|
708
|
+
size="fullscreen"
|
|
709
|
+
closeOnDimmerClick
|
|
710
|
+
closeOnEscape
|
|
711
|
+
className="td-graph-modal"
|
|
712
|
+
>
|
|
713
|
+
<Modal.Header>{formatMessage({ id: "graph.expanded.header" })}</Modal.Header>
|
|
714
|
+
<Modal.Content>
|
|
715
|
+
<GraphCanvas
|
|
716
|
+
nodes={nodes}
|
|
717
|
+
edges={edges}
|
|
718
|
+
onNodeClick={onNodeClick}
|
|
719
|
+
height={EXPANDED_GRAPH_HEIGHT}
|
|
720
|
+
rootNodeId={rootNodeId}
|
|
721
|
+
/>
|
|
722
|
+
</Modal.Content>
|
|
723
|
+
</Modal>
|
|
724
|
+
) : null}
|
|
725
|
+
</>
|
|
726
|
+
);
|
|
727
|
+
};
|
|
94
728
|
|
|
95
729
|
Graph.propTypes = {
|
|
96
730
|
nodes: PropTypes.array,
|
|
97
731
|
edges: PropTypes.array,
|
|
98
|
-
onNodeClick: PropTypes.
|
|
732
|
+
onNodeClick: PropTypes.func,
|
|
733
|
+
allowExpandedView: PropTypes.bool,
|
|
734
|
+
rootNodeId: PropTypes.string,
|
|
99
735
|
};
|
|
100
736
|
|
|
101
737
|
export default Graph;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { render } from "@truedat/test/render";
|
|
3
|
+
import { Graph, getMinZoomForDiagram } from "../Graph";
|
|
4
|
+
|
|
5
|
+
const mockGetNodesBounds = jest.fn();
|
|
6
|
+
const mockGetViewportForBounds = jest.fn();
|
|
7
|
+
|
|
8
|
+
jest.mock("elkjs/lib/elk.bundled.js", () =>
|
|
9
|
+
jest.fn().mockImplementation(() => ({
|
|
10
|
+
layout: jest.fn(() => Promise.resolve({ children: [] })),
|
|
11
|
+
}))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
jest.mock("@xyflow/react", () => ({
|
|
15
|
+
ReactFlow: ({ children }) => <div data-testid="react-flow">{children}</div>,
|
|
16
|
+
ReactFlowProvider: ({ children }) => <>{children}</>,
|
|
17
|
+
useNodesState: (initialNodes) => [initialNodes, jest.fn(), jest.fn()],
|
|
18
|
+
useEdgesState: (initialEdges) => [initialEdges, jest.fn(), jest.fn()],
|
|
19
|
+
useReactFlow: () => ({
|
|
20
|
+
fitView: jest.fn(),
|
|
21
|
+
zoomIn: jest.fn(),
|
|
22
|
+
zoomOut: jest.fn(),
|
|
23
|
+
getNodes: jest.fn().mockReturnValue([]),
|
|
24
|
+
}),
|
|
25
|
+
Position: {
|
|
26
|
+
Right: "right",
|
|
27
|
+
Left: "left",
|
|
28
|
+
},
|
|
29
|
+
getNodesBounds: (...args) => mockGetNodesBounds(...args),
|
|
30
|
+
getViewportForBounds: (...args) => mockGetViewportForBounds(...args),
|
|
31
|
+
MarkerType: {
|
|
32
|
+
ArrowClosed: "arrowclosed",
|
|
33
|
+
},
|
|
34
|
+
Controls: ({ children }) => <div>{children}</div>,
|
|
35
|
+
ControlButton: ({ children, ...props }) => (
|
|
36
|
+
<button type="button" {...props}>
|
|
37
|
+
{children}
|
|
38
|
+
</button>
|
|
39
|
+
),
|
|
40
|
+
Background: () => null,
|
|
41
|
+
BackgroundVariant: {
|
|
42
|
+
Dots: "dots",
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
describe("<Graph />", () => {
|
|
47
|
+
const props = {
|
|
48
|
+
nodes: [{ id: "business_concept:1", data: { label: "Root" }, position: { x: 0, y: 0 } }],
|
|
49
|
+
edges: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
mockGetNodesBounds.mockReturnValue({ x: 0, y: 0, width: 100, height: 50 });
|
|
54
|
+
mockGetViewportForBounds.mockReturnValue({ x: 0, y: 0, zoom: 0.75 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
mockGetNodesBounds.mockReset();
|
|
59
|
+
mockGetViewportForBounds.mockReset();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("uses the fit view zoom as the minimum zoom level", () => {
|
|
63
|
+
expect(getMinZoomForDiagram(props.nodes, 600, 300)).toBe(0.75);
|
|
64
|
+
expect(mockGetViewportForBounds).toHaveBeenCalledWith(
|
|
65
|
+
{ x: 0, y: 0, width: 100, height: 50 },
|
|
66
|
+
600,
|
|
67
|
+
300,
|
|
68
|
+
0.1,
|
|
69
|
+
2,
|
|
70
|
+
0.08
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("opens an expanded modal from the controls bar", async () => {
|
|
75
|
+
render(<Graph {...props} />);
|
|
76
|
+
|
|
77
|
+
expect(screen.queryByText("graph.expanded.header")).not.toBeInTheDocument();
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getByLabelText("graph.controls.open_expanded"));
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(screen.getByText("graph.expanded.header")).toBeInTheDocument();
|
|
83
|
+
expect(screen.getAllByTestId("react-flow")).toHaveLength(2);
|
|
84
|
+
expect(screen.getAllByLabelText("graph.controls.open_expanded")).toHaveLength(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { memo, useState } from "react";
|
|
2
|
+
import { BaseEdge, EdgeLabelRenderer } from "@xyflow/react";
|
|
3
|
+
import { getTargetSlotOffset } from "./edgeLayout";
|
|
4
|
+
|
|
5
|
+
const MIN_CURVE_OFFSET = 14;
|
|
6
|
+
const MAX_CURVE_OFFSET = 38;
|
|
7
|
+
const EDGE_ARROW_LENGTH = 10;
|
|
8
|
+
const EDGE_ARROW_WIDTH = 8;
|
|
9
|
+
const EDGE_START_DOT_RADIUS = 3.5;
|
|
10
|
+
|
|
11
|
+
const getCurveOffset = ({ sourceX, sourceY, targetX, targetY }) => {
|
|
12
|
+
const horizontalDistance = Math.abs(targetX - sourceX);
|
|
13
|
+
const verticalDistance = Math.abs(targetY - sourceY);
|
|
14
|
+
|
|
15
|
+
return Math.max(
|
|
16
|
+
MIN_CURVE_OFFSET,
|
|
17
|
+
Math.min(MAX_CURVE_OFFSET, horizontalDistance * 0.28 + verticalDistance * 0.04)
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getControlPoint = (x, y, position, offset) => {
|
|
22
|
+
switch (position) {
|
|
23
|
+
case "left":
|
|
24
|
+
return { x: x - offset, y };
|
|
25
|
+
case "right":
|
|
26
|
+
return { x: x + offset, y };
|
|
27
|
+
case "top":
|
|
28
|
+
return { x, y: y - offset };
|
|
29
|
+
case "bottom":
|
|
30
|
+
return { x, y: y + offset };
|
|
31
|
+
default:
|
|
32
|
+
return { x, y };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getBezierPoint = (t, p0, p1, p2, p3) => {
|
|
37
|
+
const mt = 1 - t;
|
|
38
|
+
const mt2 = mt * mt;
|
|
39
|
+
const t2 = t * t;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
mt2 * mt * p0 +
|
|
43
|
+
3 * mt2 * t * p1 +
|
|
44
|
+
3 * mt * t2 * p2 +
|
|
45
|
+
t2 * t * p3
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getBezierDerivative = (t, p0, p1, p2, p3) => {
|
|
50
|
+
const mt = 1 - t;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
3 * mt * mt * (p1 - p0) +
|
|
54
|
+
6 * mt * t * (p2 - p1) +
|
|
55
|
+
3 * t * t * (p3 - p2)
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const normalizeVector = (x, y) => {
|
|
60
|
+
const length = Math.hypot(x, y) || 1;
|
|
61
|
+
|
|
62
|
+
return { x: x / length, y: y / length };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const ColoredEdge = ({
|
|
66
|
+
id,
|
|
67
|
+
sourceX,
|
|
68
|
+
sourceY,
|
|
69
|
+
targetX,
|
|
70
|
+
targetY,
|
|
71
|
+
sourcePosition,
|
|
72
|
+
targetPosition,
|
|
73
|
+
style = {},
|
|
74
|
+
data = {},
|
|
75
|
+
}) => {
|
|
76
|
+
const [hovered, setHovered] = useState(false);
|
|
77
|
+
const targetSlotOffset = getTargetSlotOffset(
|
|
78
|
+
data.targetSlotIndex,
|
|
79
|
+
data.targetSlotCount
|
|
80
|
+
);
|
|
81
|
+
const adjustedTargetY = targetY + targetSlotOffset;
|
|
82
|
+
const curveOffset = getCurveOffset({
|
|
83
|
+
sourceX,
|
|
84
|
+
sourceY,
|
|
85
|
+
targetX,
|
|
86
|
+
targetY: adjustedTargetY,
|
|
87
|
+
});
|
|
88
|
+
const stroke = style.stroke || "var(--td-graph-edge-stroke, #b0b8c8)";
|
|
89
|
+
const sourceControl = getControlPoint(
|
|
90
|
+
sourceX,
|
|
91
|
+
sourceY,
|
|
92
|
+
sourcePosition,
|
|
93
|
+
curveOffset
|
|
94
|
+
);
|
|
95
|
+
const targetControl = getControlPoint(
|
|
96
|
+
targetX,
|
|
97
|
+
adjustedTargetY,
|
|
98
|
+
targetPosition,
|
|
99
|
+
curveOffset
|
|
100
|
+
);
|
|
101
|
+
const targetTangent = normalizeVector(
|
|
102
|
+
getBezierDerivative(1, sourceX, sourceControl.x, targetControl.x, targetX),
|
|
103
|
+
getBezierDerivative(
|
|
104
|
+
1,
|
|
105
|
+
sourceY,
|
|
106
|
+
sourceControl.y,
|
|
107
|
+
targetControl.y,
|
|
108
|
+
adjustedTargetY
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
const lineTargetX = targetX - targetTangent.x * EDGE_ARROW_LENGTH;
|
|
112
|
+
const lineTargetY = adjustedTargetY - targetTangent.y * EDGE_ARROW_LENGTH;
|
|
113
|
+
const edgePath = `M ${sourceX},${sourceY} C ${sourceControl.x},${sourceControl.y} ${targetControl.x},${targetControl.y} ${lineTargetX},${lineTargetY}`;
|
|
114
|
+
const labelX = getBezierPoint(0.5, sourceX, sourceControl.x, targetControl.x, lineTargetX);
|
|
115
|
+
const labelY = getBezierPoint(0.5, sourceY, sourceControl.y, targetControl.y, lineTargetY);
|
|
116
|
+
const arrowNormalX = -targetTangent.y;
|
|
117
|
+
const arrowNormalY = targetTangent.x;
|
|
118
|
+
const arrowLeftX = lineTargetX + arrowNormalX * (EDGE_ARROW_WIDTH / 2);
|
|
119
|
+
const arrowLeftY = lineTargetY + arrowNormalY * (EDGE_ARROW_WIDTH / 2);
|
|
120
|
+
const arrowRightX = lineTargetX - arrowNormalX * (EDGE_ARROW_WIDTH / 2);
|
|
121
|
+
const arrowRightY = lineTargetY - arrowNormalY * (EDGE_ARROW_WIDTH / 2);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<BaseEdge
|
|
126
|
+
id={id}
|
|
127
|
+
path={edgePath}
|
|
128
|
+
style={style}
|
|
129
|
+
/>
|
|
130
|
+
<circle
|
|
131
|
+
cx={sourceX}
|
|
132
|
+
cy={sourceY}
|
|
133
|
+
r={EDGE_START_DOT_RADIUS}
|
|
134
|
+
fill={stroke}
|
|
135
|
+
pointerEvents="none"
|
|
136
|
+
/>
|
|
137
|
+
{data.showTargetArrow !== false ? (
|
|
138
|
+
<polygon
|
|
139
|
+
points={`${targetX},${adjustedTargetY} ${arrowLeftX},${arrowLeftY} ${arrowRightX},${arrowRightY}`}
|
|
140
|
+
fill={stroke}
|
|
141
|
+
pointerEvents="none"
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
<path
|
|
145
|
+
d={edgePath}
|
|
146
|
+
fill="none"
|
|
147
|
+
stroke="transparent"
|
|
148
|
+
strokeWidth={16}
|
|
149
|
+
onMouseEnter={() => setHovered(true)}
|
|
150
|
+
onMouseLeave={() => setHovered(false)}
|
|
151
|
+
/>
|
|
152
|
+
{hovered && data.label && (
|
|
153
|
+
<EdgeLabelRenderer>
|
|
154
|
+
<div
|
|
155
|
+
className="td-graph-edge-label nodrag nopan"
|
|
156
|
+
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }}
|
|
157
|
+
>
|
|
158
|
+
{data.label}
|
|
159
|
+
</div>
|
|
160
|
+
</EdgeLabelRenderer>
|
|
161
|
+
)}
|
|
162
|
+
</>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export default memo(ColoredEdge);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { Handle, Position } from "@xyflow/react";
|
|
3
|
+
import "./ConceptNode.less";
|
|
4
|
+
|
|
5
|
+
const ConceptNode = ({ data }) => {
|
|
6
|
+
const { label, isActive } = data;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className={`td-concept-node${isActive ? " td-concept-node--active" : ""}`}
|
|
11
|
+
>
|
|
12
|
+
<Handle type="target" position={Position.Left} />
|
|
13
|
+
<span className="td-concept-node__label">{label}</span>
|
|
14
|
+
<Handle type="source" position={Position.Right} />
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default memo(ConceptNode);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
.td-concept-node {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
box-sizing: border-box;
|
|
7
|
+
width: 130px;
|
|
8
|
+
min-height: 44px;
|
|
9
|
+
padding: 8px 10px;
|
|
10
|
+
border: 1.5px solid var(--td-graph-node-border, #d0d5e0);
|
|
11
|
+
border-radius: var(--td-graph-border-radius, 8px);
|
|
12
|
+
background-color: var(--td-graph-node-bg, #ffffff);
|
|
13
|
+
box-shadow: var(--td-graph-node-shadow, 0 2px 6px rgba(21, 26, 36, 0.08));
|
|
14
|
+
color: var(--td-graph-node-text, #151a24);
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
text-align: center;
|
|
18
|
+
line-height: 1.4;
|
|
19
|
+
white-space: normal;
|
|
20
|
+
word-break: normal;
|
|
21
|
+
overflow-wrap: break-word;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
transition:
|
|
24
|
+
border-color 150ms ease,
|
|
25
|
+
background-color 150ms ease,
|
|
26
|
+
box-shadow 150ms ease,
|
|
27
|
+
color 150ms ease;
|
|
28
|
+
|
|
29
|
+
&:hover {
|
|
30
|
+
border-color: var(--td-graph-node-hover-border, #2f3443);
|
|
31
|
+
box-shadow: var(--td-graph-node-hover-shadow, 0 6px 18px rgba(21, 26, 36, 0.14));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&--active {
|
|
35
|
+
background-color: var(--td-graph-node-active-bg, #ed5c17);
|
|
36
|
+
color: var(--td-graph-node-active-text, #ffffff);
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
border-color: var(--td-graph-node-active-bg, #ed5c17);
|
|
39
|
+
box-shadow: var(--td-graph-node-active-shadow, 0 4px 14px rgba(237, 92, 23, 0.35));
|
|
40
|
+
cursor: default;
|
|
41
|
+
|
|
42
|
+
.react-flow__handle {
|
|
43
|
+
background-color: rgba(255, 255, 255, 0.6);
|
|
44
|
+
border-color: rgba(255, 255, 255, 0.4);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.react-flow__handle {
|
|
49
|
+
width: 8px;
|
|
50
|
+
height: 8px;
|
|
51
|
+
border: 2px solid transparent;
|
|
52
|
+
border-radius: 50%;
|
|
53
|
+
background-color: transparent;
|
|
54
|
+
opacity: 0;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
transition: background-color 150ms ease;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.td-concept-node__label {
|
|
61
|
+
display: block;
|
|
62
|
+
width: 100%;
|
|
63
|
+
text-align: center;
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const TARGET_SLOT_STEP = 10;
|
|
2
|
+
const MAX_TARGET_SLOT_SPAN = 22;
|
|
3
|
+
|
|
4
|
+
export const getTargetSlotOffset = (slotIndex = 0, slotCount = 1) => {
|
|
5
|
+
if (!slotCount || slotCount <= 1) return 0;
|
|
6
|
+
|
|
7
|
+
const totalSpan = Math.min((slotCount - 1) * TARGET_SLOT_STEP, MAX_TARGET_SLOT_SPAN);
|
|
8
|
+
const step = totalSpan / (slotCount - 1);
|
|
9
|
+
|
|
10
|
+
return -totalSpan / 2 + slotIndex * step;
|
|
11
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.td-graph-container {
|
|
2
|
+
height: var(--td-graph-container-height, 640px);
|
|
3
|
+
|
|
4
|
+
.react-flow {
|
|
5
|
+
background: var(--td-graph-bg, #f7f8fa);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.react-flow__controls {
|
|
9
|
+
margin: 8px;
|
|
10
|
+
border-radius: 12px;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ui.modal.td-graph-modal > .content {
|
|
16
|
+
padding: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.td-graph-edge-label {
|
|
20
|
+
position: absolute;
|
|
21
|
+
background: rgba(0, 0, 0, 0.75);
|
|
22
|
+
color: #fff;
|
|
23
|
+
padding: 2px 6px;
|
|
24
|
+
border-radius: 4px;
|
|
25
|
+
font-size: 11px;
|
|
26
|
+
font-weight: 500;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
z-index: 1000;
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--td-graph-theme-primary: #ed5c17;
|
|
3
|
+
|
|
4
|
+
/* Graph palette – Relations Graph (React Flow) */
|
|
5
|
+
--td-graph-bg: #f7f8fa;
|
|
6
|
+
--td-graph-node-bg: #ffffff;
|
|
7
|
+
--td-graph-node-border: #d0d5e0;
|
|
8
|
+
--td-graph-node-text: #151a24;
|
|
9
|
+
--td-graph-node-hover-border: #2f3443;
|
|
10
|
+
--td-graph-node-shadow: 0 2px 6px rgba(21, 26, 36, 0.08);
|
|
11
|
+
--td-graph-node-hover-shadow: 0 6px 18px rgba(21, 26, 36, 0.14);
|
|
12
|
+
--td-graph-node-active-bg: var(--td-graph-theme-primary);
|
|
13
|
+
--td-graph-node-active-text: #ffffff;
|
|
14
|
+
--td-graph-node-active-shadow: 0 4px 14px rgba(237, 92, 23, 0.35);
|
|
15
|
+
--td-graph-edge-stroke: #a2a9b8;
|
|
16
|
+
--td-graph-edge-label-text: #3a4150;
|
|
17
|
+
--td-graph-edge-label-bg: #f7f8fa;
|
|
18
|
+
--td-graph-handle-bg: #b0b8c8;
|
|
19
|
+
--td-graph-handle-border: #ffffff;
|
|
20
|
+
--td-graph-accent: var(--td-graph-theme-primary);
|
|
21
|
+
--td-graph-depth-disabled: #bdbdbd;
|
|
22
|
+
--td-graph-depth-inactive: #c9c9c9;
|
|
23
|
+
--td-graph-border: #d4d4d5;
|
|
24
|
+
--td-graph-border-radius: 10px;
|
|
25
|
+
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--td-lineage-accent: #ed5c17;
|
|
3
|
+
--td-lineage-accent-light: #f49360;
|
|
4
|
+
--td-lineage-group-fill: #ccc;
|
|
5
|
+
--td-lineage-group-border: #888;
|
|
6
|
+
--td-lineage-group-fill-opacity: 0.2;
|
|
7
|
+
--td-lineage-resource-fill: #ffffff;
|
|
8
|
+
--td-lineage-resource-border: #333;
|
|
9
|
+
--td-lineage-process-fill: #90cde7;
|
|
10
|
+
--td-lineage-link-inactive: #ccc;
|
|
11
|
+
--td-lineage-metadata-inactive: rgb(238, 210, 210);
|
|
12
|
+
}
|