@cyber-harbour/ui 1.0.50 → 1.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +98 -4
- package/dist/index.d.ts +98 -4
- package/dist/index.js +275 -193
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +274 -192
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Core/Checkbox/Checkbox.tsx +74 -0
- package/src/Core/Checkbox/index.ts +1 -0
- package/src/Core/Label/Label.tsx +122 -0
- package/src/Core/Label/index.ts +1 -0
- package/src/Core/index.ts +2 -0
- package/src/Graph2D/Graph2D.tsx +692 -512
- package/src/Graph2D/types.ts +40 -0
- package/src/Theme/componentFabric.ts +14 -2
- package/src/Theme/themes/dark.ts +18 -0
- package/src/Theme/themes/light.ts +18 -0
- package/src/Theme/types.ts +13 -0
- package/src/Theme/utils.ts +21 -0
package/src/Graph2D/Graph2D.tsx
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
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 } =
|
|
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 =
|
|
134
|
-
for (let y =
|
|
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
|
|
309
|
-
// const
|
|
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,
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
449
|
+
// Малюємо всі вузли
|
|
422
450
|
nodes.forEach((node) => {
|
|
423
451
|
const { x, y, color: nodeColor, fontColor, label } = node;
|
|
424
|
-
const isHighlighted =
|
|
425
|
-
|
|
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
|
-
//
|
|
458
|
+
// Розмір та позиція вузла
|
|
428
459
|
const size = config.nodeSizeBase;
|
|
429
460
|
const radius = isSelected ? config.nodeSizeBase / 2 : config.nodeSizeBase / 2;
|
|
430
461
|
|
|
431
|
-
//
|
|
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
|
-
//
|
|
472
|
+
// Якщо вузол обрано, малюємо кільце вибору та кнопки
|
|
442
473
|
if (isSelected) {
|
|
443
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
503
|
-
const pixelRatio = window.devicePixelRatio || 1;
|
|
522
|
+
// Отримуємо відношення пікселів пристрою для коректного рендерингу
|
|
504
523
|
|
|
505
524
|
// Очищуємо весь канвас перед новим рендерингом.
|
|
506
|
-
ctx.clearRect(0, 0, width
|
|
525
|
+
ctx.clearRect(0, 0, stateRef.current.width, stateRef.current.height);
|
|
507
526
|
|
|
508
|
-
//
|
|
527
|
+
// Спочатку відображаємо сітку (фон)
|
|
509
528
|
renderGrid(ctx);
|
|
510
529
|
|
|
511
|
-
//
|
|
530
|
+
// Застосовуємо трансформацію (масштабування та панорамування) - використовуємо матричну трансформацію для кращої продуктивності
|
|
512
531
|
ctx.save();
|
|
513
532
|
|
|
514
|
-
//
|
|
515
|
-
ctx.setTransform(
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
//
|
|
547
|
+
// Відновлюємо контекст
|
|
522
548
|
ctx.restore();
|
|
523
|
-
}, [
|
|
549
|
+
}, [renderLinks, renderNodes, renderGrid]);
|
|
524
550
|
|
|
525
551
|
/**
|
|
526
|
-
*
|
|
527
|
-
* @param newNodes
|
|
528
|
-
* @param newLinks
|
|
529
|
-
* @param options
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
600
|
+
// Попереднє позиціонування нових вузлів тільки коли плавна поява увімкнена
|
|
576
601
|
if (smoothAppearance) {
|
|
577
|
-
//
|
|
602
|
+
// Попередньо позиціонуємо нові вузли біля підключених вузлів
|
|
578
603
|
filteredNewNodes.forEach((node) => {
|
|
579
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
629
|
+
// Розташовуємо новий вузол біля підключеного вузла з невеликою рандомізацією
|
|
605
630
|
const randomOffset = 30 + Math.random() * 20;
|
|
606
631
|
const randomAngle = Math.random() * Math.PI * 2;
|
|
607
632
|
|
|
608
|
-
//
|
|
633
|
+
// Встановлюємо початкову позицію
|
|
609
634
|
node.x = connectedNode.x + Math.cos(randomAngle) * randomOffset;
|
|
610
635
|
node.y = connectedNode.y + Math.sin(randomAngle) * randomOffset;
|
|
611
636
|
|
|
612
|
-
//
|
|
637
|
+
// Встановлюємо початкову швидкість в нуль для плавнішої появи
|
|
613
638
|
node.vx = 0;
|
|
614
639
|
node.vy = 0;
|
|
615
640
|
}
|
|
616
641
|
} else {
|
|
617
|
-
//
|
|
618
|
-
const centerX =
|
|
619
|
-
const centerY =
|
|
620
|
-
const radius = Math.min(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
662
|
+
// Оновлюємо симуляцію з новими вузлами та зв'язками
|
|
638
663
|
simulationRef.current.nodes(updatedNodes);
|
|
639
664
|
|
|
640
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
693
|
+
// Налаштовуємо симуляцію для плавної появи нових вузлів
|
|
669
694
|
simulationRef.current.alphaTarget(0.3);
|
|
670
695
|
simulationRef.current.alpha(0.3);
|
|
671
|
-
simulationRef.current.velocityDecay(0.7); //
|
|
696
|
+
simulationRef.current.velocityDecay(0.7); // Більше загасання для плавнішого руху
|
|
672
697
|
simulationRef.current.restart();
|
|
673
698
|
|
|
674
|
-
//
|
|
699
|
+
// Через короткий час відфіксовуємо всі вузли та скидаємо параметри симуляції
|
|
675
700
|
setTimeout(() => {
|
|
676
|
-
//
|
|
701
|
+
// Звільняємо існуючі вузли, щоб дозволити їм природний рух
|
|
677
702
|
nodes.forEach((node) => {
|
|
678
703
|
node.fx = undefined;
|
|
679
704
|
node.fy = undefined;
|
|
680
705
|
});
|
|
681
706
|
|
|
682
|
-
//
|
|
707
|
+
// Скидаємо симуляцію до нормальних налаштувань
|
|
683
708
|
simulationRef.current?.alphaTarget(0);
|
|
684
709
|
simulationRef.current?.alpha(0.1);
|
|
685
|
-
simulationRef.current?.velocityDecay(0.6); //
|
|
710
|
+
simulationRef.current?.velocityDecay(0.6); // Скидаємо до значення за замовчуванням
|
|
686
711
|
}, transitionDuration);
|
|
687
712
|
} else {
|
|
688
|
-
//
|
|
713
|
+
// Стандартний перезапуск з низькою енергією для мінімального руху
|
|
689
714
|
simulationRef.current.alpha(0.1).restart();
|
|
690
715
|
}
|
|
691
716
|
|
|
692
|
-
//
|
|
717
|
+
// Перемальовуємо канвас
|
|
693
718
|
renderCanvas2D();
|
|
694
719
|
},
|
|
695
|
-
[
|
|
720
|
+
[nodes, renderCanvas2D]
|
|
696
721
|
);
|
|
697
722
|
|
|
698
723
|
/**
|
|
699
|
-
*
|
|
700
|
-
* @param nodeIds
|
|
701
|
-
* @param options
|
|
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
|
-
//
|
|
735
|
+
// Створюємо набір ID вузлів для швидкого пошуку
|
|
711
736
|
const nodeIdsToRemove = new Set(nodeIds);
|
|
712
737
|
|
|
713
|
-
//
|
|
714
|
-
if (
|
|
715
|
-
|
|
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 (
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
745
|
-
//
|
|
802
|
+
// Перебудовуємо відносини вузлів (сусіди та зв'язки) для вузлів, що залишилися
|
|
803
|
+
// Спочатку очищаємо існуючі відносини
|
|
746
804
|
remainingNodes.forEach((node) => {
|
|
747
805
|
node.neighbors = [];
|
|
748
806
|
node.links = [];
|
|
749
807
|
});
|
|
750
808
|
|
|
751
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
[
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
const
|
|
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
|
-
//
|
|
908
|
-
const
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
});
|
|
972
|
+
// Пороговая відстань для визначення кліку по лінку
|
|
973
|
+
const threshold = 5;
|
|
953
974
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
961
|
-
setIsRendering(false);
|
|
962
|
-
}, 200);
|
|
963
|
-
}
|
|
964
|
-
});
|
|
965
|
-
}
|
|
980
|
+
if (!source || !target) return false;
|
|
966
981
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
987
|
+
// Вычисляем расстояние от точки до линии
|
|
988
|
+
const A = scaledX - sourceX;
|
|
989
|
+
const B = scaledY - sourceY;
|
|
990
|
+
const C = targetX - sourceX;
|
|
991
|
+
const D = targetY - sourceY;
|
|
975
992
|
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
996
|
+
if (lenSq === 0) return false; // Линия нулевой длины
|
|
984
997
|
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1029
|
+
// Розраховуємо кут між точкою та горизонтальною віссю
|
|
1048
1030
|
let angle = Math.atan2(dy, dx);
|
|
1049
|
-
if (angle < 0) angle += 2 * Math.PI; //
|
|
1031
|
+
if (angle < 0) angle += 2 * Math.PI; // Конвертуємо в діапазон [0, 2π]
|
|
1050
1032
|
|
|
1051
|
-
//
|
|
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
|
-
//
|
|
1038
|
+
// Перевіряємо, чи кут знаходиться в межах сектора
|
|
1057
1039
|
let isInAngle = false;
|
|
1058
1040
|
|
|
1059
|
-
//
|
|
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
|
-
//
|
|
1045
|
+
// Нижнє півколо: від 0 до Math.PI
|
|
1064
1046
|
else if (startAngle === 0 && endAngle === Math.PI) {
|
|
1065
1047
|
isInAngle = angle >= 0 && angle <= Math.PI;
|
|
1066
1048
|
}
|
|
1067
|
-
//
|
|
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
|
-
//
|
|
1061
|
+
// Обробка наведення на вузол (подібно до Graph2D handleNodeHover)
|
|
1080
1062
|
const handleNodeHover = useCallback(
|
|
1081
1063
|
(node: NodeObject | null) => {
|
|
1082
|
-
//
|
|
1083
|
-
if (node ===
|
|
1084
|
-
return; //
|
|
1064
|
+
// Перевіряємо, чи вузол той самий, що і останній вузол, на який наводили
|
|
1065
|
+
if (node === stateRef.current.lastHoveredNodeRef) {
|
|
1066
|
+
return; // Пропускаємо обробку, якщо це той самий вузол
|
|
1085
1067
|
}
|
|
1086
1068
|
|
|
1087
|
-
//
|
|
1088
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1088
|
+
stateRef.current.hoveredNode = node;
|
|
1107
1089
|
if (onNodeHover) onNodeHover(node);
|
|
1108
|
-
|
|
1109
|
-
|
|
1090
|
+
stateRef.current.highlightNodes = newHighlightNodes;
|
|
1091
|
+
stateRef.current.highlightLinks = newHighlightLinks;
|
|
1110
1092
|
},
|
|
1111
1093
|
[onNodeHover]
|
|
1112
1094
|
);
|
|
1113
1095
|
|
|
1114
|
-
//
|
|
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
|
-
|
|
1141
|
+
stateRef.current.selectedNode = node;
|
|
1118
1142
|
if (onNodeClick) onNodeClick(node);
|
|
1119
1143
|
},
|
|
1120
1144
|
[onNodeClick]
|
|
1121
1145
|
);
|
|
1122
1146
|
|
|
1123
|
-
//
|
|
1147
|
+
// Обробка кліку на фон
|
|
1124
1148
|
const handleBackgroundClick = useCallback(() => {
|
|
1125
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1144
|
-
|
|
1163
|
+
stateRef.current.mouseStartPos = { x, y };
|
|
1164
|
+
stateRef.current.isDragging = false;
|
|
1145
1165
|
|
|
1146
|
-
//
|
|
1166
|
+
// Намагаємося знайти вузол у позиції курсора - обробимо клік при mouseUp, якщо не відбувається перетягування
|
|
1147
1167
|
const node = getNodeAtPosition(x, y);
|
|
1148
1168
|
if (node) {
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1169
|
+
// Встановлюємо як потенційно перетягуваний, але не активуємо симуляцію поки що
|
|
1170
|
+
stateRef.current.draggedNode = node;
|
|
1151
1171
|
|
|
1152
|
-
//
|
|
1172
|
+
// Тимчасово фіксуємо позицію вузла
|
|
1153
1173
|
node.fx = node.x;
|
|
1154
1174
|
node.fy = node.y;
|
|
1155
1175
|
} else {
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1158
|
-
|
|
1176
|
+
// Якщо не клікнули на вузол, починаємо панорамування
|
|
1177
|
+
stateRef.current.isPanning = true;
|
|
1178
|
+
stateRef.current.lastMousePos = { x, y };
|
|
1159
1179
|
}
|
|
1160
1180
|
},
|
|
1161
1181
|
[getNodeAtPosition]
|
|
1162
1182
|
);
|
|
1163
1183
|
|
|
1164
|
-
//
|
|
1184
|
+
// Обробка руху миші для перетягування та наведення
|
|
1165
1185
|
const handleMouseMove = useCallback(
|
|
1166
1186
|
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
1167
1187
|
if (!canvasRef.current) return;
|
|
1168
1188
|
|
|
1169
|
-
//
|
|
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 &&
|
|
1176
|
-
const startX =
|
|
1177
|
-
const startY =
|
|
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
|
-
//
|
|
1189
|
-
|
|
1208
|
+
// Це точно операція перетягування, а не просто клік
|
|
1209
|
+
stateRef.current.isDragging = true;
|
|
1190
1210
|
|
|
1191
|
-
//
|
|
1211
|
+
// Якщо це перше виявлення перетягування, налаштовуємо симуляцію
|
|
1192
1212
|
if (simulationRef.current.alphaTarget() === 0) {
|
|
1193
|
-
//
|
|
1194
|
-
const alphaValue = 0
|
|
1213
|
+
// Встановлюємо alphaTarget на значення, що базується на розмірі вузла для відповідної інтенсивності руху
|
|
1214
|
+
const alphaValue = 0;
|
|
1195
1215
|
simulationRef.current.alphaTarget(alphaValue).restart();
|
|
1196
1216
|
|
|
1197
|
-
//
|
|
1198
|
-
const decayValue = 0.
|
|
1199
|
-
simulationRef.current.velocityDecay(decayValue);
|
|
1217
|
+
// // Регулюємо швидкість загасання для кращої стабільності під час перетягування
|
|
1218
|
+
// const decayValue = 0.2;
|
|
1219
|
+
// simulationRef.current.velocityDecay(decayValue);
|
|
1200
1220
|
}
|
|
1201
1221
|
}
|
|
1202
1222
|
|
|
1203
|
-
//
|
|
1223
|
+
// Масштабуємо координати на основі співвідношення пікселів пристрою та поточної трансформації
|
|
1204
1224
|
const pixelRatio = window.devicePixelRatio || 1;
|
|
1205
1225
|
|
|
1206
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1234
|
+
// if (stateRef.current.isDragging) {
|
|
1235
|
+
// // Зменшуємо енергію симуляції під час перетягування для стабільності
|
|
1236
|
+
// simulationRef.current.alpha(0); // Зменшуємо енергію системи
|
|
1237
|
+
// }
|
|
1218
1238
|
|
|
1219
|
-
//
|
|
1239
|
+
// Немає потреби перевіряти наведення під час перетягування
|
|
1220
1240
|
return;
|
|
1221
1241
|
}
|
|
1222
1242
|
|
|
1223
|
-
//
|
|
1224
|
-
if (isPanning &&
|
|
1225
|
-
const dx = x -
|
|
1226
|
-
const dy = 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
|
-
//
|
|
1229
|
-
const startX =
|
|
1230
|
-
const startY =
|
|
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
|
-
|
|
1257
|
+
stateRef.current.isDragging = true;
|
|
1238
1258
|
}
|
|
1239
1259
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1279
|
+
// Масштабуємо координати відносно розміру відображення полотна
|
|
1257
1280
|
const canvasScaleX = canvasRef.current.width / rect.width;
|
|
1258
1281
|
const canvasScaleY = canvasRef.current.height / rect.height;
|
|
1259
1282
|
|
|
1260
|
-
//
|
|
1283
|
+
// Масштабовані координати миші в системі координат полотна
|
|
1261
1284
|
const scaledMouseX = x * canvasScaleX;
|
|
1262
1285
|
const scaledMouseY = y * canvasScaleY;
|
|
1263
1286
|
|
|
1264
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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)
|
|
1317
|
+
if (stateRef.current.hoveredButtonIndex !== null) stateRef.current.hoveredButtonIndex = null;
|
|
1292
1318
|
}
|
|
1293
1319
|
|
|
1294
1320
|
if (!hoveredNode) {
|
|
1295
|
-
// If no node is hovered,
|
|
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
|
-
//
|
|
1356
|
+
// Перевіряємо наведення та оновлюємо підсвічування
|
|
1300
1357
|
|
|
1301
|
-
//
|
|
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
|
-
|
|
1367
|
+
buttonImages,
|
|
1308
1368
|
getNodeAtPosition,
|
|
1309
|
-
|
|
1310
|
-
transform,
|
|
1369
|
+
getLinkAtPosition,
|
|
1311
1370
|
handleNodeHover,
|
|
1312
|
-
|
|
1313
|
-
|
|
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 (
|
|
1378
|
+
if (stateRef.current.mustBeStoppedPropagation) {
|
|
1329
1379
|
event.stopPropagation();
|
|
1330
1380
|
event.preventDefault();
|
|
1331
1381
|
}
|
|
1332
|
-
|
|
1382
|
+
stateRef.current.mustBeStoppedPropagation = false;
|
|
1333
1383
|
}, []);
|
|
1334
1384
|
|
|
1335
|
-
//
|
|
1385
|
+
// Обробляємо відпускання кнопки миші для завершення перетягування
|
|
1336
1386
|
const handleMouseUp = useCallback(
|
|
1337
1387
|
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
1338
|
-
const wasDragging =
|
|
1388
|
+
const wasDragging = stateRef.current.isDragging;
|
|
1339
1389
|
|
|
1340
1390
|
if (wasDragging) {
|
|
1341
|
-
|
|
1391
|
+
stateRef.current.mustBeStoppedPropagation = true;
|
|
1342
1392
|
}
|
|
1343
|
-
//
|
|
1344
|
-
if (!wasDragging &&
|
|
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
|
-
//
|
|
1400
|
+
// Спочатку перевіряємо, чи ми клікаємо на кнопку обраного вузла
|
|
1351
1401
|
let isButtonClick = false;
|
|
1352
|
-
if (
|
|
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
|
-
//
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
//
|
|
1432
|
+
if (stateRef.current.draggedNode && simulationRef.current) {
|
|
1433
|
+
// Якщо відбулось реальне перетягування, оптимізуємо параметри симуляції
|
|
1374
1434
|
if (wasDragging) {
|
|
1375
|
-
//
|
|
1435
|
+
// Поступово зменшуємо енергію симуляції
|
|
1376
1436
|
simulationRef.current.alphaTarget(0);
|
|
1377
1437
|
|
|
1378
|
-
//
|
|
1379
|
-
const alphaValue = 0.05; //
|
|
1380
|
-
const alphaDecayValue = 0.04; //
|
|
1381
|
-
const velocityDecayValue = 0.6; //
|
|
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
|
-
//
|
|
1446
|
+
// Якщо це був просто клік, а не перетягування, негайно зупиняємо симуляцію
|
|
1387
1447
|
simulationRef.current.alphaTarget(0);
|
|
1388
1448
|
}
|
|
1389
1449
|
|
|
1390
|
-
//
|
|
1391
|
-
draggedNode.fx = undefined;
|
|
1392
|
-
draggedNode.fy = undefined;
|
|
1450
|
+
// Звільняємо позицію вузла незалежно від того, чи його перетягували або клікали
|
|
1451
|
+
stateRef.current.draggedNode.fx = undefined;
|
|
1452
|
+
stateRef.current.draggedNode.fy = undefined;
|
|
1393
1453
|
|
|
1394
|
-
|
|
1454
|
+
stateRef.current.draggedNode = null;
|
|
1395
1455
|
}
|
|
1396
1456
|
|
|
1397
1457
|
// Скидаємо всі стани перетягування
|
|
1398
|
-
|
|
1399
|
-
|
|
1458
|
+
stateRef.current.isDragging = false;
|
|
1459
|
+
stateRef.current.mouseStartPos = null;
|
|
1400
1460
|
|
|
1401
1461
|
// End panning if active
|
|
1402
|
-
if (isPanning) {
|
|
1403
|
-
|
|
1462
|
+
if (stateRef.current.isPanning) {
|
|
1463
|
+
stateRef.current.isPanning = false;
|
|
1404
1464
|
}
|
|
1465
|
+
|
|
1466
|
+
renderCanvas2D();
|
|
1405
1467
|
},
|
|
1406
|
-
[
|
|
1468
|
+
[buttons, renderCanvas2D, handleNodeClick, handleBackgroundClick, getLinkAtPosition, handleLinkClick]
|
|
1407
1469
|
);
|
|
1408
1470
|
|
|
1409
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1484
|
+
// Обчислюємо коефіцієнт масштабування
|
|
1423
1485
|
const delta = -event.deltaY;
|
|
1424
1486
|
const scaleFactor = delta > 0 ? 1.1 : 1 / 1.1;
|
|
1425
1487
|
|
|
1426
|
-
//
|
|
1427
|
-
|
|
1428
|
-
// Limit zoom level (optional)
|
|
1429
|
-
const newScale = prev.k * scaleFactor;
|
|
1488
|
+
// Обчислюємо нову трансформацію з масштабуванням навколо позиції миші
|
|
1489
|
+
const currentTransform = stateRef.current.transform;
|
|
1430
1490
|
|
|
1431
|
-
|
|
1432
|
-
|
|
1491
|
+
// Обмежуємо рівень масштабування (опціонально)
|
|
1492
|
+
const newScale = currentTransform.k * scaleFactor;
|
|
1433
1493
|
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
[
|
|
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
|
);
|