@cyber-harbour/ui 1.0.49 → 1.0.51

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,5 +1,14 @@
1
- import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo } from 'react';
2
- import { Graph2DProps, LinkObject, NodeObject, Graph2DRef } from './types';
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ forwardRef,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useLayoutEffect,
10
+ } from 'react';
11
+ import { Graph2DProps, LinkObject, NodeObject, Graph2DRef, GraphState } from './types';
3
12
 
4
13
  import cloneDeep from 'lodash.clonedeep';
5
14
 
@@ -19,6 +28,7 @@ import {
19
28
  import { styled, useTheme } from 'styled-components';
20
29
  import GraphLoader from './GraphLoader';
21
30
 
31
+ const RATIO = window.devicePixelRatio || 1;
22
32
  // Завантаження та підготовка зображень кнопок
23
33
  function prepareButtonImages(buttons: Graph2DProps['buttons']) {
24
34
  if (!buttons || buttons.length === 0) return [];
@@ -50,33 +60,51 @@ const config = {
50
60
  };
51
61
 
52
62
  export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
53
- ({ loading, width, height, graphData, buttons = [], onNodeClick, onBackgroundClick, onNodeHover }, ref) => {
63
+ (
64
+ {
65
+ loading,
66
+ width,
67
+ height,
68
+ graphData,
69
+ buttons = [],
70
+ onNodeClick,
71
+ onBackgroundClick,
72
+ onNodeHover,
73
+ onLinkHover,
74
+ onLinkClick,
75
+ },
76
+ ref
77
+ ) => {
54
78
  const theme = useTheme();
55
79
  const [isRendering, setIsRendering] = useState(true);
56
- const [hoveredNode, setHoveredNode] = useState<NodeObject | null>(null);
57
- const [draggedNode, setDraggedNode] = useState<NodeObject | null>(null);
58
- const [selectedNode, setSelectedNode] = useState<NodeObject | null>(null);
80
+
81
+ const stateRef = useRef<GraphState>({
82
+ transform: { x: 0, y: 0, k: 1 }, // x, y для переміщення, k для масштабу
83
+ isPanning: false,
84
+ hoveredNode: null,
85
+ hoveredLink: null,
86
+ draggedNode: null,
87
+ selectedNode: null,
88
+ hoveredButtonIndex: null,
89
+ highlightNodes: new Set(),
90
+ highlightLinks: new Set(),
91
+ lastMousePos: { x: 0, y: 0 },
92
+ mustBeStoppedPropagation: false,
93
+ lastHoveredNode: null,
94
+ mouseStartPos: null,
95
+ isDragging: false,
96
+ lastHoveredNodeRef: null,
97
+ width: width * RATIO,
98
+ height: height * RATIO,
99
+ });
59
100
 
60
101
  const { nodes, links } = useMemo(() => cloneDeep(graphData), [graphData]);
61
102
 
62
103
  // Стани кнопок
63
- const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null);
64
104
  const [buttonImages, setButtonImages] = useState<any[]>([]);
65
105
 
66
- // Стани виділення
67
- const [highlightNodes, setHighlightNodes] = useState(new Set());
68
- const [highlightLinks, setHighlightLinks] = useState(new Set());
69
-
70
- // Стан трансформації для масштабування та панорамування
71
- const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); // x, y для переміщення, k для масштабу
72
- const [isPanning, setIsPanning] = useState(false);
73
- const lastMousePosRef = useRef({ x: 0, y: 0 });
74
- const mustBeStoppedPropagation = useRef(false);
75
-
76
- // Використання canvas замість SVG для кращої продуктивності
77
106
  const canvasRef = useRef<HTMLCanvasElement>(null);
78
107
  const simulationRef = useRef<Simulation<NodeObject, LinkObject> | null>(null);
79
- const lastHoveredNodeRef = useRef<NodeObject | null>(null);
80
108
 
81
109
  // Контекст Canvas для 2D рендерингу
82
110
  const ctx2dRef = useRef<CanvasRenderingContext2D | null>(null);
@@ -124,14 +152,14 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
124
152
  ctx.setTransform(1, 0, 0, 1, 0, 0);
125
153
 
126
154
  // Малюємо фонові крапки
127
- const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
155
+ const { width: canvasWidth, height: canvasHeight } = stateRef.current;
128
156
  const gridSpacing = config.gridSpacing;
129
157
  const dotSize = config.dotSize;
130
158
 
131
159
  ctx.fillStyle = theme.graph2D.grid.dotColor;
132
160
 
133
- for (let x = 0; x < canvasWidth; x += gridSpacing) {
134
- for (let y = 0; y < canvasHeight; y += gridSpacing) {
161
+ for (let x = gridSpacing / 2; x <= canvasWidth; x += gridSpacing) {
162
+ for (let y = gridSpacing / 2; y <= canvasHeight; y += gridSpacing) {
135
163
  ctx.beginPath();
136
164
  ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
137
165
  ctx.fill();
@@ -145,7 +173,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
145
173
  );
146
174
 
147
175
  // Функція для обрізання тексту з додаванням трикрапки
148
- const truncateText = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
176
+ const truncateText = useCallback((text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
149
177
  if (!text) return '';
150
178
 
151
179
  // Вимірюємо ширину тексту
@@ -164,7 +192,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
164
192
  }
165
193
 
166
194
  return truncated + ellipsis;
167
- };
195
+ }, []);
168
196
 
169
197
  // Розрахунок розміру шрифту на основі масштабу/зуму
170
198
  const calculateFontSize = (scale: number): number => {
@@ -227,7 +255,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
227
255
  };
228
256
 
229
257
  // Колір лінії залежить від стану виділення
230
- const isHighlighted = highlightLinks.has(link);
258
+ const isHighlighted = stateRef.current.highlightLinks.has(link);
231
259
  const lineColor = isHighlighted ? theme.graph2D.link.highlighted : theme.graph2D.link.normal;
232
260
  const lineWidth = isHighlighted ? 1.5 : 0.5;
233
261
 
@@ -239,7 +267,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
239
267
  // Малюємо лінію у дві частини, якщо є мітка
240
268
  if (link.label) {
241
269
  // Обчислюємо ширину тексту для проміжку
242
- const globalScale = transform.k;
270
+ const globalScale = stateRef.current.transform.k;
243
271
  const scaledFontSize = calculateFontSize(globalScale);
244
272
  ctx.font = `${scaledFontSize}px Sans-Serif`;
245
273
  const textWidth = ctx.measureText(link.label).width;
@@ -305,10 +333,10 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
305
333
  // Малюємо мітку, якщо вона є
306
334
  if (link.label) {
307
335
  // Ми вже обчислили ці значення раніше
308
- // const middleX = start.x + (end.x - start.x) / 2;
309
- // const middleY = start.y + (end.y - start.y) / 2;
336
+ // const середнєX = start.x + (end.x - start.x) / 2;
337
+ // const середнєY = start.y + (end.y - start.y) / 2;
310
338
 
311
- const globalScale = transform.k; // Використовуємо поточний рівень масштабування
339
+ const globalScale = stateRef.current.transform.k; // Використовуємо поточний рівень масштабування
312
340
  const scaledFontSize = calculateFontSize(globalScale);
313
341
 
314
342
  ctx.font = `${scaledFontSize}px Sans-Serif`;
@@ -336,7 +364,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
336
364
  }
337
365
  });
338
366
  },
339
- [config, transform.k, highlightLinks, theme.graph2D.link]
367
+ [config, theme.graph2D.link]
340
368
  );
341
369
 
342
370
  // Функція для рендерингу кнопок навколо вузла
@@ -359,7 +387,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
359
387
  for (let i = 0; i < buttonCount; i++) {
360
388
  const startAngle = i * sectorAngle;
361
389
  const endAngle = (i + 1) * sectorAngle;
362
- const isHovered = hoveredButtonIndex === i;
390
+ const isHovered = stateRef.current.hoveredButtonIndex === i;
363
391
 
364
392
  // Малюємо фон сектора кнопки
365
393
  ctx.beginPath();
@@ -394,7 +422,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
394
422
  console.warn('Error rendering button icon:', error);
395
423
  }
