@truedat/core 8.11.6 → 8.11.7

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.
@@ -1,14 +1,14 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useIntl } from "react-intl";
3
3
  import PropTypes from "prop-types";
4
- import { Modal, Popup } from "semantic-ui-react";
4
+ import { Icon, Modal, Popup } from "semantic-ui-react";
5
5
  import {
6
6
  ReactFlow,
7
7
  ReactFlowProvider,
8
8
  useNodesState,
9
9
  useEdgesState,
10
10
  useReactFlow,
11
- getNodesBounds,
11
+ getNodesBounds as getStaticNodesBounds,
12
12
  getViewportForBounds,
13
13
  Position,
14
14
  MarkerType,
@@ -16,29 +16,118 @@ import {
16
16
  ControlButton,
17
17
  Background,
18
18
  BackgroundVariant,
19
+ MiniMap,
19
20
  } from "@xyflow/react";
21
+ import {
22
+ Expand,
23
+ Minimize2,
24
+ ZoomIn,
25
+ ZoomOut,
26
+ Maximize,
27
+ Focus,
28
+ } from "lucide-react";
20
29
  import ELK from "elkjs/lib/elk.bundled.js";
21
30
  import ConceptNode from "./graph/ConceptNode";
22
31
  import ColoredEdge from "./graph/ColoredEdge";
32
+ import LineageGroupNode from "./graph/LineageGroupNode";
33
+ import { getTargetSlotOffset } from "./graph/edgeLayout";
23
34
 
24
- import "@xyflow/react/dist/style.css";
25
-
26
- const nodeTypes = { concept: ConceptNode };
35
+ const baseNodeTypes = { concept: ConceptNode, group: LineageGroupNode };
27
36
  const edgeTypes = { colored: ColoredEdge };
28
37
 
29
38
  const elk = new ELK();
30
39
  const DEFAULT_NODE_WIDTH = 130;
31
40
  const DEFAULT_NODE_HEIGHT = 44;
32
- const DEFAULT_GRAPH_HEIGHT = "70vh";
41
+ const TRANSLATE_EXTENT_MIN_PADDING = 8000;
42
+ const TRANSLATE_EXTENT_SIZE_MULTIPLIER = 4;
43
+ const DEFAULT_GRAPH_HEIGHT = "640px";
33
44
  const EXPANDED_GRAPH_HEIGHT = "82vh";
34
- const DEFAULT_MIN_ZOOM = 0.01;
35
- const DEFAULT_MAX_ZOOM = 0.8;
36
- const FIT_VIEW_PADDING = 0.2;
45
+ const DEFAULT_MIN_ZOOM = 0.03;
46
+ const DEFAULT_MAX_ZOOM = 2;
47
+ const FIT_VIEW_PADDING = 0.15;
48
+ const FOCUS_VIEW_PADDING = 0.28;
49
+ const FOCUS_MAX_ZOOM = 1.1;
50
+ const DEFAULT_TRANSLATE_EXTENT = [
51
+ [-800, -600],
52
+ [800, 600],
53
+ ];
37
54
  const CONTROL_TOOLTIP_DELAY = 120;
38
55
  const DEFAULT_EDGE_STROKE = "var(--td-graph-edge-stroke, #b0b8c8)";
56
+ const MIN_NODE_GAP = 12;
57
+ const SPACE_KEY = " ";
58
+ const SPACE_CODE = "Space";
59
+ const SPACEBAR_KEY = "Spacebar";
60
+ const WHEEL_DELTA_LINE_HEIGHT = 16;
61
+ const MAX_WHEEL_DELTA_PER_FRAME = 80;
62
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
63
+
64
+ const isSpaceKeyEvent = (event) =>
65
+ event?.key === SPACE_KEY ||
66
+ event?.key === SPACE_CODE ||
67
+ event?.code === SPACE_CODE ||
68
+ event?.key === SPACEBAR_KEY;
69
+
70
+ const isEditableTarget = (target) => {
71
+ if (!target || typeof target.closest !== "function") return false;
72
+
73
+ return Boolean(
74
+ target.closest(
75
+ 'input, textarea, select, button, [contenteditable="true"], [role="textbox"]',
76
+ ),
77
+ );
78
+ };
79
+
80
+ export const normalizeWheelDelta = (
81
+ deltaY,
82
+ deltaMode = 0,
83
+ pageHeight = 800,
84
+ ) => {
85
+ if (!Number.isFinite(deltaY)) return 0;
86
+
87
+ if (deltaMode === 1) return deltaY * WHEEL_DELTA_LINE_HEIGHT;
88
+ if (deltaMode === 2) return deltaY * pageHeight;
89
+
90
+ return deltaY;
91
+ };
92
+
93
+ export const viewportForWheelZoom = ({
94
+ viewport,
95
+ pointer,
96
+ delta,
97
+ sensitivity,
98
+ minZoom,
99
+ maxZoom,
100
+ }) => {
101
+ const currentZoom = viewport?.zoom || 1;
102
+ const clampedDelta = clamp(
103
+ delta,
104
+ -MAX_WHEEL_DELTA_PER_FRAME,
105
+ MAX_WHEEL_DELTA_PER_FRAME,
106
+ );
107
+ const nextZoom = clamp(
108
+ currentZoom * Math.exp(-clampedDelta * sensitivity),
109
+ minZoom,
110
+ maxZoom,
111
+ );
112
+
113
+ if (nextZoom === currentZoom) return viewport;
114
+
115
+ const flowX = (pointer.x - viewport.x) / currentZoom;
116
+ const flowY = (pointer.y - viewport.y) / currentZoom;
117
+
118
+ return {
119
+ x: pointer.x - flowX * nextZoom,
120
+ y: pointer.y - flowY * nextZoom,
121
+ zoom: nextZoom,
122
+ };
123
+ };
124
+
39
125
  const getIncomingEdgeGroupKey = (edge, stroke) =>
40
126
  `${edge.target}::${stroke || DEFAULT_EDGE_STROKE}`;
41
127
 
128
+ const getOutgoingEdgeGroupKey = (edge, stroke) =>
129
+ `${edge.source}::${stroke || DEFAULT_EDGE_STROKE}`;
130
+
42
131
  const getNodeRelationGroupKey = (edge) =>
43
132
  edge?.data?.primaryType || edge?.style?.stroke || DEFAULT_EDGE_STROKE;
44
133
 
@@ -151,30 +240,335 @@ const orderNodesByRelationGroup = (nodes = [], edges = [], rootNodeId) => {
151
240
  });
152
241
  };
153
242
 
