@truedat/core 8.4.7 → 8.4.8

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 CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@truedat/core",
3
- "version": "8.4.7",
3
+ "version": "8.4.8",
4
4
  "description": "Truedat Web Core",
5
- "sideEffects": false,
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.7",
57
+ "@truedat/test": "8.4.8",
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": "71fcf487f24eb0b3e5a6962547e0e946c59033ce"
103
+ "gitHead": "7829e377f78c3cfc66e449f5dc31a8b03c0f5f00"
101
104
  }
@@ -1,69 +1,561 @@
1
- import _ from "lodash/fp";
2
- import { useCallback, useEffect } from "react";
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 LayoutFlow = ({ initialNodes, initialEdges, onNodeClick }) => {
19
- const { getNodes, setNodes, getEdges, fitView } = useReactFlow();
20
- const parsedNodes = initialNodes.map((node) => ({
21
- ...node,
22
- position: { x: 0, y: 0 },
23
- sourcePosition: Position.Right,
24
- targetPosition: Position.Left,
25
- }));
26
- const parsedEdges = initialEdges.map((edge) => ({
27
- ...edge,
28
- markerEnd: {
29
- type: MarkerType.ArrowClosed,
30
- width: 15,
31
- height: 15,
32
- },
33
- style: {
34
- strokeWidth: 2,
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
- const [nodes, , onNodesChange] = useNodesState(parsedNodes);
38
- const [edges, , onEdgesChange] = useEdgesState(parsedEdges);
39
-
40
- const getLayoutedElements = useCallback(() => {
41
- const layoutOptions = {
42
- "elk.algorithm": "layered",
43
- "elk.direction": "RIGHT",
44
- "elk.algorithm": "layered",
45
- "elk.layered.spacing.nodeNodeBetweenLayers": 100,
46
- "elk.spacing.nodeNode": 80,
47
- };
48
- const graph = {
49
- id: "root",
50
- layoutOptions: layoutOptions,
51
- children: getNodes().map((node) => ({
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
- width: node.measured?.width,
54
- height: node.measured?.height,
352
+ position: { x: 0, y: 0 },
353
+ sourcePosition: Position.Right,
354
+ targetPosition: Position.Left,
55
355
  })),
56
- edges: getEdges(),
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
- elk.layout(graph).then(({ children }) => {
60
- children.forEach((node) => {
61
- node.position = { x: node.x, y: node.y };
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
- setNodes(children);
65
- fitView();
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={getLayoutedElements}
76
- fitView
567
+ onInit={onInit}
77
568
  onNodeClick={onNodeClick}
78
569
  nodesConnectable={false}
79
- ></ReactFlow>
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
- export const Graph = ({ nodes, edges, onNodeClick }) => (
84
- <div style={{ height: "640px" }}>
85
- <ReactFlowProvider>
86
- <LayoutFlow
87
- initialNodes={nodes}
88
- initialEdges={edges}
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
- </ReactFlowProvider>
92
- </div>
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.function,
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
+ }