396
424
  } else {
397
- // Set onload handler if image isn't loaded yet
425
+ // Встановлюємо обробник onload, якщо зображення ще не завантажено
398
426
  icon.onload = () => {
399
427
  if (ctx2dRef.current) {
400
428
  try {
@@ -409,7 +437,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
409
437
 
410
438
  ctx.restore();
411
439
  },
412
- [buttonImages, hoveredButtonIndex, config, theme.graph2D?.button]
440
+ [buttonImages, theme.graph2D?.button]
413
441
  );
414
442
 
415
443
  const renderNodes = useCallback(
@@ -418,17 +446,20 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
418
446
  if (!nodes || nodes.length === 0) return;
419
447
 
420
448
  ctx.globalAlpha = 1.0;
421
- // Draw all nodes
449
+ // Малюємо всі вузли
422
450
  nodes.forEach((node) => {
423
451
  const { x, y, color: nodeColor, fontColor, label } = node;
424
- const isHighlighted = highlightNodes.has(node) || node === hoveredNode || node === draggedNode;
425
- const isSelected = node === selectedNode;
452
+ const isHighlighted =
453
+ stateRef.current.highlightNodes.has(node) ||
454
+ node === stateRef.current.hoveredNode ||
455
+ node === stateRef.current.draggedNode;
456
+ const isSelected = node === stateRef.current.selectedNode;
426
457
 
427
- // Node size and position
458
+ // Розмір та позиція вузла
428
459
  const size = config.nodeSizeBase;
429
460
  const radius = isSelected ? config.nodeSizeBase / 2 : config.nodeSizeBase / 2;
430
461
 
431
- // If node is highlighted, draw highlight ring
462
+ // Якщо вузол виділено, малюємо кільце підсвічування
432
463
  if (isHighlighted && !isSelected) {
433
464
  const ringRadius = (config.nodeSizeBase * config.nodeAreaFactor * 0.75) / 2;
434
465
 
@@ -438,9 +469,9 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
438
469
  ctx.fill();
439
470
  }
440
471
 
441
- // If node is selected, draw selection ring and buttons
472
+ // Якщо вузол обрано, малюємо кільце вибору та кнопки
442
473
  if (isSelected) {
443
- // Draw buttons around selected node if buttons are available
474
+ // Малюємо кнопки навколо обраного вузла, якщо кнопки доступні
444
475
  if (buttons && buttons.length > 0) {
445
476
  renderNodeButtons(node, ctx);
446
477
  } else {
@@ -453,18 +484,18 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
453
484
  }
454
485
  }
455
486
 
456
- // Draw the node circle
487
+ // Малюємо коло вузла
457
488
  ctx.beginPath();
458
489
  ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
459
490
  ctx.fillStyle = nodeColor || color(node.group || '0');
460
491
  ctx.fill();
461
492
 
462
- // Draw label if available
493
+ // Малюємо мітку, якщо вона доступна
463
494
  if (label) {
464
495
  ctx.save();
465
496
  ctx.translate(x as number, y as number);
466
497
 
467
- const globalScale = transform.k;
498
+ const globalScale = stateRef.current.transform.k;
468
499
  const scaledFontSize = calculateFontSize(globalScale);
469
500
  const maxWidth = size * config.textPaddingFactor;
470
501
 
@@ -480,18 +511,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
480
511
  }
481
512
  });
482
513
  },
483
- [
484
- config,
485
- color,
486
- hoveredNode,
487
- draggedNode,
488
- selectedNode,
489
- highlightNodes,
490
- transform.k,
491
- theme.graph2D.ring,
492
- buttons,
493
- renderNodeButtons,
494
- ]
514
+ [theme.graph2D.ring, buttons, renderNodeButtons]
495
515
  );
496
516
 
497
517
  // 2D Canvas rendering for everything
@@ -499,34 +519,40 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
499
519
  const ctx = ctx2dRef.current;
500
520
  if (!ctx) return;
501
521
 
502
- // Get device pixel ratio for correct rendering
503
- const pixelRatio = window.devicePixelRatio || 1;
522
+ // Отримуємо відношення пікселів пристрою для коректного рендерингу
504
523
 
505
524
  // Очищуємо весь канвас перед новим рендерингом.
506
- ctx.clearRect(0, 0, width * pixelRatio, height * pixelRatio);
525
+ ctx.clearRect(0, 0, stateRef.current.width, stateRef.current.height);
507
526
 
508
- // Render grid first (background)
527
+ // Спочатку відображаємо сітку (фон)
509
528
  renderGrid(ctx);
510
529
 
511
- // Apply transformation (zoom and pan) - use matrix transformation for better performance
530
+ // Застосовуємо трансформацію (масштабування та панорамування) - використовуємо матричну трансформацію для кращої продуктивності
512
531
  ctx.save();
513
532
 
514
- // First translate to the pan position, then scale around that point
515
- ctx.setTransform(transform.k, 0, 0, transform.k, transform.x, transform.y);
516
-
517
- // Render links and nodes
533
+ // Спочатку переміщуємо до позиції панорамування, потім масштабуємо навколо цієї точки
534
+ ctx.setTransform(
535
+ stateRef.current.transform.k,
536
+ 0,
537
+ 0,
538
+ stateRef.current.transform.k,
539
+ stateRef.current.transform.x,
540
+ stateRef.current.transform.y
541
+ );
542
+
543
+ // Відображаємо зв'язки та вузли
518
544
  renderLinks(ctx);
519
545
  renderNodes(ctx);
520
546
 
521
- // Restore context
547
+ // Відновлюємо контекст
522
548
  ctx.restore();
523
- }, [width, height, renderLinks, renderNodes, renderGrid, transform]);
549
+ }, [renderLinks, renderNodes, renderGrid]);
524
550
 
525
551
  /**
526
- * Function to add new nodes to the graph with optional smooth appearance animation
527
- * @param newNodes The new nodes to add to the graph
528
- * @param newLinks Optional new links to add with the nodes
529
- * @param options Configuration options for the node addition
552
+ * Функція для додавання нових вузлів до графа з опціональною плавною анімацією появи
553
+ * @param newNodes Нові вузли для додавання до графа
554
+ * @param newLinks Опціональні нові зв'язки для додавання з вузлами
555
+ * @param options Параметри конфігурації для додавання вузлів
530
556
  */