243
+ const getNodeHeight = (node) =>
244
+ node.height ?? node.measured?.height ?? DEFAULT_NODE_HEIGHT;
245
+
246
+ const getNodeCenterY = (node) =>
247
+ (node.y ?? node.position?.y ?? 0) + getNodeHeight(node) / 2;
248
+
249
+ const alignNodesToConnectionPoints = (nodes = [], edges = [], rootNodeId) => {
250
+ if (!rootNodeId) return nodes;
251
+
252
+ const levels = buildSignedNodeLevels(nodes, edges, rootNodeId);
253
+
254
+ if (!levels.size) return nodes;
255
+
256
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
257
+ const desiredCentersById = new Map();
258
+
259
+ nodes.forEach((node) => {
260
+ const rank = levels.get(node.id) || 0;
261
+
262
+ if (rank === 0) return;
263
+
264
+ const preferredEdges = edges.filter((edge) => {
265
+ if (rank < 0 && edge.source === node.id) {
266
+ const neighbourRank = levels.get(edge.target);
267
+ return (
268
+ neighbourRank !== undefined &&
269
+ Math.abs(neighbourRank) < Math.abs(rank)
270
+ );
271
+ }
272
+
273
+ if (rank > 0 && edge.target === node.id) {
274
+ const neighbourRank = levels.get(edge.source);
275
+ return (
276
+ neighbourRank !== undefined &&
277
+ Math.abs(neighbourRank) < Math.abs(rank)
278
+ );
279
+ }
280
+
281
+ return false;
282
+ });
283
+
284
+ if (preferredEdges.length !== 1) return;
285
+
286
+ const edge = preferredEdges[0];
287
+ const slotOffset = getTargetSlotOffset(
288
+ edge.data?.targetSlotIndex,
289
+ edge.data?.targetSlotCount,
290
+ );
291
+
292
+ if (rank < 0) {
293
+ const targetNode = nodeById.get(edge.target);
294
+
295
+ if (!targetNode) return;
296
+
297
+ desiredCentersById.set(node.id, getNodeCenterY(targetNode) + slotOffset);
298
+ return;
299
+ }
300
+
301
+ const sourceNode = nodeById.get(edge.source);
302
+
303
+ if (!sourceNode) return;
304
+
305
+ desiredCentersById.set(node.id, getNodeCenterY(sourceNode) - slotOffset);
306
+ });
307
+
308
+ const nodesByRank = new Map();
309
+
310
+ nodes.forEach((node) => {
311
+ const rank = levels.get(node.id) || 0;
312
+ const bucket = nodesByRank.get(rank) || [];
313
+
314
+ bucket.push(node);
315
+ nodesByRank.set(rank, bucket);
316
+ });
317
+
318
+ const alignedNodes = new Map();
319
+
320
+ nodesByRank.forEach((rankNodes) => {
321
+ const orderedNodes = [...rankNodes].sort((nodeA, nodeB) => {
322
+ const desiredA =
323
+ desiredCentersById.get(nodeA.id) ?? getNodeCenterY(nodeA);
324
+ const desiredB =
325
+ desiredCentersById.get(nodeB.id) ?? getNodeCenterY(nodeB);
326
+
327
+ if (desiredA !== desiredB) return desiredA - desiredB;
328
+
329
+ return (
330
+ (nodeA.y ?? nodeA.position?.y ?? 0) -
331
+ (nodeB.y ?? nodeB.position?.y ?? 0)
332
+ );
333
+ });
334
+
335
+ orderedNodes.reduce((prevBottom, node) => {
336
+ const height = getNodeHeight(node);
337
+ const desiredCenter =
338
+ desiredCentersById.get(node.id) ?? getNodeCenterY(node);
339
+ const desiredTop = desiredCenter - height / 2;
340
+ const nextTop = Math.max(desiredTop, prevBottom + MIN_NODE_GAP);
341
+
342
+ alignedNodes.set(node.id, {
343
+ ...node,
344
+ y: nextTop,
345
+ position: { ...(node.position || {}), x: node.x, y: nextTop },
346
+ });
347
+
348
+ return nextTop + height;
349
+ }, Number.NEGATIVE_INFINITY);
350
+ });
351
+
352
+ return nodes.map((node) => alignedNodes.get(node.id) || node);
353
+ };
354
+
154
355
  export const getMinZoomForDiagram = (
155
356
  nodes = [],
156
357
  viewportWidth,
157
358
  viewportHeight,
158
359
  padding = FIT_VIEW_PADDING,
360
+ minZoom = DEFAULT_MIN_ZOOM,
361
+ maxZoom = DEFAULT_MAX_ZOOM,
362
+ getDiagramBounds = getStaticNodesBounds,
159
363
  ) => {
160
- if (!nodes.length || !viewportWidth || !viewportHeight)
161
- return DEFAULT_MIN_ZOOM;
162
-
163
- const bounds = getNodesBounds(nodes);
164
-
165
- if (!bounds.width || !bounds.height) return DEFAULT_MIN_ZOOM;
166
-
167
- return Math.max(
168
- getViewportForBounds(
169
- bounds,
170
- viewportWidth,
171
- viewportHeight,
172
- DEFAULT_MIN_ZOOM,
173
- DEFAULT_MAX_ZOOM,
174
- padding,
175
- ).zoom,
176
- DEFAULT_MIN_ZOOM,
364
+ if (!nodes.length || !viewportWidth || !viewportHeight) return minZoom;
365
+
366
+ const bounds = getDiagramBounds(nodes);
367
+
368
+ if (!bounds.width || !bounds.height) return minZoom;
369
+
370
+ const fitZoom = getViewportForBounds(
371
+ bounds,
372
+ viewportWidth,
373
+ viewportHeight,
374
+ minZoom,
375
+ maxZoom,
376
+ padding,
377
+ ).zoom;
378
+
379
+ return Math.min(fitZoom, minZoom);
380
+ };
381
+
382
+ const nodeBoundsForViewport = (node = {}) => {
383
+ const geometry = node.data?.absoluteGeometry;
384
+ const x = geometry?.x ?? node.position?.x ?? node.x ?? 0;
385
+ const y = geometry?.y ?? node.position?.y ?? node.y ?? 0;
386
+ const width =
387
+ geometry?.w ?? node.width ?? node.measured?.width ?? DEFAULT_NODE_WIDTH;
388
+ const height =
389
+ geometry?.h ?? node.height ?? node.measured?.height ?? DEFAULT_NODE_HEIGHT;
390
+
391
+ return { x, y, width, height };
392
+ };
393
+
394
+ const nodeCenterForViewport = (node) => {
395
+ const bounds = nodeBoundsForViewport(node);
396
+
397
+ return {
398
+ x: bounds.x + bounds.width / 2,
399
+ y: bounds.y + bounds.height / 2,
400
+ };
401
+ };
402
+
403
+ const finiteNumber = (value) => {
404
+ const parsed = Number(value);
405
+
406
+ return Number.isFinite(parsed) ? parsed : undefined;
407
+ };
408
+
409
+ const firstFiniteNumber = (values = [], fallback = 0) =>
410
+ values.map(finiteNumber).find((value) => value !== undefined) ?? fallback;
411
+
412
+ const nodePositionForOverlap = (node = {}) => ({
413
+ x: firstFiniteNumber([node.position?.x, node.x], 0),
414
+ y: firstFiniteNumber([node.position?.y, node.y], 0),
415
+ });
416
+
417
+ const nodeSizeForOverlap = (node = {}) => ({
418
+ width: firstFiniteNumber(
419
+ [node.width, node.measured?.width, node.data?.absoluteGeometry?.w],
420
+ DEFAULT_NODE_WIDTH,
421
+ ),
422
+ height: firstFiniteNumber(
423
+ [node.height, node.measured?.height, node.data?.absoluteGeometry?.h],
424
+ DEFAULT_NODE_HEIGHT,
425
+ ),
426
+ });
427
+
428
+ const nodeBoundsForOverlap = (node = {}) => ({
429
+ ...nodePositionForOverlap(node),
430
+ ...nodeSizeForOverlap(node),
431
+ });
432
+
433
+ const boundsOverlap = (boundsA, boundsB, gap = 0) =>
434
+ boundsA.x < boundsB.x + boundsB.width + gap &&
435
+ boundsA.x + boundsA.width + gap > boundsB.x &&
436
+ boundsA.y < boundsB.y + boundsB.height + gap &&
437
+ boundsA.y + boundsA.height + gap > boundsB.y;
438
+
439
+ const siblingDragPosition = (draggedNode, siblingNodes = [], parentNode) => {
440
+ const position = nodePositionForOverlap(draggedNode);
441
+ const size = nodeSizeForOverlap(draggedNode);
442
+ const parentSize = parentNode ? nodeSizeForOverlap(parentNode) : null;
443
+ const maxX = parentSize
444
+ ? Math.max(0, parentSize.width - size.width)
445
+ : position.x;
446
+ const maxY = parentSize
447
+ ? Math.max(0, parentSize.height - size.height)
448
+ : position.y;
449
+ const x = parentSize ? clamp(position.x, 0, maxX) : position.x;
450
+ const candidateYs = new Set([
451
+ parentSize ? clamp(position.y, 0, maxY) : position.y,
452
+ ]);
453
+
454
+ siblingNodes.forEach((siblingNode) => {
455
+ const siblingBounds = nodeBoundsForOverlap(siblingNode);
456
+
457
+ candidateYs.add(siblingBounds.y + siblingBounds.height + MIN_NODE_GAP);
458
+ candidateYs.add(siblingBounds.y - size.height - MIN_NODE_GAP);
459
+ });
460
+
461
+ if (parentSize) {
462
+ candidateYs.add(0);
463
+ candidateYs.add(maxY);
464
+ }
465
+
466
+ const sortedCandidates = [...candidateYs]
467
+ .map((y) => (parentSize ? clamp(y, 0, maxY) : y))
468
+ .sort((yA, yB) => Math.abs(yA - position.y) - Math.abs(yB - position.y));
469
+ const nextY =
470
+ sortedCandidates.find((y) => {
471
+ const candidateBounds = { x, y, ...size };
472
+
473
+ return siblingNodes.every(
474
+ (siblingNode) =>
475
+ !boundsOverlap(
476
+ candidateBounds,
477
+ nodeBoundsForOverlap(siblingNode),
478
+ MIN_NODE_GAP,
479
+ ),
480
+ );
481
+ }) ?? (parentSize ? clamp(position.y, 0, maxY) : position.y);
482
+
483
+ return { x, y: nextY };
484
+ };
485
+
486
+ const nodeWithUpdatedFixedPosition = (node, position, nodesById) => {
487
+ const size = nodeSizeForOverlap(node);
488
+ const parentGeometry = node.parentId
489
+ ? nodesById.get(node.parentId)?.data?.absoluteGeometry
490
+ : undefined;
491
+ const absoluteGeometry = node.data?.absoluteGeometry
492
+ ? {
493
+ ...node.data.absoluteGeometry,
494
+ x: (parentGeometry?.x ?? 0) + position.x,
495
+ y: (parentGeometry?.y ?? 0) + position.y,
496
+ w: size.width,
497
+ h: size.height,
498
+ }
499
+ : undefined;
500
+
501
+ return {
502
+ ...node,
503
+ position,
504
+ x: position.x,
505
+ y: position.y,
506
+ data: absoluteGeometry
507
+ ? {
508
+ ...node.data,
509
+ absoluteGeometry,
510
+ }
511
+ : node.data,
512
+ };
513
+ };
514
+
515
+ export const resolveFixedNodeSiblingOverlap = (nodes = [], draggedNodeId) => {
516
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
517
+ const draggedNode = nodesById.get(draggedNodeId);
518
+
519
+ if (!draggedNode || draggedNode.draggable === false) return nodes;
520
+
521
+ const parentNode = draggedNode.parentId
522
+ ? nodesById.get(draggedNode.parentId)
523
+ : undefined;
524
+ const siblingNodes = nodes.filter(
525
+ (node) =>
526
+ node.id !== draggedNode.id && node.parentId === draggedNode.parentId,
527
+ );
528
+ const nextPosition = siblingDragPosition(
529
+ draggedNode,
530
+ siblingNodes,
531
+ parentNode,
177
532
  );
533
+ const currentPosition = nodePositionForOverlap(draggedNode);
534
+
535
+ if (
536
+ nextPosition.x === currentPosition.x &&
537
+ nextPosition.y === currentPosition.y
538
+ ) {
539
+ return nodes;
540
+ }
541
+
542
+ return nodes.map((node) =>
543
+ node.id === draggedNode.id
544
+ ? nodeWithUpdatedFixedPosition(node, nextPosition, nodesById)
545
+ : node,
546
+ );
547
+ };
548
+
549
+ const getFocusZoomForNode = (
550
+ node,
551
+ viewportWidth,
552
+ viewportHeight,
553
+ minZoom,
554
+ currentZoom,
555
+ ) => {
556
+ if (!viewportWidth || !viewportHeight) return currentZoom;
557
+
558
+ const bounds = nodeBoundsForViewport(node);
559
+
560
+ if (!bounds.width || !bounds.height) return currentZoom;
561
+
562
+ const focusZoom = getViewportForBounds(
563
+ bounds,
564
+ viewportWidth,
565
+ viewportHeight,
566
+ DEFAULT_MIN_ZOOM,
567
+ DEFAULT_MAX_ZOOM,
568
+ FOCUS_VIEW_PADDING,
569
+ ).zoom;
570
+
571
+ return Math.max(minZoom, Math.min(FOCUS_MAX_ZOOM, focusZoom));
178
572
  };
179
573
 
180
574
  const GraphControlTooltip = ({ content, children }) => (
@@ -192,30 +586,182 @@ GraphControlTooltip.propTypes = {
192
586
  children: PropTypes.node.isRequired,
193
587
  };
194
588
 
589
+ const getTranslateExtent = (nodes = []) => {
590
+ if (!nodes.length) return DEFAULT_TRANSLATE_EXTENT;
591
+
592
+ const bounds = nodes.reduce(
593
+ (acc, node) => {
594
+ const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH;
595
+ const height =
596
+ node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT;
597
+ const x = node.position?.x ?? node.x ?? 0;
598
+ const y = node.position?.y ?? node.y ?? 0;
599
+
600
+ return {
601
+ minX: Math.min(acc.minX, x),
602
+ minY: Math.min(acc.minY, y),
603
+ maxX: Math.max(acc.maxX, x + width),
604
+ maxY: Math.max(acc.maxY, y + height),
605
+ };
606
+ },
607
+ {
608
+ minX: Number.POSITIVE_INFINITY,
609
+ minY: Number.POSITIVE_INFINITY,
610
+ maxX: Number.NEGATIVE_INFINITY,
611
+ maxY: Number.NEGATIVE_INFINITY,
612
+ },
613
+ );
614
+
615
+ const diagramWidth = bounds.maxX - bounds.minX;
616
+ const diagramHeight = bounds.maxY - bounds.minY;
617
+ const padding = Math.max(
618
+ TRANSLATE_EXTENT_MIN_PADDING,
619
+ diagramWidth * TRANSLATE_EXTENT_SIZE_MULTIPLIER,
620
+ diagramHeight * TRANSLATE_EXTENT_SIZE_MULTIPLIER,
621
+ );
622
+
623
+ return [
624
+ [bounds.minX - padding, bounds.minY - padding],
625
+ [bounds.maxX + padding, bounds.maxY + padding],
626
+ ];
627
+ };
628
+
629
+ const nodeLayoutSignature = (node = {}) =>
630
+ [
631
+ node.id,
632
+ node.parentId || "",
633
+ node.position?.x ?? node.x ?? 0,
634
+ node.position?.y ?? node.y ?? 0,
635
+ node.width ?? node.measured?.width ?? DEFAULT_NODE_WIDTH,
636
+ node.height ?? node.measured?.height ?? DEFAULT_NODE_HEIGHT,
637
+ ].join(":");
638
+
639
+ const graphLayoutSignature = (nodes = [], edges = []) =>
640
+ `${nodes.map(nodeLayoutSignature).join("|")}::${edges
641
+ .map((edge) => edge.id)
642
+ .join("|")}`;
643
+
644
+ const absoluteGeometrySignature = (node = {}) => {
645
+ const geometry = node.data?.absoluteGeometry;
646
+
647
+ if (!geometry) return "";
648
+
649
+ return [geometry.x, geometry.y, geometry.w, geometry.h].join(":");
650
+ };
651
+
652
+ const mergeFixedLayoutNodes = (currentNodes = [], nextNodes = []) => {
653
+ const currentById = new Map(currentNodes.map((node) => [node.id, node]));
654
+
655
+ return nextNodes.map((node) => {
656
+ const currentNode = currentById.get(node.id);
657
+ const geometryChanged =
658
+ absoluteGeometrySignature(currentNode) !==
659
+ absoluteGeometrySignature(node);
660
+
661
+ if (!currentNode?.position || node.draggable === false || geometryChanged) {
662
+ return node;
663
+ }
664
+
665
+ return {
666
+ ...node,
667
+ measured: currentNode.measured || node.measured,
668
+ position: currentNode.position,
669
+ x: currentNode.x ?? node.x,
670
+ y: currentNode.y ?? node.y,
671
+ data: currentNode.data?.absoluteGeometry
672
+ ? {
673
+ ...node.data,
674
+ absoluteGeometry: currentNode.data.absoluteGeometry,
675
+ }
676
+ : node.data,
677
+ };
678
+ });
679
+ };
680
+
195
681
  const LayoutFlow = ({
196
682
  initialNodes,
197
683
  initialEdges,
198
684
  onNodeClick,
685
+ onNodeDoubleClick,
199
686
  onOpenExpanded,
687
+ onCloseExpanded,
200
688
  containerRef,
201
689
  rootNodeId,
690
+ onPaneClick,
691
+ topControls,
692
+ afterControls,
693
+ bottomControls,
694
+ onInitialFitComplete,
695
+ graphOptions,
202
696
  }) => {
203
- const { fitView, zoomIn, zoomOut, getNodes } = useReactFlow();
697
+ const {
698
+ layoutMode,
699
+ extraNodeTypes,
700
+ focusNodeId,
701
+ focusNodeRequestKey,
702
+ minimapNodeColor,
703
+ controlsOrientation = "horizontal",
704
+ fitViewOnLayoutChange,
705
+ fitViewPadding = FIT_VIEW_PADDING,
706
+ defaultFitViewPadding,
707
+ minZoom: minZoomLimit = DEFAULT_MIN_ZOOM,
708
+ maxZoom: maxZoomLimit = DEFAULT_MAX_ZOOM,
709
+ wheelZoomSensitivity,
710
+ preserveUserViewportOnLayoutChange,
711
+ fitViewRequestKey,
712
+ onlyRenderVisibleElements,
713
+ } = graphOptions || {};
714
+ const {
715
+ fitView,
716
+ zoomIn,
717
+ zoomOut,
718
+ getNodes,
719
+ getNodesBounds: getFlowNodesBounds,
720
+ setCenter,
721
+ getViewport,
722
+ setViewport,
723
+ } = useReactFlow();
204
724
  const { formatMessage } = useIntl();
205
725
  const [isInitialized, setIsInitialized] = useState(false);
206
- const [minZoom, setMinZoom] = useState(DEFAULT_MIN_ZOOM);
726
+ const [isMinimapCollapsed, setIsMinimapCollapsed] = useState(false);
727
+ const [isMinimapExpanded, setIsMinimapExpanded] = useState(false);
728
+ const [minimapZoom, setMinimapZoom] = useState(1);
729
+ const [spacePanMode, setSpacePanMode] = useState(false);
730
+ const resolvedFitViewPadding = defaultFitViewPadding ?? fitViewPadding;
731
+ const [minZoom, setMinZoom] = useState(minZoomLimit);
207
732
  const layoutRunId = useRef(0);
208
733
  const fitViewFrameId = useRef();
734
+ const fitRunId = useRef(0);
735
+ const initialFitComplete = useRef(false);
736
+ const initialFitCallback = useRef(onInitialFitComplete);
737
+ initialFitCallback.current = onInitialFitComplete;
738
+ const focusFrameIds = useRef([]);
739
+ const wheelZoomFrameId = useRef();
740
+ const minimapZoomFrameId = useRef();
741
+ const pendingMinimapZoom = useRef(1);
742
+ const pendingWheelZoom = useRef({ delta: 0, pointer: null });
743
+ const userOwnsViewport = useRef(false);
209
744
  const lastFitSignature = useRef("");
745
+ const lastFitViewRequestKey = useRef(fitViewRequestKey);
746
+ const suppressSpaceInteraction = useRef(false);
747
+ const focusNodeIdRef = useRef(focusNodeId);
748
+ focusNodeIdRef.current = focusNodeId ?? null;
749
+ const fixedLayout = layoutMode === "fixed";
750
+ const nodeTypes = useMemo(
751
+ () => ({ ...baseNodeTypes, ...(extraNodeTypes || {}) }),
752
+ [extraNodeTypes],
753
+ );
210
754
  const parsedNodes = useMemo(
211
755
  () =>
212
756
  initialNodes.map((node) => ({
213
757
  ...node,
214
- position: { x: 0, y: 0 },
215
- sourcePosition: Position.Right,
216
- targetPosition: Position.Left,
758
+ position: fixedLayout
759
+ ? node.position || { x: 0, y: 0 }
760
+ : { x: 0, y: 0 },
761
+ sourcePosition: node.sourcePosition || Position.Right,
762
+ targetPosition: node.targetPosition || Position.Left,
217
763
  })),
218
- [initialNodes],
764
+ [fixedLayout, initialNodes],
219
765
  );
220
766
  const parsedEdges = useMemo(() => {
221
767
  const edgesWithStroke = initialEdges.map((edge) => {
@@ -234,60 +780,179 @@ const LayoutFlow = ({
234
780
  };
235
781
  });
236
782
 
237
- const groupsByTarget = new Map();
783
+ const groupMetaByEdgeId = new Map();
784
+ const mergeEdgeMeta = (edgeId, meta) => {
785
+ groupMetaByEdgeId.set(edgeId, {
786
+ ...groupMetaByEdgeId.get(edgeId),
787
+ ...meta,
788
+ });
789
+ };
238
790
 
239
- edgesWithStroke.forEach((edge) => {
240
- const stroke = edge.style?.stroke || DEFAULT_EDGE_STROKE;
241
- const groupKey = getIncomingEdgeGroupKey(edge, stroke);
242
- const targetGroups = groupsByTarget.get(edge.target) || new Map();
243
- const group = targetGroups.get(groupKey) || [];
791
+ if (fixedLayout) {
792
+ const edgesByTarget = new Map();
793
+ const edgesBySource = new Map();
244
794
 
245
- group.push(edge.id);
246
- targetGroups.set(groupKey, group);
247
- groupsByTarget.set(edge.target, targetGroups);
248
- });
795
+ edgesWithStroke.forEach((edge) => {
796
+ const targetEdges = edgesByTarget.get(edge.target) || [];
797
+ const sourceEdges = edgesBySource.get(edge.source) || [];
249
798
 
250
- const groupMetaByEdgeId = new Map();
799
+ targetEdges.push(edge);
800
+ edgesByTarget.set(edge.target, targetEdges);
801
+ sourceEdges.push(edge);
802
+ edgesBySource.set(edge.source, sourceEdges);
803
+ });
251
804
 
252
- groupsByTarget.forEach((targetGroups) => {
253
- const orderedGroups = Array.from(targetGroups.entries()).sort(
254
- ([groupKeyA], [groupKeyB]) => groupKeyA.localeCompare(groupKeyB),
255
- );
805
+ edgesByTarget.forEach((targetEdges) => {
806
+ const orderedEdges = [...targetEdges].sort((edgeA, edgeB) => {
807
+ const sourceDiff = edgeA.source.localeCompare(edgeB.source);
808
+
809
+ if (sourceDiff !== 0) return sourceDiff;
810
+
811
+ const strokeA = edgeA.style?.stroke || DEFAULT_EDGE_STROKE;
812
+ const strokeB = edgeB.style?.stroke || DEFAULT_EDGE_STROKE;
813
+ const strokeDiff = strokeA.localeCompare(strokeB);
256
814
 
257
- orderedGroups.forEach(([groupKey, edgeIds], groupIndex) => {
258
- edgeIds.forEach((edgeId, edgeIndex) => {
259
- groupMetaByEdgeId.set(edgeId, {
260
- targetSlotCount: orderedGroups.length,
261
- targetSlotIndex: groupIndex,
262
- showTargetArrow: edgeIndex === edgeIds.length - 1,
263
- incomingGroupKey: groupKey,
815
+ return strokeDiff || edgeA.id.localeCompare(edgeB.id);
816
+ });
817
+
818
+ orderedEdges.forEach((edge, edgeIndex) => {
819
+ mergeEdgeMeta(edge.id, {
820
+ targetSlotCount: orderedEdges.length,
821
+ targetSlotIndex: edgeIndex,
822
+ showTargetArrow: edge.data?.showTargetArrow !== false,
264
823
  });
265
824
  });
266
825
  });
267
- });
826
+
827
+ edgesBySource.forEach((sourceEdges) => {
828
+ const orderedEdges = [...sourceEdges].sort((edgeA, edgeB) => {
829
+ const targetDiff = edgeA.target.localeCompare(edgeB.target);
830
+
831
+ if (targetDiff !== 0) return targetDiff;
832
+
833
+ const strokeA = edgeA.style?.stroke || DEFAULT_EDGE_STROKE;
834
+ const strokeB = edgeB.style?.stroke || DEFAULT_EDGE_STROKE;
835
+ const strokeDiff = strokeA.localeCompare(strokeB);
836
+
837
+ return strokeDiff || edgeA.id.localeCompare(edgeB.id);
838
+ });
839
+
840
+ orderedEdges.forEach((edge, edgeIndex) => {
841
+ mergeEdgeMeta(edge.id, {
842
+ sourceSlotCount: orderedEdges.length,
843
+ sourceSlotIndex: edgeIndex,
844
+ routeCount: orderedEdges.length,
845
+ routeIndex: edgeIndex,
846
+ });
847
+ });
848
+ });
849
+ } else {
850
+ const groupsByTarget = new Map();
851
+ const groupsBySource = new Map();
852
+
853
+ edgesWithStroke.forEach((edge) => {
854
+ const stroke = edge.style?.stroke || DEFAULT_EDGE_STROKE;
855
+ const groupKey = getIncomingEdgeGroupKey(edge, stroke);
856
+ const targetGroups = groupsByTarget.get(edge.target) || new Map();
857
+ const group = targetGroups.get(groupKey) || [];
858
+ const sourceGroupKey = getOutgoingEdgeGroupKey(edge, stroke);
859
+ const sourceGroups = groupsBySource.get(edge.source) || new Map();
860
+ const sourceGroup = sourceGroups.get(sourceGroupKey) || [];
861
+
862
+ group.push(edge.id);
863
+ targetGroups.set(groupKey, group);
864
+ groupsByTarget.set(edge.target, targetGroups);
865
+ sourceGroup.push(edge.id);
866
+ sourceGroups.set(sourceGroupKey, sourceGroup);
867
+ groupsBySource.set(edge.source, sourceGroups);
868
+ });
869
+
870
+ groupsByTarget.forEach((targetGroups) => {
871
+ const orderedGroups = Array.from(targetGroups.entries()).sort(
872
+ ([groupKeyA], [groupKeyB]) => groupKeyA.localeCompare(groupKeyB),
873
+ );
874
+
875
+ orderedGroups.forEach(([groupKey, edgeIds], groupIndex) => {
876
+ edgeIds.forEach((edgeId, edgeIndex) => {
877
+ mergeEdgeMeta(edgeId, {
878
+ targetSlotCount: orderedGroups.length,
879
+ targetSlotIndex: groupIndex,
880
+ showTargetArrow: edgeIndex === edgeIds.length - 1,
881
+ incomingGroupKey: groupKey,
882
+ });
883
+ });
884
+ });
885
+ });
886
+
887
+ groupsBySource.forEach((sourceGroups) => {
888
+ const orderedGroups = Array.from(sourceGroups.entries()).sort(
889
+ ([groupKeyA], [groupKeyB]) => groupKeyA.localeCompare(groupKeyB),
890
+ );
891
+
892
+ orderedGroups.forEach(([, edgeIds], groupIndex) => {
893
+ edgeIds.forEach((edgeId) => {
894
+ mergeEdgeMeta(edgeId, {
895
+ sourceSlotCount: orderedGroups.length,
896
+ sourceSlotIndex: groupIndex,
897
+ });
898
+ });
899
+ });
900
+ });
901
+ }
268
902
 
269
903
  return edgesWithStroke.map((edge) => ({
270
904
  ...edge,
271
- markerEnd: edge.markerEnd || {
905
+ markerEnd: {
272
906
  type: MarkerType.ArrowClosed,
273
907
  width: 12,
274
908
  height: 12,
275
909
  color: edge.style?.stroke || DEFAULT_EDGE_STROKE,
910
+ ...(edge.markerEnd || {}),
276
911
  },
277
912
  data: {
278
913
  ...edge.data,
279
914
  ...groupMetaByEdgeId.get(edge.id),
280
915
  },
281
916
  }));
282
- }, [initialEdges]);
917
+ }, [fixedLayout, initialEdges]);
283
918
  const [nodes, setNodes, onNodesChange] = useNodesState(parsedNodes);
284
919
  const [edges, setEdges, onEdgesChange] = useEdgesState(parsedEdges);
920
+ const translateExtent = useMemo(() => getTranslateExtent(nodes), [nodes]);
921
+ const onFixedNodeDragStop = useCallback(
922
+ (_, draggedNode) => {
923
+ if (!fixedLayout || !draggedNode?.id) return;
924
+
925
+ setNodes((currentNodes) => {
926
+ const nodesWithDraggedPosition = currentNodes.map((node) =>
927
+ node.id === draggedNode.id
928
+ ? {
929
+ ...node,
930
+ ...draggedNode,
931
+ data: {
932
+ ...node.data,
933
+ ...draggedNode.data,
934
+ },
935
+ style: {
936
+ ...node.style,
937
+ ...draggedNode.style,
938
+ },
939
+ }
940
+ : node,
941
+ );
942
+
943
+ return resolveFixedNodeSiblingOverlap(
944
+ nodesWithDraggedPosition,
945
+ draggedNode.id,
946
+ );
947
+ });
948
+ },
949
+ [fixedLayout, setNodes],
950
+ );
285
951
  const layoutInput = useMemo(() => {
286
- const safeNodes = orderNodesByRelationGroup(
287
- parsedNodes.filter((node) => node && node.id),
288
- parsedEdges,
289
- rootNodeId,
290
- );
952
+ const parsedSafeNodes = parsedNodes.filter((node) => node && node.id);
953
+ const safeNodes = fixedLayout
954
+ ? parsedSafeNodes
955
+ : orderNodesByRelationGroup(parsedSafeNodes, parsedEdges, rootNodeId);
291
956
  const safeNodeIds = new Set(safeNodes.map((node) => node.id));
292
957
  const safeEdges = parsedEdges.filter(
293
958
  (edge) =>
@@ -300,44 +965,263 @@ const LayoutFlow = ({
300
965
  );
301
966
 
302
967
  return { nodes: safeNodes, edges: safeEdges };
303
- }, [parsedNodes, parsedEdges, rootNodeId]);
968
+ }, [fixedLayout, parsedNodes, parsedEdges, rootNodeId]);
969
+
970
+ const scheduleFitView = useCallback(
971
+ (nodesForFit, edgesForFit) => {
972
+ const signature = fixedLayout
973
+ ? graphLayoutSignature(nodesForFit, edgesForFit)
974
+ : `${nodesForFit.map((node) => node.id).join("|")}::${edgesForFit
975
+ .map((edge) => edge.id)
976
+ .join("|")}`;
977
+ const isInitialLoad = lastFitSignature.current === "";
978
+ const changed = signature !== lastFitSignature.current;
979
+ const fitViewRequested =
980
+ fitViewRequestKey !== lastFitViewRequestKey.current;
981
+ lastFitSignature.current = signature;
982
+ lastFitViewRequestKey.current = fitViewRequestKey;
983
+
984
+ if (fixedLayout && !changed && !fitViewRequested) return;
304
985
 
305
- const getLayoutedElements = useCallback(
306
- ({ nodes: nodesToLayout, edges: edgesToLayout }) => {
307
- const scheduleFitView = (nodesForFit, edgesForFit) => {
308
- const signature = `${nodesForFit
309
- .map((node) => node.id)
310
- .join("|")}::${edgesForFit.map((edge) => edge.id).join("|")}`;
311
- const changed = signature !== lastFitSignature.current;
312
- lastFitSignature.current = signature;
313
-
314
- if (fitViewFrameId.current) {
315
- cancelAnimationFrame(fitViewFrameId.current);
986
+ if (fitViewFrameId.current) {
987
+ cancelAnimationFrame(fitViewFrameId.current);
988
+ }
989
+
990
+ fitRunId.current += 1;
991
+ const runId = fitRunId.current;
992
+ const fitWhenMeasured = () => {
993
+ const measuredNodes = getNodes();
994
+ const measuredIds = new Set(
995
+ measuredNodes
996
+ .filter((node) => node.measured?.width && node.measured?.height)
997
+ .map((node) => node.id),
998
+ );
999
+
1000
+ if (
1001
+ initialFitCallback.current &&
1002
+ !initialFitComplete.current &&
1003
+ (!containerRef.current?.clientWidth ||
1004
+ !containerRef.current?.clientHeight ||
1005
+ nodesForFit.some((node) => !measuredIds.has(node.id)))
1006
+ ) {
1007
+ fitViewFrameId.current = requestAnimationFrame(fitWhenMeasured);
1008
+ return;
1009
+ }
1010
+ const nextMinZoom = getMinZoomForDiagram(
1011
+ measuredNodes.length ? measuredNodes : nodesForFit,
1012
+ containerRef.current?.clientWidth,
1013
+ containerRef.current?.clientHeight,
1014
+ resolvedFitViewPadding,
1015
+ minZoomLimit,
1016
+ maxZoomLimit,
1017
+ getFlowNodesBounds,
1018
+ );
1019
+
1020
+ setMinZoom(nextMinZoom);
1021
+ const shouldFollowFixedLayoutChange =
1022
+ fixedLayout &&
1023
+ fitViewOnLayoutChange &&
1024
+ (fitViewRequested ||
1025
+ !(preserveUserViewportOnLayoutChange && userOwnsViewport.current));
1026
+ const pendingFocusId = shouldFollowFixedLayoutChange
1027
+ ? null
1028
+ : focusNodeIdRef.current;
1029
+ if (
1030
+ pendingFocusId &&
1031
+ (initialFitComplete.current || !initialFitCallback.current)
1032
+ ) {
1033
+ const nodesForFocus = measuredNodes.length
1034
+ ? measuredNodes
1035
+ : nodesForFit;
1036
+ const targetNode = nodesForFocus.find((n) => n.id === pendingFocusId);
1037
+
1038
+ if (targetNode) {
1039
+ const currentZoom = getViewport().zoom;
1040
+ const focusCenter = nodeCenterForViewport(targetNode);
1041
+ const focusZoom = getFocusZoomForNode(
1042
+ targetNode,
1043
+ containerRef.current?.clientWidth,
1044
+ containerRef.current?.clientHeight,
1045
+ nextMinZoom,
1046
+ currentZoom,
1047
+ );
1048
+
1049
+ setCenter(focusCenter.x, focusCenter.y, {
1050
+ zoom: focusZoom,
1051
+ duration: 300,
1052
+ });
1053
+ return;
1054
+ }
316
1055
  }
317
1056
 
318
- fitViewFrameId.current = requestAnimationFrame(() => {
319
- const measuredNodes = getNodes();
320
- const nextMinZoom = getMinZoomForDiagram(
321
- measuredNodes.length ? measuredNodes : nodesForFit,
322
- containerRef.current?.clientWidth,
323
- containerRef.current?.clientHeight,
324
- );
1057
+ const shouldFitGraph =
1058
+ !fixedLayout ||
1059
+ isInitialLoad ||
1060
+ (initialFitCallback.current && !initialFitComplete.current) ||
1061
+ shouldFollowFixedLayoutChange;
1062
+
1063
+ if (!shouldFitGraph) return;
325
1064
 
326
- setMinZoom(nextMinZoom);
1065
+ Promise.resolve(
327
1066
  fitView({
328
- padding: FIT_VIEW_PADDING,
329
- duration: changed ? 300 : 0,
1067
+ padding: resolvedFitViewPadding,
1068
+ duration:
1069
+ initialFitCallback.current && !initialFitComplete.current
1070
+ ? 0
1071
+ : changed || isInitialLoad || fitViewRequested
1072
+ ? 500
1073
+ : 0,
330
1074
  minZoom: nextMinZoom,
1075
+ }),
1076
+ ).then((fitted) => {
1077
+ if (runId !== fitRunId.current || initialFitComplete.current) return;
1078
+ if (fitted === false) {
1079
+ if (initialFitCallback.current)
1080
+ fitViewFrameId.current = requestAnimationFrame(fitWhenMeasured);
1081
+ return;
1082
+ }
1083
+ fitViewFrameId.current = requestAnimationFrame(() => {
1084
+ if (runId !== fitRunId.current) return;
1085
+ initialFitComplete.current = true;
1086
+ initialFitCallback.current?.();
331
1087
  });
332
1088
  });
333
1089
  };
1090
+ fitViewFrameId.current = requestAnimationFrame(fitWhenMeasured);
1091
+ },
1092
+ [
1093
+ containerRef,
1094
+ fitView,
1095
+ fitViewRequestKey,
1096
+ fixedLayout,
1097
+ fitViewOnLayoutChange,
1098
+ getFlowNodesBounds,
1099
+ getNodes,
1100
+ getViewport,
1101
+ maxZoomLimit,
1102
+ minZoomLimit,
1103
+ preserveUserViewportOnLayoutChange,
1104
+ resolvedFitViewPadding,
1105
+ setCenter,
1106
+ ],
1107
+ );
1108
+
1109
+ const markUserViewportInteraction = useCallback(() => {
1110
+ userOwnsViewport.current = true;
1111
+ }, []);
1112
+
1113
+ const handleMoveStart = useCallback(
1114
+ (event) => {
1115
+ if (event) markUserViewportInteraction();
1116
+ },
1117
+ [markUserViewportInteraction],
1118
+ );
1119
+
1120
+ const handleWheelZoom = useCallback(
1121
+ (event) => {
1122
+ if (spacePanMode) return;
1123
+ if (
1124
+ !Number.isFinite(wheelZoomSensitivity) ||
1125
+ wheelZoomSensitivity <= 0 ||
1126
+ isEditableTarget(event.target)
1127
+ ) {
1128
+ return;
1129
+ }
1130
+
1131
+ event.preventDefault();
1132
+ const bounds = containerRef.current?.getBoundingClientRect();
1133
+
1134
+ if (!bounds) return;
1135
+
1136
+ const delta = normalizeWheelDelta(
1137
+ event.deltaY,
1138
+ event.deltaMode,
1139
+ bounds.height,
1140
+ );
1141
+
1142
+ pendingWheelZoom.current = {
1143
+ delta: pendingWheelZoom.current.delta + delta,
1144
+ pointer: {
1145
+ x: event.clientX - bounds.left,
1146
+ y: event.clientY - bounds.top,
1147
+ },
1148
+ };
1149
+ markUserViewportInteraction();
1150
+
1151
+ if (wheelZoomFrameId.current) return;
1152
+
1153
+ wheelZoomFrameId.current = requestAnimationFrame(() => {
1154
+ const pendingZoom = pendingWheelZoom.current;
1155
+
1156
+ wheelZoomFrameId.current = undefined;
1157
+ pendingWheelZoom.current = { delta: 0, pointer: null };
1158
+
1159
+ if (!pendingZoom.pointer || !pendingZoom.delta) return;
1160
+
1161
+ const nextViewport = viewportForWheelZoom({
1162
+ viewport: getViewport(),
1163
+ pointer: pendingZoom.pointer,
1164
+ delta: pendingZoom.delta,
1165
+ sensitivity: wheelZoomSensitivity,
1166
+ minZoom,
1167
+ maxZoom: maxZoomLimit,
1168
+ });
1169
+
1170
+ setViewport(nextViewport, { duration: 0 });
1171
+ });
1172
+ },
1173
+ [
1174
+ containerRef,
1175
+ getViewport,
1176
+ markUserViewportInteraction,
1177
+ maxZoomLimit,
1178
+ minZoom,
1179
+ setViewport,
1180
+ wheelZoomSensitivity,
1181
+ spacePanMode,
1182
+ ],
1183
+ );
1184
+
1185
+ useEffect(() => {
1186
+ const graphContainer = containerRef.current;
1187
+
1188
+ if (
1189
+ !graphContainer ||
1190
+ !Number.isFinite(wheelZoomSensitivity) ||
1191
+ wheelZoomSensitivity <= 0
1192
+ ) {
1193
+ return undefined;
1194
+ }
1195
+
1196
+ graphContainer.addEventListener("wheel", handleWheelZoom, {
1197
+ capture: true,
1198
+ passive: false,
1199
+ });
1200
+
1201
+ return () => {
1202
+ graphContainer.removeEventListener("wheel", handleWheelZoom, {
1203
+ capture: true,
1204
+ });
1205
+ };
1206
+ }, [containerRef, handleWheelZoom, wheelZoomSensitivity]);
334
1207
 
1208
+ const getLayoutedElements = useCallback(
1209
+ ({ nodes: nodesToLayout, edges: edgesToLayout }) => {
335
1210
  if (!nodesToLayout.length) {
336
1211
  setNodes([]);
337
1212
  setEdges([]);
338
1213
  return;
339
1214
  }
340
1215
 
1216
+ if (fixedLayout) {
1217
+ setNodes((currentNodes) =>
1218
+ mergeFixedLayoutNodes(currentNodes, nodesToLayout),
1219
+ );
1220
+ setEdges(edgesToLayout);
1221
+ scheduleFitView(nodesToLayout, edgesToLayout);
1222
+ return;
1223
+ }
1224
+
341
1225
  const currentLayoutRunId = layoutRunId.current + 1;
342
1226
  layoutRunId.current = currentLayoutRunId;
343
1227
  const layoutOptions = {
@@ -345,11 +1229,9 @@ const LayoutFlow = ({
345
1229
  "elk.direction": "RIGHT",
346
1230
  "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
347
1231
  "elk.layered.crossingMinimization.forceNodeModelOrder": true,
348
- "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
349
- "elk.layered.spacing.nodeNodeBetweenLayers": 50,
350
- "elk.spacing.nodeNode": 32,
351
- "elk.spacing.edgeNodeBetweenLayers": 8,
352
- "elk.spacing.edgeEdgeBetweenLayers": 8,
1232
+ "elk.layered.spacing.nodeNodeBetweenLayers": 120,
1233
+ "elk.spacing.nodeNode": 70,
1234
+ "elk.padding": "[top=40,left=40,bottom=40,right=40]",
353
1235
  };
354
1236
  const graph = {
355
1237
  id: "root",
@@ -366,13 +1248,19 @@ const LayoutFlow = ({
366
1248
  .layout(graph)
367
1249
  .then(({ children = [] }) => {
368
1250
  if (currentLayoutRunId !== layoutRunId.current) return;
369
- children.forEach((node) => {
370
- // eslint-disable-next-line fp/no-mutation
371
- node.position = { x: node.x, y: node.y };
372
- });
1251
+ const positionedChildren = children.map((node) => ({
1252
+ ...node,
1253
+ position: { x: node.x, y: node.y },
1254
+ }));
1255
+
1256
+ const alignedChildren = alignNodesToConnectionPoints(
1257
+ positionedChildren,
1258
+ edgesToLayout,
1259
+ rootNodeId,
1260
+ );
373
1261
 
374
- setNodes(children);
375
- scheduleFitView(children, edgesToLayout);
1262
+ setNodes(alignedChildren);
1263
+ scheduleFitView(alignedChildren, edgesToLayout);
376
1264
  })
377
1265
  .catch(() => {
378
1266
  if (currentLayoutRunId !== layoutRunId.current) return;
@@ -382,7 +1270,7 @@ const LayoutFlow = ({
382
1270
  scheduleFitView(nodesToLayout, edgesToLayout);
383
1271
  });
384
1272
  },
385
- [containerRef, fitView, getNodes, rootNodeId, setEdges, setNodes],
1273
+ [fixedLayout, rootNodeId, scheduleFitView, setEdges, setNodes],
386
1274
  );
387
1275
 
388
1276
  const onInit = useCallback(() => {
@@ -392,24 +1280,197 @@ const LayoutFlow = ({
392
1280
  getLayoutedElements(layoutInput);
393
1281
  }, [getLayoutedElements, layoutInput, setEdges, setNodes]);
394
1282
 
1283
+ useEffect(() => {
1284
+ if (isInitialized) setMinimapZoom(getViewport().zoom);
1285
+ }, [getViewport, isInitialized]);
1286
+
1287
+ const suppressCanvasAction = useCallback((event) => {
1288
+ event?.preventDefault?.();
1289
+ event?.stopPropagation?.();
1290
+ }, []);
1291
+
1292
+ const handleNodeClick = useCallback(
1293
+ (event, node) => {
1294
+ if (spacePanMode || suppressSpaceInteraction.current) {
1295
+ suppressCanvasAction(event);
1296
+ suppressSpaceInteraction.current = false;
1297
+ return;
1298
+ }
1299
+
1300
+ onNodeClick && onNodeClick(event, node);
1301
+ },
1302
+ [onNodeClick, spacePanMode, suppressCanvasAction],
1303
+ );
1304
+
1305
+ const handleNodeDoubleClick = useCallback(
1306
+ (event, node) => {
1307
+ if (spacePanMode || suppressSpaceInteraction.current) {
1308
+ suppressCanvasAction(event);
1309
+ suppressSpaceInteraction.current = false;
1310
+ return;
1311
+ }
1312
+
1313
+ onNodeDoubleClick && onNodeDoubleClick(event, node);
1314
+ },
1315
+ [onNodeDoubleClick, spacePanMode, suppressCanvasAction],
1316
+ );
1317
+
1318
+ const handlePaneClick = useCallback(
1319
+ (event) => {
1320
+ if (spacePanMode || suppressSpaceInteraction.current) {
1321
+ suppressCanvasAction(event);
1322
+ suppressSpaceInteraction.current = false;
1323
+ return;
1324
+ }
1325
+
1326
+ onPaneClick && onPaneClick(event);
1327
+ },
1328
+ [onPaneClick, spacePanMode, suppressCanvasAction],
1329
+ );
1330
+
1331
+ useEffect(() => {
1332
+ const container = containerRef.current;
1333
+ if (!container) return undefined;
1334
+
1335
+ const handleMouseDown = () => {
1336
+ if (spacePanMode) {
1337
+ suppressSpaceInteraction.current = true;
1338
+ }
1339
+ };
1340
+
1341
+ const suppressAction = (event) => {
1342
+ if (!spacePanMode && !suppressSpaceInteraction.current) return;
1343
+ event.preventDefault();
1344
+ event.stopPropagation();
1345
+ if (event.type === "click") suppressSpaceInteraction.current = false;
1346
+ };
1347
+ container.addEventListener("mousedown", handleMouseDown, true);
1348
+ container.addEventListener("click", suppressAction, true);
1349
+ container.addEventListener("dblclick", suppressAction, true);
1350
+ container.addEventListener("contextmenu", suppressAction, true);
1351
+ return () => {
1352
+ container.removeEventListener("mousedown", handleMouseDown, true);
1353
+ container.removeEventListener("click", suppressAction, true);
1354
+ container.removeEventListener("dblclick", suppressAction, true);
1355
+ container.removeEventListener("contextmenu", suppressAction, true);
1356
+ };
1357
+ }, [containerRef, spacePanMode]);
1358
+
1359
+ useEffect(() => {
1360
+ const deactivateSpacePanMode = () => {
1361
+ setSpacePanMode(false);
1362
+ };
1363
+
1364
+ const handleKeyDown = (event) => {
1365
+ if (!isSpaceKeyEvent(event) || isEditableTarget(event.target)) return;
1366
+
1367
+ event.preventDefault();
1368
+ setSpacePanMode(true);
1369
+ };
1370
+
1371
+ const handleKeyUp = (event) => {
1372
+ if (!isSpaceKeyEvent(event)) return;
1373
+
1374
+ event.preventDefault();
1375
+ deactivateSpacePanMode();
1376
+ };
1377
+
1378
+ document.addEventListener("keydown", handleKeyDown);
1379
+ document.addEventListener("keyup", handleKeyUp);
1380
+ window.addEventListener("blur", deactivateSpacePanMode);
1381
+
1382
+ return () => {
1383
+ document.removeEventListener("keydown", handleKeyDown);
1384
+ document.removeEventListener("keyup", handleKeyUp);
1385
+ window.removeEventListener("blur", deactivateSpacePanMode);
1386
+ };
1387
+ }, []);
1388
+
395
1389
  useEffect(() => {
396
1390
  if (!isInitialized) return;
397
1391
 
398
- setNodes(layoutInput.nodes);
1392
+ if (fixedLayout) {
1393
+ setNodes((currentNodes) =>
1394
+ mergeFixedLayoutNodes(currentNodes, layoutInput.nodes),
1395
+ );
1396
+ } else {
1397
+ setNodes(layoutInput.nodes);
1398
+ }
399
1399
  setEdges(layoutInput.edges);
400
1400
  const animationFrameId = requestAnimationFrame(() => {
401
1401
  getLayoutedElements(layoutInput);
402
1402
  });
403
1403
 
404
1404
  return () => cancelAnimationFrame(animationFrameId);
405
- }, [isInitialized, layoutInput, setNodes, setEdges, getLayoutedElements]);
1405
+ }, [
1406
+ fixedLayout,
1407
+ isInitialized,
1408
+ layoutInput,
1409
+ setNodes,
1410
+ setEdges,
1411
+ getLayoutedElements,
1412
+ ]);
1413
+
1414
+ useEffect(() => {
1415
+ if (!isInitialized || !focusNodeId) return undefined;
1416
+
1417
+ const focusNode = () => {
1418
+ const targetNode = getNodes().find((node) => node.id === focusNodeId);
1419
+
1420
+ if (!targetNode) return;
1421
+
1422
+ const focusCenter = nodeCenterForViewport(targetNode);
1423
+ const focusZoom = getFocusZoomForNode(
1424
+ targetNode,
1425
+ containerRef.current?.clientWidth,
1426
+ containerRef.current?.clientHeight,
1427
+ minZoom,
1428
+ getViewport().zoom,
1429
+ );
1430
+
1431
+ userOwnsViewport.current = true;
1432
+ setCenter(focusCenter.x, focusCenter.y, {
1433
+ zoom: focusZoom,
1434
+ duration: 300,
1435
+ });
1436
+ };
1437
+ const firstFrameId = requestAnimationFrame(() => {
1438
+ const secondFrameId = requestAnimationFrame(focusNode);
1439
+
1440
+ focusFrameIds.current = [firstFrameId, secondFrameId];
1441
+ });
1442
+
1443
+ focusFrameIds.current = [firstFrameId];
1444
+
1445
+ return () => {
1446
+ focusFrameIds.current.forEach(cancelAnimationFrame);
1447
+ focusFrameIds.current = [];
1448
+ };
1449
+ }, [
1450
+ containerRef,
1451
+ focusNodeId,
1452
+ focusNodeRequestKey,
1453
+ getNodes,
1454
+ getViewport,
1455
+ isInitialized,
1456
+ minZoom,
1457
+ setCenter,
1458
+ ]);
406
1459
 
407
1460
  useEffect(
408
1461
  () => () => {
409
1462
  if (fitViewFrameId.current) {
410
1463
  cancelAnimationFrame(fitViewFrameId.current);
411
1464
  }
1465
+ if (wheelZoomFrameId.current) {
1466
+ cancelAnimationFrame(wheelZoomFrameId.current);
1467
+ }
1468
+ if (minimapZoomFrameId.current) {
1469
+ cancelAnimationFrame(minimapZoomFrameId.current);
1470
+ }
1471
+ focusFrameIds.current.forEach(cancelAnimationFrame);
412
1472
  layoutRunId.current += 1;
1473
+ fitRunId.current += 1;
413
1474
  },
414
1475
  [],
415
1476
  );
@@ -421,12 +1482,64 @@ const LayoutFlow = ({
421
1482
  onNodesChange={onNodesChange}
422
1483
  onEdgesChange={onEdgesChange}
423
1484
  onInit={onInit}
424
- onNodeClick={onNodeClick}
1485
+ onNodeClick={handleNodeClick}
1486
+ onNodeDoubleClick={handleNodeDoubleClick}
425
1487
  nodesConnectable={false}
1488
+ nodesDraggable={!spacePanMode}
1489
+ nodesFocusable={!spacePanMode}
1490
+ elementsSelectable={!spacePanMode}
1491
+ onlyRenderVisibleElements={onlyRenderVisibleElements}
1492
+ onNodeDragStop={fixedLayout ? onFixedNodeDragStop : undefined}
1493
+ panOnDrag
1494
+ onMoveStart={handleMoveStart}
1495
+ onMove={(_event, viewport) => {
1496
+ pendingMinimapZoom.current = viewport.zoom;
1497
+ if (minimapZoomFrameId.current) return;
1498
+
1499
+ minimapZoomFrameId.current = requestAnimationFrame(() => {
1500
+ minimapZoomFrameId.current = undefined;
1501
+ setMinimapZoom((currentZoom) =>
1502
+ Math.abs(currentZoom - pendingMinimapZoom.current) < 0.01
1503
+ ? currentZoom
1504
+ : pendingMinimapZoom.current,
1505
+ );
1506
+ });
1507
+ }}
1508
+ {...(wheelZoomSensitivity
1509
+ ? {
1510
+ zoomOnScroll: false,
1511
+ }
1512
+ : {})}
1513
+ {...(fixedLayout
1514
+ ? {
1515
+ zoomOnDoubleClick: false,
1516
+ edgesFocusable: false,
1517
+ edgesReconnectable: false,
1518
+ elevateNodesOnSelect: false,
1519
+ elevateEdgesOnSelect: false,
1520
+ }
1521
+ : {})}
1522
+ {...(spacePanMode
1523
+ ? {
1524
+ zoomOnScroll: false,
1525
+ zoomOnPinch: false,
1526
+ zoomOnDoubleClick: false,
1527
+ selectionOnDrag: false,
1528
+ selectionKeyCode: null,
1529
+ multiSelectionKeyCode: null,
1530
+ deleteKeyCode: null,
1531
+ panOnScroll: true,
1532
+ }
1533
+ : {})}
1534
+ translateExtent={translateExtent}
426
1535
  nodeTypes={nodeTypes}
427
1536
  edgeTypes={edgeTypes}
428
1537
  attributionPosition="bottom-left"
429
- minZoom={DEFAULT_MIN_ZOOM}
1538
+ proOptions={{ hideAttribution: true }}
1539
+ minZoom={minZoom}
1540
+ maxZoom={maxZoomLimit}
1541
+ onPaneClick={handlePaneClick}
1542
+ className={spacePanMode ? "td-graph__space-pan-active" : undefined}
430
1543
  >
431
1544
  <Background
432
1545
  variant={BackgroundVariant.Dots}
@@ -435,73 +1548,80 @@ const LayoutFlow = ({
435
1548
  color="var(--td-graph-node-border, #d0d5e0)"
436
1549
  />
437
1550
  <Controls
1551
+ className={`td-graph-controls td-graph-controls--${controlsOrientation}`}
438
1552
  position="top-right"
439
- orientation="horizontal"
1553
+ orientation={controlsOrientation}
440
1554
  showZoom={false}
441
1555
  showFitView={false}
442
1556
  showInteractive={false}
443
1557
  >
1558
+ {topControls}
444
1559
  <GraphControlTooltip
445
1560
  content={formatMessage({ id: "graph.controls.zoom_in" })}
446
1561
  >
447
1562
  <ControlButton
448
- onClick={() => zoomIn({ duration: 200 })}
1563
+ onClick={() => {
1564
+ markUserViewportInteraction();
1565
+ zoomIn({ duration: 200 });
1566
+ }}
449
1567
  aria-label={formatMessage({ id: "graph.controls.zoom_in" })}
450
1568
  >
451
- <svg
452
- viewBox="0 0 24 24"
453
- fill="none"
454
- stroke="currentColor"
455
- strokeWidth="2"
456
- strokeLinecap="round"
457
- >
458
- <line x1="12" y1="5" x2="12" y2="19" />
459
- <line x1="5" y1="12" x2="19" y2="12" />
460
- </svg>
1569
+ <ZoomIn size={20} strokeWidth={2} />
461
1570
  </ControlButton>
462
1571
  </GraphControlTooltip>
463
1572
  <GraphControlTooltip
464
1573
  content={formatMessage({ id: "graph.controls.zoom_out" })}
465
1574
  >
466
1575
  <ControlButton
467
- onClick={() => zoomOut({ duration: 200 })}
1576
+ onClick={() => {
1577
+ markUserViewportInteraction();
1578
+ zoomOut({ duration: 200 });
1579
+ }}
468
1580
  aria-label={formatMessage({ id: "graph.controls.zoom_out" })}
469
1581
  >
470
- <svg
471
- viewBox="0 0 24 24"
472
- fill="none"
473
- stroke="currentColor"
474
- strokeWidth="2"
475
- strokeLinecap="round"
476
- >
477
- <line x1="5" y1="12" x2="19" y2="12" />
478
- </svg>
1582
+ <ZoomOut size={20} strokeWidth={2} />
479
1583
  </ControlButton>
480
1584
  </GraphControlTooltip>
481
1585
  <GraphControlTooltip
482
1586
  content={formatMessage({ id: "graph.controls.fit_view" })}
483
1587
  >
484
1588
  <ControlButton
485
- onClick={() =>
486
- fitView({ padding: FIT_VIEW_PADDING, duration: 300, minZoom })
487
- }
1589
+ onClick={() => {
1590
+ userOwnsViewport.current = false;
1591
+ fitView({
1592
+ padding: resolvedFitViewPadding,
1593
+ duration: 300,
1594
+ minZoom,
1595
+ });
1596
+ }}
488
1597
  aria-label={formatMessage({ id: "graph.controls.fit_view" })}
489
1598
  >
490
- <svg
491
- viewBox="0 0 24 24"
492
- fill="none"
493
- stroke="currentColor"
494
- strokeWidth="2"
495
- strokeLinecap="round"
496
- strokeLinejoin="round"
497
- >
498
- <path d="M8 3H5a2 2 0 0 0-2 2v3" />
499
- <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
500
- <path d="M3 16v3a2 2 0 0 0 2 2h3" />
501
- <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
502
- </svg>
1599
+ {fixedLayout ? (
1600
+ <Focus size={20} strokeWidth={2} />
1601
+ ) : (
1602
+ <Maximize size={20} strokeWidth={2} />
1603
+ )}
503
1604
  </ControlButton>
504
1605
  </GraphControlTooltip>
1606
+ {onCloseExpanded ? (
1607
+ <GraphControlTooltip
1608
+ content={formatMessage({
1609
+ id: "graph.controls.return_normal",
1610
+ defaultMessage: "Volver a vista normal",
1611
+ })}
1612
+ >
1613
+ <ControlButton
1614
+ className="td-graph-controls-return"
1615
+ onClick={onCloseExpanded}
1616
+ aria-label={formatMessage({
1617
+ id: "graph.controls.return_normal",
1618
+ defaultMessage: "Volver a vista normal",
1619
+ })}
1620
+ >
1621
+ <Minimize2 size={20} strokeWidth={2} />
1622
+ </ControlButton>
1623
+ </GraphControlTooltip>
1624
+ ) : null}
505
1625
  {onOpenExpanded ? (
506
1626
  <GraphControlTooltip
507
1627
  content={formatMessage({ id: "graph.controls.open_expanded" })}
@@ -510,23 +1630,90 @@ const LayoutFlow = ({
510
1630
  onClick={onOpenExpanded}
511
1631
  aria-label={formatMessage({ id: "graph.controls.open_expanded" })}
512
1632
  >
513
- <svg
514
- viewBox="0 0 24 24"
515
- fill="none"
516
- stroke="currentColor"
517
- strokeWidth="2"
518
- strokeLinecap="round"
519
- strokeLinejoin="round"
520
- >
521
- <path d="M15 3h6v6" />
522
- <path d="M9 21H3v-6" />
523
- <path d="M21 3l-7 7" />
524
- <path d="M3 21l7-7" />
525
- </svg>
1633
+ <Expand size={20} strokeWidth={2} />
526
1634
  </ControlButton>
527
1635
  </GraphControlTooltip>
528
1636
  ) : null}
1637
+ {afterControls}
529
1638
  </Controls>
1639
+ {minimapNodeColor && (
1640
+ <div
1641
+ className={`td-graph-minimap-shell${
1642
+ isMinimapCollapsed ? " td-graph-minimap-shell--collapsed" : ""
1643
+ }${isMinimapExpanded ? " td-graph-minimap-shell--expanded" : ""}`}
1644
+ >
1645
+ <div className="td-graph-minimap-header">
1646
+ <span className="td-graph-minimap-title">
1647
+ <Icon name="map outline" aria-hidden="true" />
1648
+ Mapa
1649
+ </span>
1650
+ <span className="td-graph-minimap-zoom">
1651
+ {Math.round(minimapZoom * 100)}%
1652
+ </span>
1653
+ <button
1654
+ aria-expanded={!isMinimapCollapsed}
1655
+ aria-label={
1656
+ isMinimapCollapsed
1657
+ ? "Expandir mini-mapa"
1658
+ : "Minimizar mini-mapa"
1659
+ }
1660
+ className="td-graph-minimap-toggle"
1661
+ onClick={(event) => {
1662
+ event.stopPropagation();
1663
+ setIsMinimapCollapsed((collapsed) => !collapsed);
1664
+ }}
1665
+ onMouseDown={(event) => event.stopPropagation()}
1666
+ type="button"
1667
+ >
1668
+ <Icon
1669
+ name={isMinimapCollapsed ? "chevron up" : "chevron down"}
1670
+ aria-hidden="true"
1671
+ />
1672
+ </button>
1673
+ </div>
1674
+ {!isMinimapCollapsed ? (
1675
+ <div className="td-graph-minimap-body">
1676
+ <MiniMap
1677
+ position="bottom-left"
1678
+ pannable
1679
+ zoomable
1680
+ maskColor="rgba(15, 23, 42, 0.08)"
1681
+ nodeColor={minimapNodeColor}
1682
+ nodeStrokeColor="rgba(15, 23, 42, 0.12)"
1683
+ className="td-graph-minimap"
1684
+ />
1685
+ <button
1686
+ type="button"
1687
+ className="td-graph-minimap-expand"
1688
+ aria-label={
1689
+ isMinimapExpanded ? "Reducir mini-mapa" : "Expandir mini-mapa"
1690
+ }
1691
+ onClick={(event) => {
1692
+ event.stopPropagation();
1693
+ setIsMinimapExpanded((prev) => !prev);
1694
+ }}
1695
+ onMouseDown={(event) => event.stopPropagation()}
1696
+ >
1697
+ {isMinimapExpanded ? (
1698
+ <Minimize2 size={12} strokeWidth={2} />
1699
+ ) : (
1700
+ <Expand size={12} strokeWidth={2} />
1701
+ )}
1702
+ </button>
1703
+ </div>
1704
+ ) : null}
1705
+ </div>
1706
+ )}
1707
+ {bottomControls && (
1708
+ <Controls
1709
+ position="bottom-right"
1710
+ showZoom={false}
1711
+ showFitView={false}
1712
+ showInteractive={false}
1713
+ >
1714
+ {bottomControls}
1715
+ </Controls>
1716
+ )}
530
1717
  </ReactFlow>
531
1718
  );
532
1719
  };
@@ -535,24 +1722,41 @@ LayoutFlow.propTypes = {
535
1722
  initialNodes: PropTypes.array,
536
1723
  initialEdges: PropTypes.array,
537
1724
  onNodeClick: PropTypes.func,
1725
+ onNodeDoubleClick: PropTypes.func,
538
1726
  onOpenExpanded: PropTypes.func,
1727
+ onCloseExpanded: PropTypes.func,
539
1728
  containerRef: PropTypes.object,
540
1729
  rootNodeId: PropTypes.string,
1730
+ onPaneClick: PropTypes.func,
1731
+ topControls: PropTypes.node,
1732
+ afterControls: PropTypes.node,
1733
+ bottomControls: PropTypes.node,
1734
+ onInitialFitComplete: PropTypes.func,
1735
+ graphOptions: PropTypes.object,
541
1736
  };
542
1737
 
543
1738
  const GraphCanvas = ({
544
1739
  nodes,
545
1740
  edges,
546
1741
  onNodeClick,
1742
+ onNodeDoubleClick,
547
1743
  height,
548
1744
  onOpenExpanded,
1745
+ onCloseExpanded,
549
1746
  rootNodeId,
1747
+ onPaneClick,
1748
+ topControls,
1749
+ afterControls,
1750
+ bottomControls,
1751
+ onInitialFitComplete,
1752
+ className,
1753
+ graphOptions,
550
1754
  }) => {
551
1755
  const containerRef = useRef(null);
552
1756
 
553
1757
  return (
554
1758
  <div
555
- className="td-graph-container"
1759
+ className={["td-graph-container", className].filter(Boolean).join(" ")}
556
1760
  style={{ "--td-graph-container-height": height }}
557
1761
  ref={containerRef}
558
1762
  >
@@ -561,9 +1765,17 @@ const GraphCanvas = ({
561
1765
  initialNodes={nodes}
562
1766
  initialEdges={edges}
563
1767
  onNodeClick={onNodeClick}
1768
+ onNodeDoubleClick={onNodeDoubleClick}
564
1769
  onOpenExpanded={onOpenExpanded}
1770
+ onCloseExpanded={onCloseExpanded}
565
1771
  containerRef={containerRef}
566
1772
  rootNodeId={rootNodeId}
1773
+ onPaneClick={onPaneClick}
1774
+ topControls={topControls}
1775
+ afterControls={afterControls}
1776
+ bottomControls={bottomControls}
1777
+ onInitialFitComplete={onInitialFitComplete}
1778
+ graphOptions={graphOptions}
567
1779
  />
568
1780
  </ReactFlowProvider>
569
1781
  </div>
@@ -574,19 +1786,36 @@ GraphCanvas.propTypes = {
574
1786
  nodes: PropTypes.array,
575
1787
  edges: PropTypes.array,
576
1788
  onNodeClick: PropTypes.func,
1789
+ onNodeDoubleClick: PropTypes.func,
577
1790
  height: PropTypes.string,
578
1791
  onOpenExpanded: PropTypes.func,
1792
+ onCloseExpanded: PropTypes.func,
579
1793
  rootNodeId: PropTypes.string,
1794
+ onPaneClick: PropTypes.func,
1795
+ topControls: PropTypes.node,
1796
+ afterControls: PropTypes.node,
1797
+ bottomControls: PropTypes.node,
1798
+ onInitialFitComplete: PropTypes.func,
1799
+ className: PropTypes.string,
1800
+ graphOptions: PropTypes.object,
580
1801
  };
581
1802
 
582
1803
  export const Graph = ({
583
1804
  nodes,
584
1805
  edges,
585
1806
  onNodeClick,
1807
+ onNodeDoubleClick,
586
1808
  allowExpandedView = true,
1809
+ height = DEFAULT_GRAPH_HEIGHT,
587
1810
  rootNodeId,
1811
+ onPaneClick,
1812
+ topControls,
1813
+ afterControls,
1814
+ bottomControls,
1815
+ onInitialFitComplete,
1816
+ canvasClassName,
1817
+ graphOptions,
588
1818
  }) => {
589
- const { formatMessage } = useIntl();
590
1819
  const [expandedOpen, setExpandedOpen] = useState(false);
591
1820
 
592
1821
  const openExpanded = useCallback(() => setExpandedOpen(true), []);
@@ -598,9 +1827,17 @@ export const Graph = ({
598
1827
  nodes={nodes}
599
1828
  edges={edges}
600
1829
  onNodeClick={onNodeClick}
601
- height={DEFAULT_GRAPH_HEIGHT}
1830
+ onNodeDoubleClick={onNodeDoubleClick}
1831
+ height={height}
602
1832
  onOpenExpanded={allowExpandedView ? openExpanded : undefined}
603
1833
  rootNodeId={rootNodeId}
1834
+ onPaneClick={onPaneClick}
1835
+ topControls={topControls}
1836
+ afterControls={afterControls}
1837
+ bottomControls={bottomControls}
1838
+ onInitialFitComplete={onInitialFitComplete}
1839
+ className={canvasClassName}
1840
+ graphOptions={graphOptions}
604
1841
  />
605
1842
  {allowExpandedView ? (
606
1843
  <Modal
@@ -610,18 +1847,24 @@ export const Graph = ({
610
1847
  size="fullscreen"
611
1848
  closeOnDimmerClick
612
1849
  closeOnEscape
613
- className="td-graph-modal"
1850
+ className="td-graph-modal td-graph-modal--expanded"
614
1851
  >
615
- <Modal.Header>
616
- {formatMessage({ id: "graph.expanded.header" })}
617
- </Modal.Header>
618
1852
  <Modal.Content>
619
1853
  <GraphCanvas
620
1854
  nodes={nodes}
621
1855
  edges={edges}
622
1856
  onNodeClick={onNodeClick}
1857
+ onNodeDoubleClick={onNodeDoubleClick}
1858
+ onCloseExpanded={closeExpanded}
623
1859
  height={EXPANDED_GRAPH_HEIGHT}
624
1860
  rootNodeId={rootNodeId}
1861
+ onPaneClick={onPaneClick}
1862
+ topControls={topControls}
1863
+ afterControls={afterControls}
1864
+ bottomControls={bottomControls}
1865
+ onInitialFitComplete={onInitialFitComplete}
1866
+ className={canvasClassName}
1867
+ graphOptions={graphOptions}
625
1868
  />
626
1869
  </Modal.Content>
627
1870
  </Modal>
@@ -634,8 +1877,17 @@ Graph.propTypes = {
634
1877
  nodes: PropTypes.array,
635
1878
  edges: PropTypes.array,
636
1879
  onNodeClick: PropTypes.func,
1880
+ onNodeDoubleClick: PropTypes.func,
637
1881
  allowExpandedView: PropTypes.bool,
1882
+ height: PropTypes.string,
638
1883
  rootNodeId: PropTypes.string,
1884
+ onPaneClick: PropTypes.func,
1885
+ topControls: PropTypes.node,
1886
+ afterControls: PropTypes.node,
1887
+ bottomControls: PropTypes.node,
1888
+ onInitialFitComplete: PropTypes.func,
1889
+ canvasClassName: PropTypes.string,
1890
+ graphOptions: PropTypes.object,
639
1891
  };
640
1892
 
641
1893
  export default Graph;