531
557
  const addNodes = useCallback(
532
558
  (
@@ -541,15 +567,15 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
541
567
  const nodes = getNodes() || [];
542
568
  if (!simulationRef.current || !newNodes.length) return;
543
569
 
544
- // Опції по умолчанню
570
+ const { width: canvasWidth, height: canvasHeight } = stateRef.current; // Опції по умолчанню
545
571
  const smoothAppearance = options?.smoothAppearance ?? false;
546
572
  const transitionDuration = options?.transitionDuration ?? 1000; // 1 секунда по умолчанню
547
573
 
548
- // Process the new nodes to avoid duplicates
574
+ // Обробляємо нові вузли, щоб уникнути дублікатів
549
575
  const existingNodeIds = new Set(nodes.map((node) => node.id));
550
576
  const filteredNewNodes = newNodes.filter((node) => !existingNodeIds.has(node.id));
551
577
 
552
- // Process the new links to avoid duplicates and ensure they reference valid nodes
578
+ // Обробляємо нові зв'язки, щоб уникнути дублікатів та переконатися, що вони посилаються на дійсні вузли
553
579
  const existingLinkIds = new Set(
554
580
  links.map(
555
581
  (link) =>
@@ -568,15 +594,14 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
568
594
 
569
595
  if (filteredNewNodes.length === 0 && filteredNewLinks.length === 0) return;
570
596
 
571
- // Update graphData with new nodes and links
572
597
  const updatedNodes = [...nodes, ...filteredNewNodes];
573
598
  const updatedLinks = [...links, ...filteredNewLinks];
574
599
 
575
- // Pre-position new nodes only when smooth appearance is enabled
600
+ // Попереднє позиціонування нових вузлів тільки коли плавна поява увімкнена
576
601
  if (smoothAppearance) {
577
- // Pre-position new nodes near their connected nodes
602
+ // Попередньо позиціонуємо нові вузли біля підключених вузлів
578
603
  filteredNewNodes.forEach((node) => {
579
- // Check if any link connects this node to existing nodes
604
+ // Перевіряємо, чи є зв'язки, що поєднують цей вузол з існуючими вузлами
580
605
  const connectedLinks = filteredNewLinks.filter((link) => {
581
606
  const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
582
607
  const targetId = typeof link.target === 'object' ? link.target.id : link.target;
@@ -587,7 +612,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
587
612
  });
588
613
 
589
614
  if (connectedLinks.length > 0) {
590
- // Find an existing connected node to position near
615
+ // Знаходимо існуючий підключений вузол для розташування поряд
591
616
  const someLink = connectedLinks[0];
592
617
  const connectedNodeId =
593
618
  typeof someLink.source === 'object'
@@ -601,23 +626,23 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
601
626
  const connectedNode = updatedNodes.find((n) => n.id === connectedNodeId);
602
627
 
603
628
  if (connectedNode && connectedNode.x !== undefined && connectedNode.y !== undefined) {
604
- // Position new node near the connected node with small randomization
629
+ // Розташовуємо новий вузол біля підключеного вузла з невеликою рандомізацією
605
630
  const randomOffset = 30 + Math.random() * 20;
606
631
  const randomAngle = Math.random() * Math.PI * 2;
607
632
 
608
- // Set initial position
633
+ // Встановлюємо початкову позицію
609
634
  node.x = connectedNode.x + Math.cos(randomAngle) * randomOffset;
610
635
  node.y = connectedNode.y + Math.sin(randomAngle) * randomOffset;
611
636
 
612
- // Set initial velocity to zero for smoother appearance
637
+ // Встановлюємо початкову швидкість в нуль для плавнішої появи
613
638
  node.vx = 0;
614
639
  node.vy = 0;
615
640
  }
616
641
  } else {
617
- // For disconnected nodes, place them in view at random positions
618
- const centerX = width / 2;
619
- const centerY = height / 2;
620
- const radius = Math.min(width, height) / 4;
642
+ // Для непідключених вузлів розміщуємо їх у полі зору у випадкових позиціях
643
+ const centerX = canvasWidth / 2;
644
+ const centerY = canvasHeight / 2;
645
+ const radius = Math.min(canvasWidth, canvasHeight) / 4;
621
646
  const angle = Math.random() * Math.PI * 2;
622
647
 
623
648
  node.x = centerX + Math.cos(angle) * (radius * Math.random());
@@ -627,23 +652,23 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
627
652
  }
628
653
  });
629
654
 
630
- // Fix positions of existing nodes to prevent them from moving
655
+ // Фіксуємо позиції існуючих вузлів, щоб запобігти їхньому руху
631
656
  nodes.forEach((node) => {
632
657
  node.fx = node.x;
633
658
  node.fy = node.y;
634
659
  });
635
660
  }
636
661
 
637
- // Update the simulation with new nodes and links
662
+ // Оновлюємо симуляцію з новими вузлами та зв'язками
638
663
  simulationRef.current.nodes(updatedNodes);
639
664
 
640
- // Get the link force with proper typing
665
+ // Отримуємо силу зв'язків із правильним типом
641
666
  const linkForce = simulationRef.current.force('link') as ForceLink<NodeObject, LinkObject>;
642
667
  if (linkForce) {
643
668
  linkForce.links(updatedLinks);
644
669
  }
645
670
 
646
- // Connect new nodes to their neighbors and links
671
+ // Підключаємо нові вузли до їхніх сусідів та зв'язків
647
672
  filteredNewLinks.forEach((link: any) => {
648
673
  const source =
649
674
  typeof link.source === 'object' ? link.source : updatedNodes.find((n: any) => n.id === link.source);
@@ -652,7 +677,7 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
652
677
 
653
678
  if (!source || !target) return;
654
679
 
655
- // Initialize arrays if they don't exist
680
+ // Ініціалізуємо масиви, якщо вони не існують
656
681
  !source.neighbors && (source.neighbors = []);
657
682
  !target.neighbors && (target.neighbors = []);
658
683
  source.neighbors.push(target);
@@ -665,40 +690,40 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
665
690
  });
666
691
 
667
692
  if (smoothAppearance) {
668
- // Configure simulation for smooth appearance of new nodes
693
+ // Налаштовуємо симуляцію для плавної появи нових вузлів
669
694
  simulationRef.current.alphaTarget(0.3);
670
695
  simulationRef.current.alpha(0.3);
671
- simulationRef.current.velocityDecay(0.7); // Higher decay for smoother motion
696
+ simulationRef.current.velocityDecay(0.7); // Більше загасання для плавнішого руху
672
697
  simulationRef.current.restart();
673
698
 
674
- // After a short time, unfix all nodes and reset simulation parameters
699
+ // Через короткий час відфіксовуємо всі вузли та скидаємо параметри симуляції
675
700
  setTimeout(() => {
676
- // Unfix existing nodes to allow natural movement again
701
+ // Звільняємо існуючі вузли, щоб дозволити їм природний рух
677
702
  nodes.forEach((node) => {
678
703
  node.fx = undefined;
679
704
  node.fy = undefined;
680
705
  });
681
706
 
682
- // Reset simulation to normal settings
707
+ // Скидаємо симуляцію до нормальних налаштувань
683
708
  simulationRef.current?.alphaTarget(0);
684
709
  simulationRef.current?.alpha(0.1);
685
- simulationRef.current?.velocityDecay(0.6); // Reset to default
710
+ simulationRef.current?.velocityDecay(0.6); // Скидаємо до значення за замовчуванням
686
711
  }, transitionDuration);
687
712
  } else {
688
- // Standard restart with low energy for minimal movement
713
+ // Стандартний перезапуск з низькою енергією для мінімального руху
689
714
  simulationRef.current.alpha(0.1).restart();
690
715
  }
691
716
 
692
- // Re-render the canvas
717
+ // Перемальовуємо канвас
693
718
  renderCanvas2D();
694
719
  },
695
- [graphData, simulationRef, renderCanvas2D, width, height]
720
+ [nodes, renderCanvas2D]
696
721
  );
697
722
 
698
723
  /**
699
- * Function to remove nodes from the graph with optional smooth disappearance animation
700
- * @param nodeIds Array of node IDs to remove
701
- * @param options Configuration options for the node removal
724
+ * Функція для видалення вузлів з графа з опціональною плавною анімацією зникнення
725
+ * @param nodeIds Масив ID вузлів для видалення
726
+ * @param options Параметри конфігурації для видалення вузлів
702
727
  */
703
728
  const removeNodes = useCallback(
704
729
  (nodeIds: (string | number)[]) => {
@@ -707,28 +732,61 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
707
732
  if (!simulationRef.current || !nodeIds.length || !nodes || nodes.length === 0 || !links || links.length === 0)
708
733
  return;
709
734
 
710
- // Create set of node IDs for quick lookup
735
+ // Створюємо набір ID вузлів для швидкого пошуку
711
736
  const nodeIdsToRemove = new Set(nodeIds);
712
737
 
713
- // First check if we're removing any selected/hovered node
714
- if (selectedNode && selectedNode.id !== undefined && nodeIdsToRemove.has(selectedNode.id)) {
715
- setSelectedNode(null);
738
+ // Спочатку перевіряємо, чи видаляємо який-небудь виділений/наведений вузол
739
+ if (
740
+ stateRef.current.selectedNode &&
741
+ stateRef.current.selectedNode.id !== undefined &&
742
+ nodeIdsToRemove.has(stateRef.current.selectedNode.id)
743
+ ) {
744
+ stateRef.current.selectedNode = null;
716
745
  }
717
746
 
718
- if (hoveredNode && hoveredNode.id !== undefined && nodeIdsToRemove.has(hoveredNode.id)) {
719
- setHoveredNode(null);
720
- setHighlightNodes(new Set());
721
- setHighlightLinks(new Set());
747
+ if (
748
+ stateRef.current.hoveredNode &&
749
+ stateRef.current.hoveredNode.id !== undefined &&
750
+ nodeIdsToRemove.has(stateRef.current.hoveredNode.id)
751
+ ) {
752
+ stateRef.current.hoveredNode = null;
753
+ stateRef.current.highlightNodes = new Set<NodeObject>();
754
+ stateRef.current.highlightLinks = new Set<any>();
722
755
  }
723
756
 
724
- if (draggedNode && draggedNode.id !== undefined && nodeIdsToRemove.has(draggedNode.id)) {
725
- setDraggedNode(null);
757
+ // Clear hoveredLink if it involves removed nodes
758
+ if (stateRef.current.hoveredLink) {
759
+ const source =
760
+ typeof stateRef.current.hoveredLink.source === 'object'
761
+ ? stateRef.current.hoveredLink.source.id
762
+ : stateRef.current.hoveredLink.source;
763
+ const target =
764
+ typeof stateRef.current.hoveredLink.target === 'object'
765
+ ? stateRef.current.hoveredLink.target.id
766
+ : stateRef.current.hoveredLink.target;
767
+
768
+ if (
769
+ (source !== undefined && nodeIdsToRemove.has(source)) ||
770
+ (target !== undefined && nodeIdsToRemove.has(target))
771
+ ) {
772
+ stateRef.current.hoveredLink = null;
773
+ stateRef.current.highlightNodes = new Set<NodeObject>();
774
+ stateRef.current.highlightLinks = new Set<any>();
775
+ }
726
776
  }
727
777
 
728
- // Get all nodes that will be kept after removal
778
+ if (
779
+ stateRef.current.draggedNode &&
780
+ stateRef.current.draggedNode.id !== undefined &&
781
+ nodeIdsToRemove.has(stateRef.current.draggedNode.id)
782
+ ) {
783
+ stateRef.current.draggedNode = null;
784
+ }
785
+
786
+ // Отримуємо всі вузли, які залишаться після видалення
729
787
  const remainingNodes = nodes.filter((node) => node.id !== undefined && !nodeIdsToRemove.has(node.id));
730
788
 
731
- // Get all links that don't connect to removed nodes
789
+ // Отримуємо всі зв'язки, які не підключаються до видалених вузлів
732
790
  const remainingLinks = links.filter((link) => {
733
791
  const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
734
792
  const targetId = typeof link.target === 'object' ? link.target.id : link.target;
@@ -741,14 +799,14 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
741
799
  );
742
800
  });
743
801
 
744
- // Rebuild node relationships (neighbors and links) for remaining nodes
745
- // First, clear existing relationships
802
+ // Перебудовуємо відносини вузлів (сусіди та зв'язки) для вузлів, що залишилися
803
+ // Спочатку очищаємо існуючі відносини
746
804
  remainingNodes.forEach((node) => {
747
805
  node.neighbors = [];
748
806
  node.links = [];
749
807
  });
750
808
 
751
- // Then rebuild based on remaining links
809
+ // Потім перебудовуємо на основі зв'язків, що залишилися
752
810
  remainingLinks.forEach((link: any) => {
753
811
  const source =
754
812
  typeof link.source === 'object' ? link.source : remainingNodes.find((n: any) => n.id === link.source);
@@ -757,28 +815,24 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
757
815
 
758
816
  if (!source || !target) return;
759
817
 
760
- // Add to neighbors arrays
818
+ // Додаємо до масивів сусідів
761
819
  source.neighbors = source.neighbors || [];
762
820
  target.neighbors = target.neighbors || [];
763
821
  source.neighbors.push(target);
764
822
  target.neighbors.push(source);
765
823
 
766
- // Add to links arrays
824
+ // Додаємо до масивів зв'язків
767
825
  source.links = source.links || [];
768
826
  target.links = target.links || [];
769
827
  source.links.push(link);
770
828
  target.links.push(link);
771
829
  });
772
830
 
773
- // Update component state directly
774
- graphData.nodes = remainingNodes;
775
- graphData.links = remainingLinks;
776
-
777
- // Update the simulation with the filtered nodes and links
831
+ // Оновлюємо симуляцію з відфільтрованими вузлами та зв'язками
778
832
  // але не змінюємо їхні позиції
779
833
  simulationRef.current.nodes(remainingNodes);
780
834
 
781
- // Get and update the link force
835
+ // Отримуємо та оновлюємо силу зв'язків
782
836
  const linkForce = simulationRef.current.force('link') as ForceLink<NodeObject, LinkObject>;
783
837
  if (linkForce) {
784
838
  linkForce.links(remainingLinks);
@@ -787,248 +841,176 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
787
841
  // Просто перемальовуємо canvas з новими даними
788
842
  renderCanvas2D();
789
843
  },
790
- [selectedNode, hoveredNode, draggedNode, graphData, renderCanvas2D]
844
+ [renderCanvas2D]
791
845
  );
846
+ // Функція для масштабування, щоб усі вузли помістилися в полі зору з відступами
847
+ const zoomToFit = useCallback((duration: number = 0, padding: number = 20) => {
848
+ const nodes = getNodes();
849
+ const ctx = ctx2dRef.current;
850
+ if (!ctx || !canvasRef.current || !nodes || !nodes.length) return;
851
+
852
+ // Знаходимо межі всіх вузлів
853
+ let minX = Infinity,
854
+ minY = Infinity;
855
+ let maxX = -Infinity,
856
+ maxY = -Infinity;
857
+
858
+ // Розраховуємо область, що містить усі вузли
859
+ nodes.forEach((node) => {
860
+ if (node.x === undefined || node.y === undefined) return;
861
+
862
+ const x = node.x;
863
+ const y = node.y;
864
+
865
+ // Оновлюємо мін/макс координати
866
+ minX = Math.min(minX, x);
867
+ minY = Math.min(minY, y);
868
+ maxX = Math.max(maxX, x);
869
+ maxY = Math.max(maxY, y);
870
+ });
792
871
 
793
- // Function to zoom to fit all nodes in view with padding
794
-
795
- // Function to zoom to fit all nodes in view with padding
796
- const zoomToFit = useCallback(
797
- (duration: number = 0, padding: number = 20) => {
798
- const nodes = getNodes();
799
- if (!canvasRef.current || !nodes || !nodes.length) return;
800
-
801
- // Find the bounds of all nodes
802
- let minX = Infinity,
803
- minY = Infinity;
804
- let maxX = -Infinity,
805
- maxY = -Infinity;
806
-
807
- // Calculate the bounding box containing all nodes
808
- nodes.forEach((node) => {
809
- if (node.x === undefined || node.y === undefined) return;
810
-
811
- const x = node.x;
812
- const y = node.y;
813
-
814
- // Update min/max coordinates
815
- minX = Math.min(minX, x);
816
- minY = Math.min(minY, y);
817
- maxX = Math.max(maxX, x);
818
- maxY = Math.max(maxY, y);
819
- });
872
+ // Якщо у нас валідні межі
873
+ if (isFinite(minX) && isFinite(maxX) && isFinite(minY) && isFinite(maxY)) {
874
+ // Розраховуємо необхідний масштаб, щоб вмістити всі вузли
875
+ const { width: canvasWidth, height: canvasHeight } = stateRef.current;
876
+
877
+ // Додаємо відступи до області
878
+ minX -= padding;
879
+ minY -= padding;
880
+ maxX += padding;
881
+ maxY += padding;
882
+
883
+ // Розраховуємо ширину та висоту вмісту
884
+ const contentWidth = maxX - minX;
885
+ const contentHeight = maxY - minY;
886
+
887
+ // Розраховуємо масштаб, необхідний для розміщення вмісту
888
+ const scaleX = contentWidth > 0 ? canvasWidth / contentWidth : 1;
889
+ const scaleY = contentHeight > 0 ? canvasHeight / contentHeight : 1;
890
+ const scale = Math.min(scaleX, scaleY, 10); // Обмежуємо масштаб до 10x
891
+
892
+ // Розраховуємо центр вмісту
893
+ const centerX = minX + contentWidth / 2;
894
+ const centerY = minY + contentHeight / 2;
895
+
896
+ // Розраховуємо нову трансформацію для правильного центрування та масштабування
897
+ const newTransform = {
898
+ k: scale,
899
+ x: canvasWidth / 2 - centerX * scale,
900
+ y: canvasHeight / 2 - centerY * scale,
901
+ };
902
+
903
+ if (duration > 0) {
904
+ // Анімуємо перехід, якщо вказана тривалість
905
+ const startTransform = { ...stateRef.current.transform };
906
+ const startTime = Date.now();
907
+
908
+ const animateZoom = () => {
909
+ const t = Math.min(1, (Date.now() - startTime) / duration);
910
+
911
+ // Використовуємо функцію пом'якшення для плавнішого переходу
912
+ const easedT = t === 1 ? 1 : 1 - Math.pow(1 - t, 3); // Кубічне пом'якшення
913
+
914
+ // Інтерполюємо між початковою та кінцевою трансформаціями
915
+ const interpolatedTransform = {
916
+ k: startTransform.k + (newTransform.k - startTransform.k) * easedT,
917
+ x: startTransform.x + (newTransform.x - startTransform.x) * easedT,
918
+ y: startTransform.y + (newTransform.y - startTransform.y) * easedT,
919
+ };
820
920
 
821
- // If we have valid bounds
822
- if (isFinite(minX) && isFinite(maxX) && isFinite(minY) && isFinite(maxY)) {
823
- // Calculate the required scale to fit all nodes
824
- const canvasWidth = width;
825
- const canvasHeight = height;
826
-
827
- // Add padding to the bounding box
828
- minX -= padding;
829
- minY -= padding;
830
- maxX += padding;
831
- maxY += padding;
832
-
833
- // Calculate the width and height of the content
834
- const contentWidth = maxX - minX;
835
- const contentHeight = maxY - minY;
836
-
837
- // Calculate the scale required to fit the content
838
- const scaleX = contentWidth > 0 ? canvasWidth / contentWidth : 1;
839
- const scaleY = contentHeight > 0 ? canvasHeight / contentHeight : 1;
840
- const scale = Math.min(scaleX, scaleY, 10); // Cap zoom at 10x
841
-
842
- // Calculate the center of the content
843
- const centerX = minX + contentWidth / 2;
844
- const centerY = minY + contentHeight / 2;
845
-
846
- // Calculate the new transform to center and scale correctly
847
- const newTransform = {
848
- k: scale,
849
- x: canvasWidth / 2 - centerX * scale,
850
- y: canvasHeight / 2 - centerY * scale,
921
+ stateRef.current.transform = interpolatedTransform;
922
+ renderCanvas2D();
923
+ if (t < 1) {
924
+ requestAnimationFrame(animateZoom);
925
+ }
851
926
  };
852
927
 
853
- if (duration > 0) {
854
- // Animate the transition if duration is provided
855
- const startTransform = { ...transform };
856
- const startTime = Date.now();
857
-
858
- const animateZoom = () => {
859
- const t = Math.min(1, (Date.now() - startTime) / duration);
860
-
861
- // Use easing function for smoother transition
862
- const easedT = t === 1 ? 1 : 1 - Math.pow(1 - t, 3); // Cubic easing
863
-
864
- // Interpolate between start and end transform
865
- const interpolatedTransform = {
866
- k: startTransform.k + (newTransform.k - startTransform.k) * easedT,
867
- x: startTransform.x + (newTransform.x - startTransform.x) * easedT,
868
- y: startTransform.y + (newTransform.y - startTransform.y) * easedT,
869
- };
870
-
871
- setTransform(interpolatedTransform);
872
-
873
- if (t < 1) {
874
- requestAnimationFrame(animateZoom);
875
- }
876
- };
877
-
878
- requestAnimationFrame(animateZoom);
879
- } else {
880
- // Apply transform immediately if no duration
881
- setTransform(newTransform);
882
- }
928
+ requestAnimationFrame(animateZoom);
929
+ } else {
930
+ // Застосовуємо трансформацію негайно, якщо немає тривалості
931
+ stateRef.current.transform = newTransform;
932
+ renderCanvas2D();
883
933
  }
884
- },
885
- [width, height, transform]
886
- );
887
-
888
- useEffect(() => {
889
- // Initialize canvas context
890
- const canvasElement = canvasRef.current;
891
- if (!canvasElement) return;
892
-
893
- // Set canvas size with device pixel ratio for sharp rendering
894
- const pixelRatio = window.devicePixelRatio || 1;
895
- canvasElement.width = width * pixelRatio;
896
- canvasElement.height = height * pixelRatio;
897
- canvasElement.style.width = `${width}px`;
898
- canvasElement.style.height = `${height}px`;
899
-
900
- // Initialize Canvas 2D context
901
- init2DCanvas();
934
+ }
935
+ }, []);
902
936
 
903
- // Calculate the center position adjusted for the canvas size
904
- const centerX = width / 2;
905
- const centerY = height / 2;
937
+ // Знаходимо вузол за вказаними координатами
938
+ const getNodeAtPosition = useCallback((x: number, y: number): NodeObject | null => {
939
+ const nodes = getNodes();
940
+ if (!nodes || nodes.length === 0) return null;
906
941
 
907
- // Initialize D3 force simulation
908
- const nodeSize = config.nodeSizeBase;
909
- const nodeRadius = nodeSize / 2;
910
- const linkDistance = nodeSize * 2.5; // Calculate link distance based on node size
942
+ // Знаходимо будь-який вузол у межах радіусу від вказівника (з урахуванням розміру вузла)
943
+ const nodeRadius = config.nodeSizeBase / 2;
911
944
 
912
- const simulation = (simulationRef.current = forceSimulation(nodes)
913
- .force(
914
- 'link',
915
- forceLink(links)
916
- .id((d: any) => d.id)
917
- .distance(linkDistance) // Адаптивна відстань між вузлами на основі розміру
918
- .strength(0.9) // Зменшуємо силу зв'язків (значення від 0 до 1)
919
- )
920
- .force(
921
- 'charge',
922
- forceManyBody()
923
- .strength((-nodeSize / 10) * 100) // Силу відштовхування на основі розміру вузла
924
- .theta(0.5) // Оптимізація для стабільності (0.5-1.0)
925
- .distanceMin(nodeSize * 2)
926
- )
927
- .force('x', forceX().strength(0.03)) // Слабка сила для стабілізації по осі X
928
- .force('y', forceY().strength(0.03)) // Слабка сила для стабілізації по осі Y
929
- .force('center', forceCenter(centerX, centerY).strength(0.05)) // Слабка сила центрування
930
- .force(
931
- 'collide',
932
- forceCollide()
933
- .radius(nodeRadius * 2) // Радіус колізії залежно від розміру вузла
934
- .iterations(2) // Більше ітерацій для кращого запобігання перекриття
935
- .strength(1) // Збільшуємо силу запобігання колізіям
936
- )
937
- .velocityDecay(0.6)); // Коефіцієнт затухання швидкості для зменшення "тряски"
945
+ // Масштабуємо координати на основі коефіцієнту щільності пікселів пристрою та застосовуємо зворотну трансформацію
946
+ const pixelRatio = window.devicePixelRatio || 1;
947
+ // Застосовуємо зворотну трансформацію, щоб отримати координати в системі координат графа
948
+ const scaledX = (x * pixelRatio - stateRef.current.transform.x) / stateRef.current.transform.k;
949
+ const scaledY = (y * pixelRatio - stateRef.current.transform.y) / stateRef.current.transform.k;
950
+
951
+ return (
952
+ nodes.find((node) => {
953
+ const dx = (node.x || 0) - scaledX;
954
+ const dy = (node.y || 0) - scaledY;
955
+ return Math.sqrt(dx * dx + dy * dy) <= nodeRadius;
956
+ }) || null
957
+ );
958
+ }, []);
938
959
 
939
- return () => {
940
- // Cleanup
941
- simulation.stop();
942
- };
943
- }, [width, height, nodes, links, init2DCanvas]);
960
+ // Знаходимо лінк за вказаними координатами
961
+ const getLinkAtPosition = useCallback((x: number, y: number): LinkObject | null => {
962
+ const links = getLinks();
963
+ const nodes = getNodes();
964
+ if (!links || links.length === 0 || !nodes || nodes.length === 0) return null;
944
965
 
945
- useEffect(() => {
946
- if (simulationRef.current) {
947
- const simulation = simulationRef.current;
948
- // Update node positions on each tick
966
+ // Масштабуємо координати на основі коефіцієнту щільності пікселів пристрою та застосовуємо зворотну трансформацію
967
+ const pixelRatio = window.devicePixelRatio || 1;
968
+ // Застосовуємо зворотну трансформацію, щоб отримати координати в системі координат графа
969
+ const scaledX = (x * pixelRatio - stateRef.current.transform.x) / stateRef.current.transform.k;
970
+ const scaledY = (y * pixelRatio - stateRef.current.transform.y) / stateRef.current.transform.k;
949
971
 
950
- simulation.on('tick', () => {
951
- renderCanvas2D();
952
- });
972
+ // Пороговая відстань для визначення кліку по лінку
973
+ const threshold = 5;
953
974
 
954
- // When simulation ends, stop rendering indicator
955
- simulation.on('end', () => {
956
- // Render one last time
957
- if (isRendering) {
958
- zoomToFit(0, 20); // Zoom to fit after rendering
975
+ return (
976
+ links.find((link) => {
977
+ const source = typeof link.source === 'object' ? link.source : nodes.find((n) => n.id === link.source);
978
+ const target = typeof link.target === 'object' ? link.target : nodes.find((n) => n.id === link.target);
959
979
 
960
- setTimeout(() => {
961
- setIsRendering(false);
962
- }, 200);
963
- }
964
- });
965
- }
980
+ if (!source || !target) return false;
966
981
 
967
- if (!isRendering) {
968
- renderCanvas2D();
969
- }
970
- }, [isRendering, renderCanvas2D, zoomToFit]);
982
+ const sourceX = source.x || 0;
983
+ const sourceY = source.y || 0;
984
+ const targetX = target.x || 0;
985
+ const targetY = target.y || 0;
971
986
 
972
- // Set up node relationships (neighbors and links)
973
- useEffect(() => {
974
- if (!graphData) return;
987
+ // Вычисляем расстояние от точки до линии
988
+ const A = scaledX - sourceX;
989
+ const B = scaledY - sourceY;
990
+ const C = targetX - sourceX;
991
+ const D = targetY - sourceY;
975
992
 
976
- // Connect nodes to their neighbors and links
977
- graphData.links.forEach((link: any) => {
978
- const source =
979
- typeof link.source === 'object' ? link.source : graphData.nodes.find((n: any) => n.id === link.source);
980
- const target =
981
- typeof link.target === 'object' ? link.target : graphData.nodes.find((n: any) => n.id === link.target);
993
+ const dot = A * C + B * D;
994
+ const lenSq = C * C + D * D;
982
995
 
983
- if (!source || !target) return;
996
+ if (lenSq === 0) return false; // Линия нулевой длины
984
997
 
985
- // Initialize arrays if they don't exist
986
- !source.neighbors && (source.neighbors = []);
987
- !target.neighbors && (target.neighbors = []);
988
- source.neighbors.push(target);
989
- target.neighbors.push(source);
998
+ let param = dot / lenSq;
999
+ param = Math.max(0, Math.min(1, param)); // Ограничиваем параметр отрезком [0, 1]
990
1000
 
991
- !source.links && (source.links = []);
992
- !target.links && (target.links = []);
993
- source.links.push(link);
994
- target.links.push(link);
995
- });
996
- }, [graphData]);
1001
+ const xx = sourceX + param * C;
1002
+ const yy = sourceY + param * D;
997
1003
 
998
- // Initialize button images
999
- useEffect(() => {
1000
- if (buttons && buttons.length > 0) {
1001
- setButtonImages(prepareButtonImages(buttons));
1002
- }
1003
- }, [buttons]);
1004
+ const dx = scaledX - xx;
1005
+ const dy = scaledY - yy;
1006
+ const distance = Math.sqrt(dx * dx + dy * dy);
1004
1007
 
1005
- // Find node at specific coordinates
1006
- const getNodeAtPosition = useCallback(
1007
- (x: number, y: number): NodeObject | null => {
1008
- const nodes = getNodes();
1009
- if (!nodes || nodes.length === 0) return null;
1010
-
1011
- // Find any node within radius pixels of the pointer (adjusted for node size)
1012
- const nodeRadius = config.nodeSizeBase / 2;
1013
-
1014
- // Scale coordinates based on device pixel ratio and apply inverse transform
1015
- const pixelRatio = window.devicePixelRatio || 1;
1016
- // Apply inverse transform to get the coordinates in the graph's coordinate system
1017
- const scaledX = (x * pixelRatio - transform.x) / transform.k;
1018
- const scaledY = (y * pixelRatio - transform.y) / transform.k;
1019
-
1020
- return (
1021
- nodes.find((node) => {
1022
- const dx = (node.x || 0) - scaledX;
1023
- const dy = (node.y || 0) - scaledY;
1024
- return Math.sqrt(dx * dx + dy * dy) <= nodeRadius;
1025
- }) || null
1026
- );
1027
- },
1028
- [transform, config.nodeSizeBase]
1029
- );
1008
+ return distance <= threshold;
1009
+ }) || null
1010
+ );
1011
+ }, []);
1030
1012
 
1031
- // Utility function to check if a point is inside a button sector
1013
+ // Допоміжна функція для перевірки, чи знаходиться точка всередині сектора кнопки
1032
1014
  const isPointInButtonSector = useCallback(
1033
1015
  (
1034
1016
  mouseX: number,
@@ -1039,32 +1021,32 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1039
1021
  startAngle: number,
1040
1022
  endAngle: number
1041
1023
  ): boolean => {
1042
- // Calculate distance from node center to mouse point
1024
+ // Розраховуємо відстань від центру вузла до точки миші
1043
1025
  const dx = mouseX - nodeX;
1044
1026
  const dy = mouseY - nodeY;
1045
1027
  const distance = Math.sqrt(dx * dx + dy * dy);
1046
1028
 
1047
- // Calculate angle between point and horizontal axis
1029
+ // Розраховуємо кут між точкою та горизонтальною віссю
1048
1030
  let angle = Math.atan2(dy, dx);
1049
- if (angle < 0) angle += 2 * Math.PI; // Convert to [0, 2π] range
1031
+ if (angle < 0) angle += 2 * Math.PI; // Конвертуємо в діапазон [0, 2π]
1050
1032
 
1051
- // Expand radius range for easier button interaction
1033
+ // Розширюємо діапазон радіусу для зручнішої взаємодії з кнопками
1052
1034
  const minRadiusRatio = 0.5;
1053
1035
  const maxRadiusRatio = 1;
1054
1036
  const isInRadius = distance >= radius * minRadiusRatio && distance <= radius * maxRadiusRatio;
1055
1037
 
1056
- // Check if the angle is within the sector
1038
+ // Перевіряємо, чи кут знаходиться в межах сектора
1057
1039
  let isInAngle = false;
1058
1040
 
1059
- // Top half circle: from Math.PI to Math.PI * 2
1041
+ // Верхнє півколо: від Math.PI до Math.PI * 2
1060
1042
  if (startAngle === Math.PI && endAngle === Math.PI * 2) {
1061
1043
  isInAngle = angle >= Math.PI && angle <= Math.PI * 2;
1062
1044
  }
1063
- // Bottom half circle: from 0 to Math.PI
1045
+ // Нижнє півколо: від 0 до Math.PI
1064
1046
  else if (startAngle === 0 && endAngle === Math.PI) {
1065
1047
  isInAngle = angle >= 0 && angle <= Math.PI;
1066
1048
  }
1067
- // General case for arbitrary sectors
1049
+ // Загальний випадок для довільних секторів
1068
1050
  else {
1069
1051
  isInAngle =
1070
1052
  (startAngle <= endAngle && angle >= startAngle && angle <= endAngle) ||
@@ -1076,24 +1058,24 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1076
1058
  []
1077
1059
  );
1078
1060
 
1079
- // Handle node hover (similar to Graph2D handleNodeHover)
1061
+ // Обробка наведення на вузол (подібно до Graph2D handleNodeHover)
1080
1062
  const handleNodeHover = useCallback(
1081
1063
  (node: NodeObject | null) => {
1082
- // Check if the node is the same as the last hovered node
1083
- if (node === lastHoveredNodeRef.current) {
1084
- return; // Skip processing if it's the same node
1064
+ // Перевіряємо, чи вузол той самий, що і останній вузол, на який наводили
1065
+ if (node === stateRef.current.lastHoveredNodeRef) {
1066
+ return; // Пропускаємо обробку, якщо це той самий вузол
1085
1067
  }
1086
1068
 
1087
- // Update last hovered node reference
1088
- lastHoveredNodeRef.current = node;
1069
+ // Оновлюємо посилання на останній наведений вузол
1070
+ stateRef.current.lastHoveredNodeRef = node;
1089
1071
 
1090
- const newHighlightNodes = new Set();
1091
- const newHighlightLinks = new Set();
1072
+ const newHighlightNodes = new Set<NodeObject>();
1073
+ const newHighlightLinks = new Set<any>();
1092
1074
 
1093
1075
  if (node) {
1094
1076
  newHighlightNodes.add(node);
1095
1077
 
1096
- // Add neighboring nodes and links to highlighting
1078
+ // Додаємо сусідні вузли та зв'язки до підсвічування
1097
1079
  if (node.neighbors) {
1098
1080
  node.neighbors.forEach((neighbor: any) => newHighlightNodes.add(neighbor));
1099
1081
  }
@@ -1103,78 +1085,116 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1103
1085
  }
1104
1086
  }
1105
1087
 
1106
- setHoveredNode(node);
1088
+ stateRef.current.hoveredNode = node;
1107
1089
  if (onNodeHover) onNodeHover(node);
1108
- setHighlightNodes(newHighlightNodes);
1109
- setHighlightLinks(newHighlightLinks);
1090
+ stateRef.current.highlightNodes = newHighlightNodes;
1091
+ stateRef.current.highlightLinks = newHighlightLinks;
1110
1092
  },
1111
1093
  [onNodeHover]
1112
1094
  );
1113
1095
 
1114
- // Handle node click
1096
+ // Обробка наведення на лінк
1097
+ const handleLinkHover = useCallback(
1098
+ (link: LinkObject | null) => {
1099
+ // Перевіряємо, чи лінк той самий, що і останній лінк, на який наводили
1100
+ if (link === stateRef.current.hoveredLink) {
1101
+ return; // Пропускаємо обробку, якщо це той самий лінк
1102
+ }
1103
+
1104
+ const newHighlightNodes = new Set<NodeObject>();
1105
+ const newHighlightLinks = new Set<any>();
1106
+
1107
+ if (link) {
1108
+ // Підсвічуємо сам лінк
1109
+ newHighlightLinks.add(link);
1110
+
1111
+ // Підсвічуємо пов'язані вузли
1112
+ const nodes = getNodes();
1113
+ if (nodes) {
1114
+ const source = typeof link.source === 'object' ? link.source : nodes.find((n) => n.id === link.source);
1115
+ const target = typeof link.target === 'object' ? link.target : nodes.find((n) => n.id === link.target);
1116
+
1117
+ if (source) newHighlightNodes.add(source);
1118
+ if (target) newHighlightNodes.add(target);
1119
+ }
1120
+ }
1121
+
1122
+ stateRef.current.hoveredLink = link;
1123
+ if (onLinkHover) onLinkHover(link);
1124
+ stateRef.current.highlightNodes = newHighlightNodes;
1125
+ stateRef.current.highlightLinks = newHighlightLinks;
1126
+ },
1127
+ [onLinkHover, getNodes]
1128
+ );
1129
+
1130
+ // Обробка кліку на лінк
1131
+ const handleLinkClick = useCallback(
1132
+ (link: LinkObject) => {
1133
+ if (onLinkClick) onLinkClick(link);
1134
+ },
1135
+ [onLinkClick]
1136
+ );
1137
+
1138
+ // Обробка кліку на вузол
1115
1139
  const handleNodeClick = useCallback(
1116
1140
  (node: NodeObject) => {
1117
- setSelectedNode(node);
1141
+ stateRef.current.selectedNode = node;
1118
1142
  if (onNodeClick) onNodeClick(node);
1119
1143
  },
1120
1144
  [onNodeClick]
1121
1145
  );
1122
1146
 
1123
- // Handle background click
1147
+ // Обробка кліку на фон
1124
1148
  const handleBackgroundClick = useCallback(() => {
1125
- setSelectedNode(null);
1149
+ stateRef.current.selectedNode = null;
1126
1150
  if (onBackgroundClick) onBackgroundClick();
1127
- }, [onBackgroundClick]);
1128
-
1129
- // Відслідковування початкових координат для виявлення перетягування
1130
- const mouseStartPosRef = useRef<{ x: number; y: number } | null>(null);
1131
- const isDraggingRef = useRef<boolean>(false); // Handle mouse down for dragging
1151
+ }, [onBackgroundClick]); // Перетягування тепер обробляється через stateRef для покращення продуктивності
1132
1152
 
1133
1153
  const handleMouseDown = useCallback(
1134
1154
  (event: React.MouseEvent<HTMLCanvasElement>) => {
1135
1155
  if (!canvasRef.current || !simulationRef.current) return;
1136
1156
 
1137
- // Get canvas-relative coordinates
1157
+ // Отримуємо координати відносно полотна
1138
1158
  const rect = canvasRef.current.getBoundingClientRect();
1139
1159
  const x = event.clientX - rect.left;
1140
1160
  const y = event.clientY - rect.top;
1141
1161
 
1142
1162
  // Зберігаємо початкові координати для подальшого відстеження перетягування
1143
- mouseStartPosRef.current = { x, y };
1144
- isDraggingRef.current = false;
1163
+ stateRef.current.mouseStartPos = { x, y };
1164
+ stateRef.current.isDragging = false;
1145
1165
 
1146
- // Try to find a node at the cursor position - we'll process the click on mouseUp if not dragging
1166
+ // Намагаємося знайти вузол у позиції курсора - обробимо клік при mouseUp, якщо не відбувається перетягування
1147
1167
  const node = getNodeAtPosition(x, y);
1148
1168
  if (node) {
1149
- // Set as potentially draggable but don't activate simulation yet
1150
- setDraggedNode(node);
1169
+ // Встановлюємо як потенційно перетягуваний, але не активуємо симуляцію поки що
1170
+ stateRef.current.draggedNode = node;
1151
1171
 
1152
- // Fix the node position temporarily - поки що фіксуємо позицію
1172
+ // Тимчасово фіксуємо позицію вузла
1153
1173
  node.fx = node.x;
1154
1174
  node.fy = node.y;
1155
1175
  } else {
1156
- // If no node was clicked, start panning
1157
- setIsPanning(true);
1158
- lastMousePosRef.current = { x, y };
1176
+ // Якщо не клікнули на вузол, починаємо панорамування
1177
+ stateRef.current.isPanning = true;
1178
+ stateRef.current.lastMousePos = { x, y };
1159
1179
  }
1160
1180
  },
1161
1181
  [getNodeAtPosition]
1162
1182
  );
1163
1183
 
1164
- // Handle mouse move for dragging and hovering
1184
+ // Обробка руху миші для перетягування та наведення
1165
1185
  const handleMouseMove = useCallback(
1166
1186
  (event: React.MouseEvent<HTMLCanvasElement>) => {
1167
1187
  if (!canvasRef.current) return;
1168
1188
 
1169
- // Get canvas-relative coordinates
1189
+ // Отримуємо координати відносно полотна
1170
1190
  const rect = canvasRef.current.getBoundingClientRect();
1171
1191
  const x = event.clientX - rect.left;
1172
1192
  const y = event.clientY - rect.top;
1173
1193
 
1174
1194
  // Перевіряємо чи почалось перетягування
1175
- if (draggedNode && mouseStartPosRef.current && simulationRef.current) {
1176
- const startX = mouseStartPosRef.current.x;
1177
- const startY = mouseStartPosRef.current.y;
1195
+ if (stateRef.current.draggedNode && stateRef.current.mouseStartPos && simulationRef.current) {
1196
+ const startX = stateRef.current.mouseStartPos.x;
1197
+ const startY = stateRef.current.mouseStartPos.y;
1178
1198
 
1179
1199
  // Визначаємо відстань переміщення для виявлення факту перетягування
1180
1200
  const dx = x - startX;
@@ -1185,97 +1205,100 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1185
1205
  const dragThreshold = 3; // поріг у пікселях
1186
1206
 
1187
1207
  if (dragDistance > dragThreshold) {
1188
- // This is definitely a drag operation, not a click
1189
- isDraggingRef.current = true;
1208
+ // Це точно операція перетягування, а не просто клік
1209
+ stateRef.current.isDragging = true;
1190
1210
 
1191
- // If this is the first detection of dragging, configure the simulation
1211
+ // Якщо це перше виявлення перетягування, налаштовуємо симуляцію
1192
1212
  if (simulationRef.current.alphaTarget() === 0) {
1193
- // Set alphaTarget to a value based on node size for appropriate movement intensity
1194
- const alphaValue = 0.15;
1213
+ // Встановлюємо alphaTarget на значення, що базується на розмірі вузла для відповідної інтенсивності руху
1214
+ const alphaValue = 0;
1195
1215
  simulationRef.current.alphaTarget(alphaValue).restart();
1196
1216
 
1197
- // Adjust decay based on node size for better stability during drag
1198
- const decayValue = 0.8;
1199
- simulationRef.current.velocityDecay(decayValue);
1217
+ // // Регулюємо швидкість загасання для кращої стабільності під час перетягування
1218
+ // const decayValue = 0.2;
1219
+ // simulationRef.current.velocityDecay(decayValue);
1200
1220
  }
1201
1221
  }
1202
1222
 
1203
- // Scale coordinates based on device pixel ratio and current transform
1223
+ // Масштабуємо координати на основі співвідношення пікселів пристрою та поточної трансформації
1204
1224
  const pixelRatio = window.devicePixelRatio || 1;
1205
1225
 
1206
- // Apply inverse transformation to get coordinates in the graph's space
1207
- const scaledX = (x * pixelRatio - transform.x) / transform.k;
1208
- const scaledY = (y * pixelRatio - transform.y) / transform.k;
1226
+ // Застосовуємо зворотну трансформацію, щоб отримати координати у просторі графа
1227
+ const scaledX = (x * pixelRatio - stateRef.current.transform.x) / stateRef.current.transform.k;
1228
+ const scaledY = (y * pixelRatio - stateRef.current.transform.y) / stateRef.current.transform.k;
1209
1229
 
1210
- // Update the fixed positions of the dragged node with smoothing
1211
- draggedNode.fx = scaledX;
1212
- draggedNode.fy = scaledY;
1230
+ // Оновлюємо фіксовані позиції перетягуваного вузла з плавністю
1231
+ stateRef.current.draggedNode.fx = scaledX;
1232
+ stateRef.current.draggedNode.fy = scaledY;
1213
1233
 
1214
- if (isDraggingRef.current) {
1215
- // Reduce simulation energy during dragging for stability
1216
- simulationRef.current.alpha(0.1); // Reduce system energy
1217
- }
1234
+ // if (stateRef.current.isDragging) {
1235
+ // // Зменшуємо енергію симуляції під час перетягування для стабільності
1236
+ // simulationRef.current.alpha(0); // Зменшуємо енергію системи
1237
+ // }
1218
1238
 
1219
- // No need to check for hover when dragging
1239
+ // Немає потреби перевіряти наведення під час перетягування
1220
1240
  return;
1221
1241
  }
1222
1242
 
1223
- // Handle panning
1224
- if (isPanning && mouseStartPosRef.current) {
1225
- const dx = x - lastMousePosRef.current.x;
1226
- const dy = y - lastMousePosRef.current.y;
1243
+ // Обробка панорамування
1244
+ if (stateRef.current.isPanning && stateRef.current.mouseStartPos) {
1245
+ const dx = x - stateRef.current.lastMousePos.x;
1246
+ const dy = y - stateRef.current.lastMousePos.y;
1227
1247
 
1228
- // Calculate total distance moved during panning
1229
- const startX = mouseStartPosRef.current.x;
1230
- const startY = mouseStartPosRef.current.y;
1248
+ // Обчислюємо загальну відстань, пройдену під час панорамування
1249
+ const startX = stateRef.current.mouseStartPos.x;
1250
+ const startY = stateRef.current.mouseStartPos.y;
1231
1251
  const panDistance = Math.sqrt(Math.pow(x - startX, 2) + Math.pow(y - startY, 2));
1232
1252
 
1233
1253
  // Використовуємо ту ж саму логіку і поріг відстані як і для перетягування вузла
1234
1254
  const panThreshold = 3; // Той самий поріг як і для перетягування вузла
1235
1255
  if (panDistance > panThreshold) {
1236
1256
  // Це точно панорамування, а не просто клік
1237
- isDraggingRef.current = true;
1257
+ stateRef.current.isDragging = true;
1238
1258
  }
1239
1259
 
1240
- setTransform((prev) => ({
1241
- ...prev,
1242
- x: prev.x + dx,
1243
- y: prev.y + dy,
1244
- }));
1260
+ // Оновлюємо трансформацію безпосередньо у stateRef
1261
+ stateRef.current.transform = {
1262
+ ...stateRef.current.transform,
1263
+ x: stateRef.current.transform.x + dx,
1264
+ y: stateRef.current.transform.y + dy,
1265
+ };
1245
1266
 
1246
- lastMousePosRef.current = { x, y };
1267
+ stateRef.current.lastMousePos = { x, y };
1268
+ renderCanvas2D(); // Перемальовуємо полотно після панорамування
1247
1269
  return;
1248
1270
  }
1249
1271
 
1272
+ let shouldRender;
1250
1273
  let hoveredNode;
1251
1274
 
1252
- // Button hover detection logic
1253
- if (selectedNode && canvasRef.current && buttonImages.length > 0) {
1275
+ // Логіка виявлення наведення на кнопки
1276
+ if (stateRef.current.selectedNode && canvasRef.current && buttonImages.length > 0) {
1254
1277
  const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
1255
1278
 
1256
- // Scale coordinates based on canvas display size
1279
+ // Масштабуємо координати відносно розміру відображення полотна
1257
1280
  const canvasScaleX = canvasRef.current.width / rect.width;
1258
1281
  const canvasScaleY = canvasRef.current.height / rect.height;
1259
1282
 
1260
- // Scaled mouse coordinates in canvas coordinate system
1283
+ // Масштабовані координати миші в системі координат полотна
1261
1284
  const scaledMouseX = x * canvasScaleX;
1262
1285
  const scaledMouseY = y * canvasScaleY;
1263
1286
 
1264
- // Apply current transformation to get world coordinates
1265
- const worldX = (scaledMouseX - transform.x) / transform.k;
1266
- const worldY = (scaledMouseY - transform.y) / transform.k;
1287
+ // Застосовуємо поточну трансформацію для отримання світових координат
1288
+ const worldX = (scaledMouseX - stateRef.current.transform.x) / stateRef.current.transform.k;
1289
+ const worldY = (scaledMouseY - stateRef.current.transform.y) / stateRef.current.transform.k;
1267
1290
 
1268
1291
  // Node position
1269
- const nodeX = selectedNode.x || 0;
1270
- const nodeY = selectedNode.y || 0;
1292
+ const nodeX = stateRef.current.selectedNode.x || 0;
1293
+ const nodeY = stateRef.current.selectedNode.y || 0;
1271
1294
 
1272
- // Calculate number of buttons and their sectors
1295
+ // Обчислюємо кількість кнопок та їхні сектори
1273
1296
  const buttonCount = Math.min(buttonImages.length, 8);
1274
1297
  const sectorAngle = Math.min((Math.PI * 2) / buttonCount, Math.PI);
1275
1298
 
1276
1299
  let hoveredIndex = null;
1277
1300
 
1278
- // Check if mouse is over any button sector
1301
+ // Перевіряємо, чи вказівник миші знаходиться над будь-яким сектором кнопок
1279
1302
  for (let i = 0; i < buttonCount; i++) {
1280
1303
  const startAngle = i * sectorAngle;
1281
1304
  const endAngle = (i + 1) * sectorAngle;
@@ -1285,128 +1308,167 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1285
1308
  break;
1286
1309
  }
1287
1310
  }
1288
- if (hoveredIndex !== null) hoveredNode = selectedNode; // Set hoveredNode to selectedNode for further processing
1289
- setHoveredButtonIndex(hoveredIndex);
1311
+ if (hoveredIndex !== null) hoveredNode = stateRef.current.selectedNode; // Set hoveredNode to selectedNode for further processing
1312
+ if (hoveredIndex !== stateRef.current.hoveredButtonIndex) {
1313
+ shouldRender = true; // Only render if hovered button index has changed
1314
+ }
1315
+ stateRef.current.hoveredButtonIndex = hoveredIndex;
1290
1316
  } else {
1291
- if (hoveredButtonIndex !== null) setHoveredButtonIndex(null);
1317
+ if (stateRef.current.hoveredButtonIndex !== null) stateRef.current.hoveredButtonIndex = null;
1292
1318
  }
1293
1319
 
1294
1320
  if (!hoveredNode) {
1295
- // If no node is hovered, reset hoveredNode
1321
+ // If no node is hovered, check for link hover
1296
1322
  hoveredNode = getNodeAtPosition(x, y);
1323
+
1324
+ // If no node is hovered, check for link hover
1325
+ if (!hoveredNode) {
1326
+ const hoveredLink = getLinkAtPosition(x, y);
1327
+ const shouldRenderLink = hoveredLink !== stateRef.current.hoveredLink;
1328
+
1329
+ // Clear node hover state when hovering over a link
1330
+ if (hoveredLink && stateRef.current.hoveredNode) {
1331
+ handleNodeHover(null);
1332
+ }
1333
+
1334
+ handleLinkHover(hoveredLink);
1335
+
1336
+ // Update cursor style for links
1337
+ if (canvasRef.current) {
1338
+ canvasRef.current.style.cursor = hoveredLink ? 'pointer' : 'default';
1339
+ }
1340
+
1341
+ if (shouldRenderLink) {
1342
+ renderCanvas2D();
1343
+ }
1344
+ return;
1345
+ }
1346
+ }
1347
+
1348
+ // Clear link hover state when hovering over a node
1349
+ if (hoveredNode && stateRef.current.hoveredLink) {
1350
+ handleLinkHover(null);
1297
1351
  }
1352
+
1353
+ if (hoveredNode !== stateRef.current.hoveredNode) shouldRender = true;
1354
+
1298
1355
  handleNodeHover(hoveredNode);
1299
- // Check for hover and update highlighting
1356
+ // Перевіряємо наведення та оновлюємо підсвічування
1300
1357
 
1301
- // Change cursor style based on hover
1358
+ // Змінюємо стиль курсору залежно від наведення
1302
1359
  if (canvasRef.current) {
1303
1360
  canvasRef.current.style.cursor = hoveredNode ? 'pointer' : 'default';
1304
1361
  }
1362
+ if (shouldRender) {
1363
+ renderCanvas2D(); // Перемальовуємо полотно після зміни наведення
1364
+ }
1305
1365
  },
1306
1366
  [
1307
- draggedNode,
1367
+ buttonImages,
1308
1368
  getNodeAtPosition,
1309
- isPanning,
1310
- transform,
1369
+ getLinkAtPosition,
1311
1370
  handleNodeHover,
1312
- selectedNode,
1313
- buttonImages,
1314
- config,
1315
- hoveredButtonIndex,
1316
- simulationRef,
1317
- lastMousePosRef,
1318
- mouseStartPosRef,
1319
- isDraggingRef,
1371
+ handleLinkHover,
1372
+ renderCanvas2D,
1320
1373
  isPointInButtonSector,
1321
- canvasRef,
1322
- setTransform,
1323
- setHoveredButtonIndex,
1324
1374
  ]
1325
1375
  );
1326
1376
 
1327
1377
  const handleClick = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {
1328
- if (mustBeStoppedPropagation.current) {
1378
+ if (stateRef.current.mustBeStoppedPropagation) {
1329
1379
  event.stopPropagation();
1330
1380
  event.preventDefault();
1331
1381
  }
1332
- mustBeStoppedPropagation.current = false;
1382
+ stateRef.current.mustBeStoppedPropagation = false;
1333
1383
  }, []);
1334
1384
 
1335
- // Handle mouse up to end dragging
1385
+ // Обробляємо відпускання кнопки миші для завершення перетягування
1336
1386
  const handleMouseUp = useCallback(
1337
1387
  (event: React.MouseEvent<HTMLCanvasElement>) => {
1338
- const wasDragging = isDraggingRef.current;
1388
+ const wasDragging = stateRef.current.isDragging;
1339
1389
 
1340
1390
  if (wasDragging) {
1341
- mustBeStoppedPropagation.current = true;
1391
+ stateRef.current.mustBeStoppedPropagation = true;
1342
1392
  }
1343
- // Process node clicks or button clicks only if we haven't been dragging
1344
- if (!wasDragging && mouseStartPosRef.current) {
1393
+ // Обробляємо кліки на вузли або кнопки тільки якщо ми не перетягували
1394
+ if (!wasDragging && stateRef.current.mouseStartPos) {
1345
1395
  const rect = canvasRef.current?.getBoundingClientRect();
1346
1396
  if (rect) {
1347
1397
  const x = event.clientX - rect.left;
1348
1398
  const y = event.clientY - rect.top;
1349
1399
 
1350
- // First check if we're clicking on a button of the selected node
1400
+ // Спочатку перевіряємо, чи ми клікаємо на кнопку обраного вузла
1351
1401
  let isButtonClick = false;
1352
- if (selectedNode && hoveredButtonIndex !== null && buttons[hoveredButtonIndex]) {
1402
+ if (
1403
+ stateRef.current.selectedNode &&
1404
+ stateRef.current.hoveredButtonIndex !== null &&
1405
+ buttons[stateRef.current.hoveredButtonIndex]
1406
+ ) {
1353
1407
  // This is a button click, trigger the button's onClick handler
1354
- const button = buttons[hoveredButtonIndex];
1408
+ const button = buttons[stateRef.current.hoveredButtonIndex];
1355
1409
  if (button && button.onClick) {
1356
- button.onClick(selectedNode);
1410
+ button.onClick(stateRef.current.selectedNode);
1357
1411
  isButtonClick = true;
1358
1412
  }
1359
1413
  }
1360
1414
 
1361
1415
  // If not a button click and we have a draggedNode (mouse was pressed on a node), trigger node click
1362
- if (!isButtonClick && draggedNode) {
1363
- handleNodeClick(draggedNode);
1364
- } else if (!isButtonClick && !draggedNode) {
1365
- // If we didn't click on a node or a button, it's a background click
1366
- // Only trigger background click if there was no dragging
1367
- handleBackgroundClick();
1416
+ if (!isButtonClick && stateRef.current.draggedNode) {
1417
+ handleNodeClick(stateRef.current.draggedNode);
1418
+ } else if (!isButtonClick && !stateRef.current.draggedNode) {
1419
+ // Check if we clicked on a link
1420
+ const clickedLink = getLinkAtPosition(x, y);
1421
+ if (clickedLink) {
1422
+ handleLinkClick(clickedLink);
1423
+ } else {
1424
+ // If we didn't click on a node, button, or link, it's a background click
1425
+ // Only trigger background click if there was no dragging
1426
+ handleBackgroundClick();
1427
+ }
1368
1428
  }
1369
1429
  }
1370
1430
  }
1371
1431
 
1372
- if (draggedNode && simulationRef.current) {
1373
- // If real dragging occurred, optimize simulation parameters
1432
+ if (stateRef.current.draggedNode && simulationRef.current) {
1433
+ // Якщо відбулось реальне перетягування, оптимізуємо параметри симуляції
1374
1434
  if (wasDragging) {
1375
- // Gradually reduce the simulation energy
1435
+ // Поступово зменшуємо енергію симуляції
1376
1436
  simulationRef.current.alphaTarget(0);
1377
1437
 
1378
- // Optimize simulation parameters for better stabilization
1379
- const alphaValue = 0.05; // Low alpha for gentle settling
1380
- const alphaDecayValue = 0.04; // Moderate decay to stop more quickly
1381
- const velocityDecayValue = 0.6; // Standard velocity decay after dragging
1438
+ // Оптимізуємо параметри симуляції для кращої стабілізації
1439
+ const alphaValue = 0.05; // Низька альфа для плавного затухання
1440
+ const alphaDecayValue = 0.04; // Помірний коефіцієнт затухання для швидшої зупинки
1441
+ const velocityDecayValue = 0.6; // Стандартний коефіцієнт загасання швидкості після перетягування
1382
1442
 
1383
1443
  simulationRef.current.alpha(alphaValue).alphaDecay(alphaDecayValue);
1384
1444
  simulationRef.current.velocityDecay(velocityDecayValue);
1385
1445
  } else {
1386
- // If it was just a click, not a drag, stop simulation immediately
1446
+ // Якщо це був просто клік, а не перетягування, негайно зупиняємо симуляцію
1387
1447
  simulationRef.current.alphaTarget(0);
1388
1448
  }
1389
1449
 
1390
- // Release node position regardless of whether it was dragged or clicked
1391
- draggedNode.fx = undefined;
1392
- draggedNode.fy = undefined;
1450
+ // Звільняємо позицію вузла незалежно від того, чи його перетягували або клікали
1451
+ stateRef.current.draggedNode.fx = undefined;
1452
+ stateRef.current.draggedNode.fy = undefined;
1393
1453
 
1394
- setDraggedNode(null);
1454
+ stateRef.current.draggedNode = null;
1395
1455
  }
1396
1456
 
1397
1457
  // Скидаємо всі стани перетягування
1398
- isDraggingRef.current = false;
1399
- mouseStartPosRef.current = null;
1458
+ stateRef.current.isDragging = false;
1459
+ stateRef.current.mouseStartPos = null;
1400
1460
 
1401
1461
  // End panning if active
1402
- if (isPanning) {
1403
- setIsPanning(false);
1462
+ if (stateRef.current.isPanning) {
1463
+ stateRef.current.isPanning = false;
1404
1464
  }
1465
+
1466
+ renderCanvas2D();
1405
1467
  },
1406
- [draggedNode, isPanning, hoveredButtonIndex, selectedNode, buttons, handleNodeClick, handleBackgroundClick]
1468
+ [buttons, renderCanvas2D, handleNodeClick, handleBackgroundClick, getLinkAtPosition, handleLinkClick]
1407
1469
  );
1408
1470
 
1409
- // Handle wheel event for zooming
1471
+ // Обробляємо подію колеса миші для масштабування
1410
1472
  const handleWheel = useCallback(
1411
1473
  (event: WheelEvent) => {
1412
1474
  event.stopPropagation();
@@ -1414,41 +1476,39 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1414
1476
 
1415
1477
  if (!canvasRef.current) return;
1416
1478
 
1417
- // Get canvas-relative coordinates
1479
+ // Отримуємо координати відносно полотна
1418
1480
  const rect = canvasRef.current.getBoundingClientRect();
1419
1481
  const x = event.clientX - rect.left;
1420
1482
  const y = event.clientY - rect.top;
1421
1483
 
1422
- // Calculate zoom factor
1484
+ // Обчислюємо коефіцієнт масштабування
1423
1485
  const delta = -event.deltaY;
1424
1486
  const scaleFactor = delta > 0 ? 1.1 : 1 / 1.1;
1425
1487
 
1426
- // Calculate new transform with zoom around mouse position
1427
- setTransform((prev) => {
1428
- // Limit zoom level (optional)
1429
- const newScale = prev.k * scaleFactor;
1488
+ // Обчислюємо нову трансформацію з масштабуванням навколо позиції миші
1489
+ const currentTransform = stateRef.current.transform;
1430
1490
 
1431
- if (newScale < 0.01 || newScale > 10) return prev;
1432
- const newK = prev.k * scaleFactor;
1491
+ // Обмежуємо рівень масштабування (опціонально)
1492
+ const newScale = currentTransform.k * scaleFactor;
1433
1493
 
1434
- // Calculate new translation to zoom centered on mouse position
1435
- const newX = x - (x - prev.x) * scaleFactor;
1436
- const newY = y - (y - prev.y) * scaleFactor;
1494
+ if (newScale < 0.01 || newScale > 10) return;
1495
+ const newK = currentTransform.k * scaleFactor;
1437
1496
 
1438
- return {
1439
- k: newK,
1440
- x: newX,
1441
- y: newY,
1442
- };
1443
- });
1497
+ // Обчислюємо нове переміщення для центрування масштабування на позиції миші
1498
+ const newX = x - (x - currentTransform.x) * scaleFactor;
1499
+ const newY = y - (y - currentTransform.y) * scaleFactor;
1500
+
1501
+ // Update transform in stateRef
1502
+ stateRef.current.transform = {
1503
+ k: newK,
1504
+ x: newX,
1505
+ y: newY,
1506
+ };
1507
+ renderCanvas2D();
1444
1508
  },
1445
- [setTransform]
1509
+ [renderCanvas2D]
1446
1510
  );
1447
1511
 
1448
- // Button click handling has been moved directly into the handleMouseDown method
1449
- // This prevents duplicate handling of click events and ensures proper coordination
1450
- // with node selection and dragging logic
1451
-
1452
1512
  useImperativeHandle(
1453
1513
  ref,
1454
1514
  () => ({
@@ -1459,6 +1519,127 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1459
1519
  [zoomToFit, addNodes, removeNodes]
1460
1520
  );
1461
1521
 
1522
+ useEffect(() => {
1523
+ // Initialize canvas context
1524
+ const canvasElement = canvasRef.current;
1525
+ const { width: canvasWidth, height: canvasHeight } = stateRef.current;
1526
+ if (!canvasElement) return;
1527
+
1528
+ // Initialize Canvas 2D context
1529
+ init2DCanvas();
1530
+ setIsRendering(true);
1531
+
1532
+ // Розрахунок центральної позиції з урахуванням розміру полотна
1533
+ const centerX = canvasWidth / 2;
1534
+ const centerY = canvasHeight / 2;
1535
+
1536
+ // Ініціалізація силової симуляції D3
1537
+ const nodeSize = config.nodeSizeBase;
1538
+ const nodeRadius = nodeSize / 2;
1539
+ const linkDistance = nodeSize * 2.5; // Розрахунок відстані між зв'язками на основі розміру вузла
1540
+
1541
+ if (simulationRef.current) simulationRef.current.stop();
1542
+ const simulation = (simulationRef.current = forceSimulation(nodes)
1543
+ .force(
1544
+ 'link',
1545
+ forceLink(links)
1546
+ .id((d: any) => d.id)
1547
+ .distance(linkDistance) // Адаптивна відстань між вузлами на основі розміру
1548
+ .strength(0.9) // Зменшуємо силу зв'язків (значення від 0 до 1)
1549
+ )
1550
+ .force(
1551
+ 'charge',
1552
+ forceManyBody()
1553
+ .strength((-nodeSize / 10) * 200) // Силу відштовхування на основі розміру вузла
1554
+ .theta(0.5) // Оптимізація для стабільності (0.5-1.0)
1555
+ .distanceMin(nodeSize * 2)
1556
+ )
1557
+ .force('x', forceX().strength(0.03)) // Слабка сила для стабілізації по осі X
1558
+ .force('y', forceY().strength(0.03)) // Слабка сила для стабілізації по осі Y
1559
+ .force('center', forceCenter(centerX, centerY).strength(0.05)) // Слабка сила центрування
1560
+ .force(
1561
+ 'collide',
1562
+ forceCollide()
1563
+ .radius(nodeRadius * 2) // Радіус колізії залежно від розміру вузла
1564
+ .iterations(2) // Більше ітерацій для кращого запобігання перекриття
1565
+ .strength(1) // Збільшуємо силу запобігання колізіям
1566
+ )
1567
+ .velocityDecay(0.6)); // Коефіцієнт затухання швидкості для зменшення "тряски"
1568
+
1569
+ return () => {
1570
+ // Cleanup
1571
+ simulation.stop();
1572
+ };
1573
+ }, [nodes, links]);
1574
+
1575
+ useLayoutEffect(() => {
1576
+ const canvasElement = canvasRef.current;
1577
+ if (!canvasElement) return;
1578
+ stateRef.current.width = width * RATIO;
1579
+ stateRef.current.height = height * RATIO;
1580
+ canvasElement.width = stateRef.current.width;
1581
+ canvasElement.height = stateRef.current.height;
1582
+ }, [width, height]);
1583
+
1584
+ useEffect(() => {
1585
+ if (simulationRef.current) {
1586
+ const simulation = simulationRef.current;
1587
+
1588
+ // Update node positions on each tick
1589
+
1590
+ simulation.on('tick', () => {
1591
+ renderCanvas2D();
1592
+ });
1593
+
1594
+ // When simulation ends, stop rendering indicator
1595
+ simulation.on('end', () => {
1596
+ // Render one last time
1597
+
1598
+ if (isRendering) {
1599
+ zoomToFit(0, 20); // Zoom to fit after rendering
1600
+ setTimeout(() => {
1601
+ setIsRendering(false);
1602
+ }, 200);
1603
+ }
1604
+ });
1605
+ }
1606
+
1607
+ if (!isRendering) {
1608
+ renderCanvas2D();
1609
+ }
1610
+ }, [nodes, links, isRendering, renderCanvas2D, zoomToFit]);
1611
+
1612
+ // Set up node relationships (neighbors and links)
1613
+ useEffect(() => {
1614
+ if (!nodes || !links) return;
1615
+
1616
+ // Connect nodes to their neighbors and links
1617
+ links.forEach((link: any) => {
1618
+ const source = typeof link.source === 'object' ? link.source : nodes.find((n: any) => n.id === link.source);
1619
+ const target = typeof link.target === 'object' ? link.target : nodes.find((n: any) => n.id === link.target);
1620
+
1621
+ if (!source || !target) return;
1622
+
1623
+ // Initialize arrays if they don't exist
1624
+ !source.neighbors && (source.neighbors = []);
1625
+ !target.neighbors && (target.neighbors = []);
1626
+ source.neighbors.push(target);
1627
+ target.neighbors.push(source);
1628
+
1629
+ !source.links && (source.links = []);
1630
+ !target.links && (target.links = []);
1631
+ source.links.push(link);
1632
+ target.links.push(link);
1633
+ });
1634
+ }, [nodes, links]);
1635
+
1636
+ // Initialize button images
1637
+ useEffect(() => {
1638
+ if (buttons && buttons.length > 0) {
1639
+ setButtonImages(prepareButtonImages(buttons));
1640
+ }
1641
+ }, [buttons]);
1642
+
1462
1643
  // Add wheel event listener with passive: false
1463
1644
  useEffect(() => {
1464
1645
  const canvas = canvasRef.current;
@@ -1478,13 +1659,12 @@ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
1478
1659
  {(loading || isRendering) && <GraphLoader width={width} height={height} />}
1479
1660
  <Canvas
1480
1661
  ref={canvasRef}
1481
- style={{ width, height, display: isRendering ? 'none' : 'block' }}
1662
+ style={{ width, height, display: loading || isRendering ? 'none' : 'block' }}
1482
1663
  onMouseDown={handleMouseDown}
1483
1664
  onMouseMove={handleMouseMove}
1484
1665
  onMouseUp={handleMouseUp}
1485
1666
  onMouseLeave={handleMouseUp}
1486
1667
  onClick={handleClick}
1487
- // Wheel event is now handled by the useEffect above
1488
1668
  />
1489
1669
  </Wrapper>
1490
1670
  );