@cyber-harbour/ui 1.0.45 → 1.0.46

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,980 +1,1506 @@
1
- import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from 'react-force-graph-2d';
2
- import { Graph2DProps } from './types';
3
- import { useEffect, useRef, useState, useCallback } from 'react';
4
- import { forceCollide } from 'd3-force';
1
+ import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo } from 'react';
2
+ import { Graph2DProps, LinkObject, NodeObject, Graph2DRef } from './types';
3
+
4
+ import cloneDeep from 'lodash.clonedeep';
5
+
6
+ import {
7
+ forceCenter,
8
+ forceLink,
9
+ forceManyBody,
10
+ forceSimulation,
11
+ forceCollide,
12
+ forceX,
13
+ forceY,
14
+ Simulation,
15
+ ForceLink,
16
+ scaleOrdinal,
17
+ schemeCategory10,
18
+ } from 'd3';
5
19
  import { styled, useTheme } from 'styled-components';
6
- import eyeLightIcon from './eye_light.png';
7
- import eyeLightHoverIcon from './eye_light_hover.png';
8
- import groupLightIcon from './group_light.png';
9
- import groupLightHoverIcon from './group_light_hover.png';
10
20
  import GraphLoader from './GraphLoader';
11
21
 
12
- // Створюємо та налаштовуємо об'єкти зображень
13
- const imgEyeLightIcon = new Image();
14
- imgEyeLightIcon.src = eyeLightIcon.replace('./', '/');
15
-
16
- const imgEyeLightHoverIcon = new Image();
17
- imgEyeLightHoverIcon.src = eyeLightHoverIcon.replace('./', '/');
18
-
19
- const imgGroupLightIcon = new Image();
20
- imgGroupLightIcon.src = groupLightIcon.replace('./', '/');
21
-
22
- const imgGroupLightHoverIcon = new Image();
23
- imgGroupLightHoverIcon.src = groupLightHoverIcon.replace('./', '/');
24
-
25
- export const Graph2D = ({
26
- graphData,
27
- width,
28
- height,
29
- linkTarget,
30
- linkSource,
31
- loading = false,
32
- config = {
33
- fontSize: 3, // Максимальний розмір шрифту при максимальному зумі
34
- nodeSizeBase: 30, // Базовий розмір вузла
35
- nodeAreaFactor: 2, // Фактор збільшення розміру вузла для відображення області (hover)
36
- textPaddingFactor: 0.9, // Скільки разів текст може бути ширшим за розмір вузла
37
- gridSpacing: 20, // Відстань між точками сітки
38
- dotSize: 1, // Розмір точки сітки,
39
- maxZoom: 4, // Максимальний зум
40
- },
41
- onNodeClick,
42
- onNodeHover,
43
- onLinkHover,
44
- onLinkClick,
45
- onBackgroundClick,
46
- onClickLoadNodes,
47
- }: Graph2DProps) => {
48
- const theme = useTheme();
49
-
50
- // Стан для підсвічування вузлів і зв'язків
51
- const [highlightNodes, setHighlightNodes] = useState(new Set());
52
- const [highlightLinks, setHighlightLinks] = useState(new Set());
53
- const [hoverNode, setHoverNode] = useState<any>(null);
54
- const [selectedNode, setSelectedNode] = useState<any>(null);
55
- const [unVisibleNodes, setUnVisibleNodes] = useState(new Set<string>());
56
- const [hiddenNodes, setHiddenNodes] = useState(new Set<string>());
57
- // Стани для відстеження наведення на кнопки
58
- const [hoverTopButton, setHoverTopButton] = useState(false);
59
- const [hoverBottomButton, setHoverBottomButton] = useState(false);
60
- const [isRendering, setIsRendering] = useState(true);
61
-
62
- const fgRef = useRef<
63
- ForceGraphMethods & {
64
- tick?: number;
65
- }
66
- >(null) as React.MutableRefObject<
67
- ForceGraphMethods<NodeObject, LinkObject> & {
68
- tick?: number;
69
- }
70
- >;
71
- const wrapperRef = useRef<HTMLDivElement>(null);
72
- const tickTimerRef = useRef<NodeJS.Timeout | null>(null);
73
-
74
- // Функція для реверсивного масштабування тексту
75
- // При максимальному зумі текст має розмір config.fontSize
76
- // При зменшенні зуму текст також зменшується
77
- const calculateFontSize = (scale: number): number => {
78
- // Обмежуємо масштаб до config.maxZoom
79
- const limitedScale = Math.min(scale, config.maxZoom);
80
-
81
- // Обчислюємо коефіцієнт масштабування: при максимальному зумі = 1, при мінімальному - менше
82
- const fontSizeRatio = limitedScale / config.maxZoom;
83
-
84
- return Math.max(config.fontSize * fontSizeRatio, config.fontSize);
85
- };
86
-
87
- // Обробка подій наведення на вузол
88
- const handleNodeHover = (node: NodeObject | null, _: NodeObject | null) => {
89
- const newHighlightNodes = new Set();
90
- const newHighlightLinks = new Set();
91
-
92
- if (node) {
93
- newHighlightNodes.add(node);
94
-
95
- // Додавання сусідніх вузлів і зв'язків до підсвічування
96
- // Перевіряємо наявність сусідів і зв'язків
97
- if (node.neighbors) {
98
- node.neighbors.forEach((neighbor: any) => newHighlightNodes.add(neighbor));
22
+ // Завантаження та підготовка зображень кнопок
23
+ function prepareButtonImages(buttons: Graph2DProps['buttons']) {
24
+ if (!buttons || buttons.length === 0) return [];
25
+
26
+ return buttons.map((button) => {
27
+ const normalImg = new Image();
28
+ normalImg.src = button.img;
29
+
30
+ const hoverImg = new Image();
31
+ hoverImg.src = button.hoverImg;
32
+
33
+ return {
34
+ ...button,
35
+ normalImg,
36
+ hoverImg,
37
+ };
38
+ });
39
+ }
40
+
41
+ // Конфігурація подібна до Graph2D
42
+ const config = {
43
+ fontSize: 3,
44
+ nodeSizeBase: 30,
45
+ nodeAreaFactor: 2,
46
+ textPaddingFactor: 0.9,
47
+ gridSpacing: 20,
48
+ dotSize: 1,
49
+ maxZoom: 4,
50
+ };
51
+
52
+ export const Graph2D: any = forwardRef<Graph2DRef, Graph2DProps>(
53
+ ({ loading, width, height, graphData, buttons = [], onNodeClick, onBackgroundClick, onNodeHover }, ref) => {
54
+ const theme = useTheme();
55
+ 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);
59
+
60
+ const { nodes, links } = useMemo(() => cloneDeep(graphData), [graphData]);
61
+
62
+ // Стани кнопок
63
+ const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null);
64
+ const [buttonImages, setButtonImages] = useState<any[]>([]);
65
+
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
+ const canvasRef = useRef<HTMLCanvasElement>(null);
78
+ const simulationRef = useRef<Simulation<NodeObject, LinkObject> | null>(null);
79
+ const lastHoveredNodeRef = useRef<NodeObject | null>(null);
80
+
81
+ // Контекст Canvas для 2D рендерингу
82
+ const ctx2dRef = useRef<CanvasRenderingContext2D | null>(null);
83
+ const color = scaleOrdinal(schemeCategory10);
84
+
85
+ // Ініціалізація контексту Canvas 2D
86
+ const init2DCanvas = useCallback(() => {
87
+ if (!canvasRef.current) return false;
88
+
89
+ try {
90
+ const ctx = canvasRef.current.getContext('2d');
91
+ if (!ctx) {
92
+ console.error('Failed to get 2D context');
93
+ return false;
94
+ }
95
+
96
+ ctx2dRef.current = ctx;
97
+ return true;
98
+ } catch (e) {
99
+ console.error('Error initializing Canvas 2D context:', e);
100
+ return false;
99
101
  }
102
+ }, []);
103
+
104
+ const getNodes = useCallback(() => {
105
+ if (!simulationRef.current) return null;
106
+ return simulationRef.current.nodes();
107
+ }, []);
108
+
109
+ const getLinks = useCallback(() => {
110
+ if (!simulationRef.current) return null;
111
+
112
+ const linkForce = simulationRef.current.force('link') as ForceLink<NodeObject, LinkObject>;
113
+ const links = linkForce ? linkForce.links() : null;
114
+ return links;
115
+ }, []);
116
+
117
+ // Рендеринг сітки на полотні
118
+ const renderGrid = useCallback(
119
+ (ctx: CanvasRenderingContext2D) => {
120
+ // Зберігаємо поточний стан контексту
121
+ ctx.save();
122
+
123
+ // Скидаємо трансформацію для малювання фону в координатах екрану
124
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
125
+
126
+ // Малюємо фонові крапки
127
+ const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
128
+ const gridSpacing = config.gridSpacing;
129
+ const dotSize = config.dotSize;
130
+
131
+ ctx.fillStyle = theme.graph2D.grid.dotColor;
132
+
133
+ for (let x = 0; x < canvasWidth; x += gridSpacing) {
134
+ for (let y = 0; y < canvasHeight; y += gridSpacing) {
135
+ ctx.beginPath();
136
+ ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
137
+ ctx.fill();
138
+ }
139
+ }
140
+
141
+ // Відновлюємо оригінальну трансформацію для іншого рендерингу
142
+ ctx.restore();
143
+ },
144
+ [theme.graph2D.grid.dotColor, config.gridSpacing, config.dotSize]
145
+ );
146
+
147
+ // Функція для обрізання тексту з додаванням трикрапки
148
+ const truncateText = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
149
+ if (!text) return '';
100
150
 
101
- if (node.links) {
102
- node.links.forEach((link: any) => newHighlightLinks.add(link));
151
+ // Вимірюємо ширину тексту
152
+ const textWidth = ctx.measureText(text).width;
153
+
154
+ // Повертаємо як є, якщо коротше за максимальну ширину
155
+ if (textWidth <= maxWidth) return text;
156
+
157
+ // Інакше обрізаємо і додаємо трикрапку
158
+ let truncated = text;
159
+ const ellipsis = '...';
160
+
161
+ // Поступово зменшуємо текст, поки він не поміститься
162
+ while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
163
+ truncated = truncated.slice(0, -1);
103
164
  }
104
- }
105
-
106
- onNodeHover?.(node);
107
-
108
- setHoverNode(node || null);
109
- setHighlightNodes(newHighlightNodes);
110
- setHighlightLinks(newHighlightLinks);
111
- };
112
-
113
- // Обробка подій наведення на зв'язок
114
- const handleLinkHover = (link: any) => {
115
- const newHighlightNodes = new Set();
116
- const newHighlightLinks = new Set();
117
-
118
- if (link) {
119
- newHighlightLinks.add(link);
120
- newHighlightNodes.add(link.source);
121
- newHighlightNodes.add(link.target);
122
- onLinkHover?.(link);
123
- }
124
-
125
- setHighlightNodes(newHighlightNodes);
126
- setHighlightLinks(newHighlightLinks);
127
- };
128
-
129
- const handleEngineTick = () => {
130
- if (isRendering) {
131
- if (fgRef.current && graphData) {
132
- if (fgRef.current.tick && graphData.nodes.length > 0 && graphData.nodes.length <= fgRef.current.tick) {
133
- if (tickTimerRef.current) {
134
- clearTimeout(tickTimerRef.current);
165
+
166
+ return truncated + ellipsis;
167
+ };
168
+
169
+ // Розрахунок розміру шрифту на основі масштабу/зуму
170
+ const calculateFontSize = (scale: number): number => {
171
+ // Обмежуємо масштаб до максимального зуму
172
+ const limitedScale = Math.min(scale, config.maxZoom);
173
+
174
+ // Масштабуємо розмір шрифту пропорційно
175
+ const fontSizeRatio = limitedScale / config.maxZoom;
176
+
177
+ return Math.max(config.fontSize * fontSizeRatio, config.fontSize);
178
+ };
179
+
180
+ // Рендеринг зв'язків на полотні - подібно до реалізації Graph2D
181
+ const renderLinks = useCallback(
182
+ (ctx: CanvasRenderingContext2D) => {
183
+ const links = getLinks();
184
+ const nodes = getNodes();
185
+ if (!links || links.length === 0 || !nodes || nodes.length === 0) return;
186
+ ctx.lineWidth = 0.5;
187
+ ctx.globalAlpha = 1.0;
188
+
189
+ links.forEach((link) => {
190
+ const source = typeof link.source === 'object' ? link.source : nodes.find((n) => n.id === link.source);
191
+ const target = typeof link.target === 'object' ? link.target : nodes.find((n) => n.id === link.target);
192
+
193
+ if (!source || !target) return;
194
+
195
+ // Координати для початку і кінця
196
+ const start = { x: source.x || 0, y: source.y || 0 };
197
+ const end = { x: target.x || 0, y: target.y || 0 };
198
+
199
+ // Обчислення відстані і напрямку
200
+ const dx = end.x - start.x;
201
+ const dy = end.y - start.y;
202
+ const distance = Math.sqrt(dx * dx + dy * dy);
203
+
204
+ // Нормалізований вектор напрямку
205
+ const unitDx = dx / distance;
206
+ const unitDy = dy / distance;
207
+
208
+ // Коригування радіусу вузлів
209
+ const sourceRadius = config.nodeSizeBase / 2;
210
+ const targetRadius = config.nodeSizeBase / 2;
211
+ const arrowHeadLength = 4;
212
+
213
+ // Коригуємо початкову і кінцеву позиції, щоб починалися/закінчувалися на краях вузлів
214
+ const adjustedStart = {
215
+ x: start.x + unitDx * sourceRadius,
216
+ y: start.y + unitDy * sourceRadius,
217
+ };
218
+
219
+ const adjustedEnd = {
220
+ x: end.x - unitDx * (targetRadius + arrowHeadLength),
221
+ y: end.y - unitDy * (targetRadius + arrowHeadLength),
222
+ };
223
+
224
+ const adjusteArrowdEnd = {
225
+ x: end.x - unitDx * (targetRadius + 1),
226
+ y: end.y - unitDy * (targetRadius + 1),
227
+ };
228
+
229
+ // Колір лінії залежить від стану виділення
230
+ const isHighlighted = highlightLinks.has(link);
231
+ const lineColor = isHighlighted ? theme.graph2D.link.highlighted : theme.graph2D.link.normal;
232
+ const lineWidth = isHighlighted ? 1.5 : 0.5;
233
+
234
+ // Обчислення середини лінії для розміщення тексту
235
+ const middleX = start.x + (end.x - start.x) / 2;
236
+ const middleY = start.y + (end.y - start.y) / 2;
237
+ const angle = Math.atan2(dy, dx);
238
+
239
+ // Малюємо лінію у дві частини, якщо є мітка
240
+ if (link.label) {
241
+ // Обчислюємо ширину тексту для проміжку
242
+ const globalScale = transform.k;
243
+ const scaledFontSize = calculateFontSize(globalScale);
244
+ ctx.font = `${scaledFontSize}px Sans-Serif`;
245
+ const textWidth = ctx.measureText(link.label).width;
246
+ const padding = 10; // Додатковий простір навколо тексту
247
+
248
+ // Перша частина лінії
249
+ ctx.beginPath();
250
+ ctx.moveTo(adjustedStart.x, adjustedStart.y);
251
+
252
+ // Обчислюємо точку перед текстом
253
+ const beforeTextDistance = distance / 2 - (textWidth + padding) / 2;
254
+ const pointBeforeText = {
255
+ x: start.x + unitDx * beforeTextDistance,
256
+ y: start.y + unitDy * beforeTextDistance,
257
+ };
258
+ ctx.lineTo(pointBeforeText.x, pointBeforeText.y);
259
+ ctx.strokeStyle = lineColor;
260
+ ctx.lineWidth = lineWidth;
261
+ ctx.stroke();
262
+
263
+ // Друга частина лінії
264
+ ctx.beginPath();
265
+ // Обчислюємо точку після тексту
266
+ const afterTextDistance = distance / 2 + (textWidth + padding) / 2;
267
+ const pointAfterText = {
268
+ x: start.x + unitDx * afterTextDistance,
269
+ y: start.y + unitDy * afterTextDistance,
270
+ };
271
+ ctx.moveTo(pointAfterText.x, pointAfterText.y);
272
+ ctx.lineTo(adjustedEnd.x, adjustedEnd.y);
273
+ ctx.strokeStyle = lineColor;
274
+ ctx.lineWidth = lineWidth;
275
+ ctx.stroke();
276
+ } else {
277
+ // Малюємо суцільну лінію якщо немає мітки
278
+ ctx.beginPath();
279
+ ctx.moveTo(adjustedStart.x, adjustedStart.y);
280
+ ctx.lineTo(adjustedEnd.x, adjustedEnd.y);
281
+ ctx.strokeStyle = lineColor;
282
+ ctx.lineWidth = lineWidth;
283
+ ctx.stroke();
135
284
  }
136
- fgRef.current.zoomToFit(0, 20);
137
- setIsRendering(false);
138
- } else {
139
- fgRef.current.tick = fgRef.current.tick ? (fgRef.current.tick = fgRef.current.tick + 1) : 1;
140
- if (tickTimerRef.current) {
141
- clearTimeout(tickTimerRef.current);
285
+
286
+ // Малюємо стрілку
287
+ const arrowHeadWidth = 2;
288
+
289
+ ctx.save();
290
+ ctx.translate(adjusteArrowdEnd.x, adjusteArrowdEnd.y);
291
+ ctx.rotate(angle);
292
+
293
+ // Наконечник стрілки
294
+ ctx.beginPath();
295
+ ctx.moveTo(0, 0);
296
+ ctx.lineTo(-arrowHeadLength, arrowHeadWidth);
297
+ ctx.lineTo(-arrowHeadLength, 0);
298
+ ctx.lineTo(-arrowHeadLength, -arrowHeadWidth);
299
+ ctx.closePath();
300
+
301
+ ctx.fillStyle = lineColor;
302
+ ctx.fill();
303
+ ctx.restore();
304
+
305
+ // Малюємо мітку, якщо вона є
306
+ if (link.label) {
307
+ // Ми вже обчислили ці значення раніше
308
+ // const middleX = start.x + (end.x - start.x) / 2;
309
+ // const middleY = start.y + (end.y - start.y) / 2;
310
+
311
+ const globalScale = transform.k; // Використовуємо поточний рівень масштабування
312
+ const scaledFontSize = calculateFontSize(globalScale);
313
+
314
+ ctx.font = `${scaledFontSize}px Sans-Serif`;
315
+ ctx.textAlign = 'center';
316
+ ctx.textBaseline = 'middle';
317
+
318
+ // Повертаємо текст, щоб він співпадав з кутом лінії
319
+ ctx.save();
320
+ ctx.translate(middleX, middleY);
321
+
322
+ // Коригуємо обертання, якщо текст буде перевернутим
323
+ if (Math.abs(angle) > Math.PI / 2) {
324
+ ctx.rotate(angle + Math.PI);
325
+ } else {
326
+ ctx.rotate(angle);
327
+ }
328
+
329
+ // Видалено фон для чистішого вигляду
330
+
331
+ // Малюємо текст
332
+ ctx.fillStyle = isHighlighted ? theme.graph2D.link.highlightedTextColor : theme.graph2D.link.textColor;
333
+
334
+ ctx.fillText(link.label, 0, 0);
335
+ ctx.restore();
142
336
  }
143
- tickTimerRef.current = setTimeout(() => {
144
- //force tick check
145
- if (fgRef.current) {
146
- fgRef.current.zoomToFit(0, 20);
147
- setIsRendering(false);
337
+ });
338
+ },
339
+ [config, transform.k, highlightLinks, theme.graph2D.link]
340
+ );
341
+
342
+ // Функція для рендерингу кнопок навколо вузла
343
+ const renderNodeButtons = useCallback(
344
+ (node: NodeObject, ctx: CanvasRenderingContext2D) => {
345
+ if (!buttonImages || buttonImages.length === 0) return;
346
+ if (!node || !node.x || !node.y) return;
347
+
348
+ const { x, y } = node;
349
+ const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
350
+
351
+ // Зберігаємо стан канвасу
352
+ ctx.save();
353
+
354
+ // Обчислюємо кількість кнопок і їхніх секторів
355
+ const buttonCount = Math.min(buttonImages.length, 8); // Обмежуємо до максимум 8 кнопок для ясності інтерфейсу
356
+ const sectorAngle = Math.min((Math.PI * 2) / buttonCount, Math.PI); // Максимальний кут сектора - півколо (PI)
357
+
358
+ // Малюємо кнопки як сектори навколо вузла
359
+ for (let i = 0; i < buttonCount; i++) {
360
+ const startAngle = i * sectorAngle;
361
+ const endAngle = (i + 1) * sectorAngle;
362
+ const isHovered = hoveredButtonIndex === i;
363
+
364
+ // Малюємо фон сектора кнопки
365
+ ctx.beginPath();
366
+ ctx.arc(x, y, buttonRadius, startAngle, endAngle, false);
367
+ ctx.lineTo(x, y);
368
+ ctx.closePath();
369
+ ctx.lineWidth = 1;
370
+ ctx.strokeStyle = theme.graph2D?.button?.stroke || '#FFFFFF';
371
+ ctx.stroke();
372
+ ctx.fillStyle = isHovered
373
+ ? theme.graph2D?.button?.hoverFill || 'rgba(255, 255, 255, 0.3)'
374
+ : theme.graph2D?.button?.normalFill || 'rgba(255, 255, 255, 0.1)';
375
+ ctx.fill();
376
+
377
+ // Обчислюємо позицію для іконки
378
+ // Розташовуємо іконку в середині сектора
379
+ const iconSize = buttonRadius * 0.2;
380
+ const midAngle = (startAngle + endAngle) / 2;
381
+ const iconDistance = buttonRadius - config.nodeSizeBase / 2 + iconSize; // Коригуємо відстань, щоб уникнути перекриття з вузлом
382
+ const iconX = x + Math.cos(midAngle) * iconDistance;
383
+ const iconY = y + Math.sin(midAngle) * iconDistance;
384
+
385
+ // Вибираємо відповідну іконку залежно від стану наведення
386
+ const buttonImage = buttonImages[i];
387
+ const icon = isHovered ? buttonImage.hoverImg : buttonImage.normalImg;
388
+
389
+ // Малюємо іконку
390
+ if (icon.complete) {
391
+ try {
392
+ ctx.drawImage(icon, iconX - iconSize / 2, iconY - iconSize / 2, iconSize, iconSize);
393
+ } catch (error) {
394
+ console.warn('Error rendering button icon:', error);
148
395
  }
149
- }, 1500);
396
+ } else {
397
+ // Set onload handler if image isn't loaded yet
398
+ icon.onload = () => {
399
+ if (ctx2dRef.current) {
400
+ try {
401
+ ctx.drawImage(icon, iconX - iconSize / 2, iconY - iconSize / 2, iconSize, iconSize);
402
+ } catch (error) {
403
+ console.warn('Error rendering button icon after load:', error);
404
+ }
405
+ }
406
+ };
407
+ }
150
408
  }
151
- }
152
- }
153
- };
154
409
 
155
- useEffect(() => {
156
- const timeoutRef = tickTimerRef.current;
410
+ ctx.restore();
411
+ },
412
+ [buttonImages, hoveredButtonIndex, config, theme.graph2D?.button]
413
+ );
414
+
415
+ const renderNodes = useCallback(
416
+ (ctx: CanvasRenderingContext2D) => {
417
+ const nodes = getNodes();
418
+ if (!nodes || nodes.length === 0) return;
419
+
420
+ ctx.globalAlpha = 1.0;
421
+ // Draw all nodes
422
+ nodes.forEach((node) => {
423
+ const { x, y, color: nodeColor, fontColor, label } = node;
424
+ const isHighlighted = highlightNodes.has(node) || node === hoveredNode || node === draggedNode;
425
+ const isSelected = node === selectedNode;
426
+
427
+ // Node size and position
428
+ const size = config.nodeSizeBase;
429
+ const radius = isSelected ? config.nodeSizeBase / 2 : config.nodeSizeBase / 2;
430
+
431
+ // If node is highlighted, draw highlight ring
432
+ if (isHighlighted && !isSelected) {
433
+ const ringRadius = (config.nodeSizeBase * config.nodeAreaFactor * 0.75) / 2;
434
+
435
+ ctx.beginPath();
436
+ ctx.arc(x as number, y as number, ringRadius, 0, 2 * Math.PI, false);
437
+ ctx.fillStyle = theme.graph2D.ring.highlightFill;
438
+ ctx.fill();
439
+ }
157
440
 
158
- return () => {
159
- if (timeoutRef) {
160
- clearTimeout(timeoutRef);
161
- }
162
- };
163
- }, []);
164
-
165
- // Створення взаємозв'язків між вузлами
166
- useEffect(() => {
167
- if (!graphData) return;
168
-
169
- // Прив'язка вузлів до їхніх сусідів та зв'язків
170
- graphData.links.forEach((link: any) => {
171
- const source =
172
- typeof link.source === 'object' ? link.source : graphData.nodes.find((n: any) => n.id === link.source);
173
- const target =
174
- typeof link.target === 'object' ? link.target : graphData.nodes.find((n: any) => n.id === link.target);
175
-
176
- if (!source || !target) return;
177
-
178
- // Ініціалізація масивів, якщо вони відсутні
179
- !source.neighbors && (source.neighbors = []);
180
- !target.neighbors && (target.neighbors = []);
181
- source.neighbors.push(target);
182
- target.neighbors.push(source);
183
-
184
- !source.links && (source.links = []);
185
- !target.links && (target.links = []);
186
- source.links.push(link);
187
- target.links.push(link);
188
- });
189
-
190
- // Додаємо різні сили для уникнення перекриття вузлів
191
- if (fgRef.current) {
192
- // 1. Додаємо силу відштовхування між всіма вузлами (charge force)
193
- const chargeForce = fgRef.current.d3Force('charge');
194
- if (chargeForce) {
195
- chargeForce
196
- .strength(config.nodeSizeBase) // Збільшуємо силу відштовхування (negative for repulsion)
197
- .distanceMax(50); // Максимальна дистанція, на якій діє ця сила
198
- }
441
+ // If node is selected, draw selection ring and buttons
442
+ if (isSelected) {
443
+ // Draw buttons around selected node if buttons are available
444
+ if (buttons && buttons.length > 0) {
445
+ renderNodeButtons(node, ctx);
446
+ } else {
447
+ const ringRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
448
+
449
+ ctx.beginPath();
450
+ ctx.arc(x as number, y as number, ringRadius, 0, 2 * Math.PI, false);
451
+ ctx.fillStyle = theme.graph2D.ring.selectionFill || theme.graph2D.ring.highlightFill;
452
+ ctx.fill();
453
+ }
454
+ }
199
455
 
200
- // 2. Додаємо силу центрування для кращої організації графа
201
- const centerForce = fgRef.current.d3Force('center');
202
- if (centerForce) {
203
- centerForce.strength(0.05); // Невелике притягування до центру
204
- }
456
+ // Draw the node circle
457
+ ctx.beginPath();
458
+ ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
459
+ ctx.fillStyle = nodeColor || color(node.group || '0');
460
+ ctx.fill();
205
461
 
206
- // 3. Додаємо силу колізії через імпортовану функцію forceCollide
207
- try {
208
- const collideForce = forceCollide()
209
- .radius((node: any) => {
210
- // Визначаємо радіус колізії на основі розміру вузла
211
- const nodeSize = node.size || config.nodeSizeBase;
212
- return nodeSize * 1.5; // Більший відступ для кращої сепарації
213
- })
214
- .iterations(3) // Більше ітерацій для точнішого розрахунку
215
- .strength(1); // Максимальна сила (1 - тверде обмеження)
216
-
217
- fgRef.current.d3Force('collide', collideForce);
218
- } catch (err) {
219
- console.error('Error setting up collision force:', err);
220
- }
221
- }
222
- }, [graphData]);
223
-
224
- useEffect(() => {
225
- if (!isRendering && fgRef.current) {
226
- setIsRendering(true);
227
- fgRef.current.tick = 0;
228
- fgRef.current.d3ReheatSimulation();
229
- }
230
- }, [graphData]);
231
-
232
- // Функція для малювання кільця навколо підсвічених вузлів
233
- const paintRing = useCallback(
234
- (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
235
- // Отримуємо розмір вузла
236
- const radius = (config.nodeSizeBase * config.nodeAreaFactor * 0.75) / 2;
237
-
238
- // Малюємо кільце навколо вузла
239
- ctx.beginPath();
240
- ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
241
- ctx.fillStyle = theme.graph2D.ring.highlightFill;
242
- ctx.fill();
243
- },
244
- [config]
245
- );
246
-
247
- // Функція для малювання кнопок навколо вузла при наведенні
248
- const paintNodeButtons = useCallback(
249
- (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
250
- const { x, y } = node;
251
- const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
252
-
253
- // Зберігаємо стан контексту
462
+ // Draw label if available
463
+ if (label) {
464
+ ctx.save();
465
+ ctx.translate(x as number, y as number);
466
+
467
+ const globalScale = transform.k;
468
+ const scaledFontSize = calculateFontSize(globalScale);
469
+ const maxWidth = size * config.textPaddingFactor;
470
+
471
+ ctx.font = `${scaledFontSize}px Sans-Serif`;
472
+ ctx.textAlign = 'center';
473
+ ctx.textBaseline = 'middle';
474
+ ctx.fillStyle = fontColor || '#000';
475
+
476
+ const truncatedLabel = truncateText(label, maxWidth, ctx);
477
+ ctx.fillText(truncatedLabel, 0, 0);
478
+
479
+ ctx.restore();
480
+ }
481
+ });
482
+ },
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
+ ]
495
+ );
496
+
497
+ // 2D Canvas rendering for everything
498
+ const renderCanvas2D = useCallback(() => {
499
+ const ctx = ctx2dRef.current;
500
+ if (!ctx) return;
501
+
502
+ // Get device pixel ratio for correct rendering
503
+ const pixelRatio = window.devicePixelRatio || 1;
504
+
505
+ // Очищуємо весь канвас перед новим рендерингом.
506
+ ctx.clearRect(0, 0, width * pixelRatio, height * pixelRatio);
507
+
508
+ // Render grid first (background)
509
+ renderGrid(ctx);
510
+
511
+ // Apply transformation (zoom and pan) - use matrix transformation for better performance
254
512
  ctx.save();
255
513
 
256
- // Кнопка "сховати" (верхня частина кільця)
257
- ctx.beginPath();
258
- ctx.arc(x, y, buttonRadius, Math.PI, Math.PI * 2, false);
259
- ctx.lineWidth = 1;
260
- ctx.strokeStyle = theme.graph2D.button.stroke;
261
- ctx.stroke();
262
- ctx.fillStyle = hoverTopButton ? theme.graph2D.button.hoverFill : theme.graph2D.button.normalFill;
263
- ctx.fill();
264
-
265
- // Лінія розділення між кнопками
266
- ctx.beginPath();
267
- ctx.moveTo(x - buttonRadius, y);
268
- ctx.lineTo(x + buttonRadius, y);
269
- ctx.lineWidth = 1;
270
- ctx.strokeStyle = theme.graph2D.button.stroke;
271
- ctx.stroke();
272
-
273
- // Кнопка "згорнути" (нижня частина кільця)
274
- ctx.beginPath();
275
- ctx.arc(x, y, buttonRadius, Math.PI * 2, Math.PI, false);
276
- ctx.lineWidth = 1;
277
- ctx.strokeStyle = theme.graph2D.button.stroke;
278
- ctx.stroke();
279
- ctx.fillStyle = hoverBottomButton ? theme.graph2D.button.hoverFill : theme.graph2D.button.normalFill;
280
- ctx.fill();
281
-
282
- // Додаємо іконку хрестика для кнопки "сховати вузол"
283
- const iconSize = buttonRadius * 0.3; // Розмір іконки відносно радіуса кнопки (зменшено вдвічі)
284
-
285
- // Вибір іконки в залежності від стану наведення для верхньої кнопки (сховати)
286
- const groupIcon = hoverBottomButton ? imgGroupLightHoverIcon : imgGroupLightIcon;
287
- // Додаємо іконку ока для кнопки "згорнути дочірні вузли"
288
- const eyeIcon = hoverTopButton ? imgEyeLightHoverIcon : imgEyeLightIcon;
289
-
290
- const renderEyeIcon = () => {
291
- try {
292
- ctx.drawImage(eyeIcon, x - iconSize / 2, y - (buttonRadius * 2) / 4 - iconSize - 1, iconSize, iconSize);
293
- } catch (error) {
294
- console.warn('Error rendering group icon:', error);
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
518
+ renderLinks(ctx);
519
+ renderNodes(ctx);
520
+
521
+ // Restore context
522
+ ctx.restore();
523
+ }, [width, height, renderLinks, renderNodes, renderGrid, transform]);
524
+
525
+ /**
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
530
+ */
531
+ const addNodes = useCallback(
532
+ (
533
+ newNodes: NodeObject[],
534
+ newLinks: LinkObject[] = [],
535
+ options?: {
536
+ smoothAppearance?: boolean;
537
+ transitionDuration?: number;
295
538
  }
296
- };
297
- // Використовуємо безпосередньо зображення, якщо воно вже завантажене
298
- if (eyeIcon.complete) {
299
- // Розміщуємо іконку в центрі верхньої половини кнопки
300
- renderEyeIcon();
301
- } else {
302
- // Якщо зображення ще не завантажене, додаємо обробник завершення завантаження
303
- eyeIcon.onload = () => {
304
- renderEyeIcon();
305
- };
306
-
307
- eyeIcon.onerror = () => {
308
- console.warn('Error loading group icon image');
309
- };
310
- }
539
+ ) => {
540
+ const links = getLinks() || [];
541
+ const nodes = getNodes() || [];
542
+ if (!simulationRef.current || !newNodes.length) return;
543
+
544
+ // Опції по умолчанню
545
+ const smoothAppearance = options?.smoothAppearance ?? false;
546
+ const transitionDuration = options?.transitionDuration ?? 1000; // 1 секунда по умолчанню
547
+
548
+ // Process the new nodes to avoid duplicates
549
+ const existingNodeIds = new Set(nodes.map((node) => node.id));
550
+ const filteredNewNodes = newNodes.filter((node) => !existingNodeIds.has(node.id));
551
+
552
+ // Process the new links to avoid duplicates and ensure they reference valid nodes
553
+ const existingLinkIds = new Set(
554
+ links.map(
555
+ (link) =>
556
+ `${typeof link.source === 'object' ? link.source.id : link.source}-${
557
+ typeof link.target === 'object' ? link.target.id : link.target
558
+ }`
559
+ )
560
+ );
561
+
562
+ const filteredNewLinks = newLinks.filter((link) => {
563
+ const linkId = `${typeof link.source === 'object' ? link.source.id : link.source}-${
564
+ typeof link.target === 'object' ? link.target.id : link.target
565
+ }`;
566
+ return !existingLinkIds.has(linkId);
567
+ });
568
+
569
+ if (filteredNewNodes.length === 0 && filteredNewLinks.length === 0) return;
570
+
571
+ // Update graphData with new nodes and links
572
+ const updatedNodes = [...nodes, ...filteredNewNodes];
573
+ const updatedLinks = [...links, ...filteredNewLinks];
574
+
575
+ // Pre-position new nodes only when smooth appearance is enabled
576
+ if (smoothAppearance) {
577
+ // Pre-position new nodes near their connected nodes
578
+ filteredNewNodes.forEach((node) => {
579
+ // Check if any link connects this node to existing nodes
580
+ const connectedLinks = filteredNewLinks.filter((link) => {
581
+ const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
582
+ const targetId = typeof link.target === 'object' ? link.target.id : link.target;
583
+ return (
584
+ (sourceId === node.id && existingNodeIds.has(targetId)) ||
585
+ (targetId === node.id && existingNodeIds.has(sourceId))
586
+ );
587
+ });
588
+
589
+ if (connectedLinks.length > 0) {
590
+ // Find an existing connected node to position near
591
+ const someLink = connectedLinks[0];
592
+ const connectedNodeId =
593
+ typeof someLink.source === 'object'
594
+ ? someLink.source.id === node.id
595
+ ? someLink.target
596
+ : someLink.source.id
597
+ : someLink.source === node.id
598
+ ? someLink.target
599
+ : someLink.source;
600
+
601
+ const connectedNode = updatedNodes.find((n) => n.id === connectedNodeId);
602
+
603
+ if (connectedNode && connectedNode.x !== undefined && connectedNode.y !== undefined) {
604
+ // Position new node near the connected node with small randomization
605
+ const randomOffset = 30 + Math.random() * 20;
606
+ const randomAngle = Math.random() * Math.PI * 2;
607
+
608
+ // Set initial position
609
+ node.x = connectedNode.x + Math.cos(randomAngle) * randomOffset;
610
+ node.y = connectedNode.y + Math.sin(randomAngle) * randomOffset;
611
+
612
+ // Set initial velocity to zero for smoother appearance
613
+ node.vx = 0;
614
+ node.vy = 0;
615
+ }
616
+ } 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;
621
+ const angle = Math.random() * Math.PI * 2;
622
+
623
+ node.x = centerX + Math.cos(angle) * (radius * Math.random());
624
+ node.y = centerY + Math.sin(angle) * (radius * Math.random());
625
+ node.vx = 0;
626
+ node.vy = 0;
627
+ }
628
+ });
311
629
 
312
- const renderGroupIcon = () => {
313
- try {
314
- ctx.drawImage(groupIcon, x - iconSize / 2, y + (buttonRadius * 2) / 4 + 1, iconSize, iconSize);
315
- } catch (error) {
316
- console.warn('Error rendering eye icon:', error);
630
+ // Fix positions of existing nodes to prevent them from moving
631
+ nodes.forEach((node) => {
632
+ node.fx = node.x;
633
+ node.fy = node.y;
634
+ });
317
635
  }
318
- };
319
- // Використовуємо безпосередньо зображення, якщо воно вже завантажене
320
- if (groupIcon.complete) {
321
- // Розміщуємо іконку в центрі нижньої половини кнопки
322
-
323
- renderGroupIcon();
324
- } else {
325
- // Якщо зображення ще не завантажене, додаємо обробник завершення завантаження
326
- groupIcon.onload = () => {
327
- renderGroupIcon();
328
- };
329
-
330
- groupIcon.onerror = () => {
331
- console.warn('Error loading eye icon image');
332
- };
333
- }
334
636
 
335
- ctx.restore();
336
- },
337
- [config, hoverTopButton, hoverBottomButton]
338
- );
339
-
340
- const hideNode = (unvisibles: Set<string>, node: NodeObject) => {
341
- if (node && node.id && !unvisibles.has(`${node.id}`) && graphData) {
342
- // Прив'язка вузлів до їхніх сусідів та зв'язків
343
- const targets = graphData.links.filter((link: any) => {
344
- return link.source.id === node.id && link.label !== 'MATCH';
345
- });
346
- targets.forEach((link: any) => {
347
- unvisibles.add(`${link.target.id}`);
348
- hideNode(unvisibles, link.target);
349
- });
350
- }
351
- };
352
-
353
- const showNode = (unvisibles: Set<string>, node: NodeObject) => {
354
- if (node && node.id && graphData) {
355
- // Прив'язка вузлів до їхніх сусідів та зв'язків
356
- const targets = graphData.links.filter((link: any) => {
357
- return link.source.id === node.id && link.label !== 'MATCH';
358
- });
637
+ // Update the simulation with new nodes and links
638
+ simulationRef.current.nodes(updatedNodes);
639
+
640
+ // Get the link force with proper typing
641
+ const linkForce = simulationRef.current.force('link') as ForceLink<NodeObject, LinkObject>;
642
+ if (linkForce) {
643
+ linkForce.links(updatedLinks);
644
+ }
645
+
646
+ // Connect new nodes to their neighbors and links
647
+ filteredNewLinks.forEach((link: any) => {
648
+ const source =
649
+ typeof link.source === 'object' ? link.source : updatedNodes.find((n: any) => n.id === link.source);
650
+ const target =
651
+ typeof link.target === 'object' ? link.target : updatedNodes.find((n: any) => n.id === link.target);
652
+
653
+ if (!source || !target) return;
654
+
655
+ // Initialize arrays if they don't exist
656
+ !source.neighbors && (source.neighbors = []);
657
+ !target.neighbors && (target.neighbors = []);
658
+ source.neighbors.push(target);
659
+ target.neighbors.push(source);
660
+
661
+ !source.links && (source.links = []);
662
+ !target.links && (target.links = []);
663
+ source.links.push(link);
664
+ target.links.push(link);
665
+ });
666
+
667
+ if (smoothAppearance) {
668
+ // Configure simulation for smooth appearance of new nodes
669
+ simulationRef.current.alphaTarget(0.3);
670
+ simulationRef.current.alpha(0.3);
671
+ simulationRef.current.velocityDecay(0.7); // Higher decay for smoother motion
672
+ simulationRef.current.restart();
673
+
674
+ // After a short time, unfix all nodes and reset simulation parameters
675
+ setTimeout(() => {
676
+ // Unfix existing nodes to allow natural movement again
677
+ nodes.forEach((node) => {
678
+ node.fx = undefined;
679
+ node.fy = undefined;
680
+ });
681
+
682
+ // Reset simulation to normal settings
683
+ simulationRef.current?.alphaTarget(0);
684
+ simulationRef.current?.alpha(0.1);
685
+ simulationRef.current?.velocityDecay(0.6); // Reset to default
686
+ }, transitionDuration);
687
+ } else {
688
+ // Standard restart with low energy for minimal movement
689
+ simulationRef.current.alpha(0.1).restart();
690
+ }
691
+
692
+ // Re-render the canvas
693
+ renderCanvas2D();
694
+ },
695
+ [graphData, simulationRef, renderCanvas2D, width, height]
696
+ );
697
+
698
+ /**
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
702
+ */
703
+ const removeNodes = useCallback(
704
+ (nodeIds: (string | number)[]) => {
705
+ const nodes = getNodes();
706
+ const links = getLinks();
707
+ if (!simulationRef.current || !nodeIds.length || !nodes || nodes.length === 0 || !links || links.length === 0)
708
+ return;
709
+
710
+ // Create set of node IDs for quick lookup
711
+ const nodeIdsToRemove = new Set(nodeIds);
712
+
713
+ // First check if we're removing any selected/hovered node
714
+ if (selectedNode && selectedNode.id !== undefined && nodeIdsToRemove.has(selectedNode.id)) {
715
+ setSelectedNode(null);
716
+ }
717
+
718
+ if (hoveredNode && hoveredNode.id !== undefined && nodeIdsToRemove.has(hoveredNode.id)) {
719
+ setHoveredNode(null);
720
+ setHighlightNodes(new Set());
721
+ setHighlightLinks(new Set());
722
+ }
359
723
 
360
- targets.forEach((link: any) => {
361
- if (unvisibles.has(`${link.target.id}`)) {
362
- if (!hiddenNodes.has(`${link.target.id}`)) {
363
- unvisibles.delete(`${link.target.id}`);
364
- showNode(unvisibles, link.target);
724
+ if (draggedNode && draggedNode.id !== undefined && nodeIdsToRemove.has(draggedNode.id)) {
725
+ setDraggedNode(null);
726
+ }
727
+
728
+ // Get all nodes that will be kept after removal
729
+ const remainingNodes = nodes.filter((node) => node.id !== undefined && !nodeIdsToRemove.has(node.id));
730
+
731
+ // Get all links that don't connect to removed nodes
732
+ const remainingLinks = links.filter((link) => {
733
+ const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
734
+ const targetId = typeof link.target === 'object' ? link.target.id : link.target;
735
+
736
+ return (
737
+ sourceId !== undefined &&
738
+ !nodeIdsToRemove.has(sourceId) &&
739
+ targetId !== undefined &&
740
+ !nodeIdsToRemove.has(targetId)
741
+ );
742
+ });
743
+
744
+ // Rebuild node relationships (neighbors and links) for remaining nodes
745
+ // First, clear existing relationships
746
+ remainingNodes.forEach((node) => {
747
+ node.neighbors = [];
748
+ node.links = [];
749
+ });
750
+
751
+ // Then rebuild based on remaining links
752
+ remainingLinks.forEach((link: any) => {
753
+ const source =
754
+ typeof link.source === 'object' ? link.source : remainingNodes.find((n: any) => n.id === link.source);
755
+ const target =
756
+ typeof link.target === 'object' ? link.target : remainingNodes.find((n: any) => n.id === link.target);
757
+
758
+ if (!source || !target) return;
759
+
760
+ // Add to neighbors arrays
761
+ source.neighbors = source.neighbors || [];
762
+ target.neighbors = target.neighbors || [];
763
+ source.neighbors.push(target);
764
+ target.neighbors.push(source);
765
+
766
+ // Add to links arrays
767
+ source.links = source.links || [];
768
+ target.links = target.links || [];
769
+ source.links.push(link);
770
+ target.links.push(link);
771
+ });
772
+
773
+ // Update component state directly
774
+ graphData.nodes = remainingNodes;
775
+ graphData.links = remainingLinks;
776
+
777
+ // Update the simulation with the filtered nodes and links
778
+ // але не змінюємо їхні позиції
779
+ simulationRef.current.nodes(remainingNodes);
780
+
781
+ // Get and update the link force
782
+ const linkForce = simulationRef.current.force('link') as ForceLink<NodeObject, LinkObject>;
783
+ if (linkForce) {
784
+ linkForce.links(remainingLinks);
785
+ }
786
+
787
+ // Просто перемальовуємо canvas з новими даними
788
+ renderCanvas2D();
789
+ },
790
+ [selectedNode, hoveredNode, draggedNode, graphData, renderCanvas2D]
791
+ );
792
+
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
+ });
820
+
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,
851
+ };
852
+
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);
365
882
  }
366
883
  }
367
- });
368
- }
369
- };
370
-
371
- // Функція для обробки кліку на кнопку "сховати вузол"
372
- const handleHideNode = (node: any) => {
373
- const newHiddenNodes = new Set(hiddenNodes);
374
- const newUnVisibleNodes = new Set(unVisibleNodes);
375
- if (newHiddenNodes.has(node.id)) {
376
- newHiddenNodes.delete(node.id);
377
- showNode(newUnVisibleNodes, node);
378
- } else {
379
- newHiddenNodes.add(node.id);
380
- hideNode(newUnVisibleNodes, node);
381
- }
382
- setHiddenNodes(newHiddenNodes);
383
- setUnVisibleNodes(newUnVisibleNodes);
384
- };
385
-
386
- // Функція для визначення, чи знаходиться точка в межах сектора кола (кнопки)
387
- const isPointInButtonArea = useCallback(
388
- (
389
- x: number, // X координата точки кліку в системі координат canvas
390
- y: number, // Y координата точки кліку в системі координат canvas
391
- buttonX: number, // X координата центра вузла в системі координат canvas
392
- buttonY: number, // Y координата центра вузла в системі координат canvas
393
- buttonRadius: number, // Радіус кнопки урахуванням зуму)
394
- startAngle: number, // Початковий кут сектора
395
- endAngle: number // Кінцевий кут сектора
396
- ): boolean => {
397
- // Обчислюємо відстань від точки кліку до центру вузла
398
- const dx = x - buttonX;
399
- const dy = y - buttonY;
400
- const distance = Math.sqrt(dx * dx + dy * dy);
401
-
402
- // Обчислюємо кут між точкою та горизонтальною віссю
403
- let angle = Math.atan2(dy, dx);
404
- if (angle < 0) angle += 2 * Math.PI; // Конвертуємо у діапазон [0, 2π]
405
-
406
- // Розширюємо діапазон радіусу для легшого потрапляння по кнопці
407
- // При більшому зумі можна зменшити цей діапазон для більшої точності
408
- const minRadiusRatio = 0.5; // Більш точне значення
409
- const maxRadiusRatio = 1.5; // Більш точне значення
410
- const isInRadius = distance >= buttonRadius * minRadiusRatio && distance <= buttonRadius * maxRadiusRatio;
411
-
412
- // Перевіряємо чи знаходиться кут у межах сектора
413
- let isInAngle = false;
414
-
415
- // Верхня півкуля: від Math.PI до Math.PI * 2
416
- if (startAngle === Math.PI && endAngle === Math.PI * 2) {
417
- isInAngle = angle >= Math.PI && angle <= Math.PI * 2;
418
- }
419
- // Нижня півкуля: від 0 до Math.PI
420
- else if (startAngle === 0 && endAngle === Math.PI) {
421
- isInAngle = angle >= 0 && angle <= Math.PI;
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();
902
+
903
+ // Calculate the center position adjusted for the canvas size
904
+ const centerX = width / 2;
905
+ const centerY = height / 2;
906
+
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
911
+
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)); // Коефіцієнт затухання швидкості для зменшення "тряски"
938
+
939
+ return () => {
940
+ // Cleanup
941
+ simulation.stop();
942
+ };
943
+ }, [width, height, nodes, links, init2DCanvas]);
944
+
945
+ useEffect(() => {
946
+ if (simulationRef.current) {
947
+ const simulation = simulationRef.current;
948
+ // Update node positions on each tick
949
+
950
+ simulation.on('tick', () => {
951
+ renderCanvas2D();
952
+ });
953
+
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
959
+
960
+ setTimeout(() => {
961
+ setIsRendering(false);
962
+ }, 200);
963
+ }
964
+ });
422
965
  }
423
- // Загальний випадок
424
- else {
425
- isInAngle =
426
- (startAngle <= endAngle && angle >= startAngle && angle <= endAngle) ||
427
- (startAngle > endAngle && (angle >= startAngle || angle <= endAngle));
966
+
967
+ if (!isRendering) {
968
+ renderCanvas2D();
428
969
  }
970
+ }, [isRendering, renderCanvas2D, zoomToFit]);
971
+
972
+ // Set up node relationships (neighbors and links)
973
+ useEffect(() => {
974
+ if (!graphData) return;
975
+
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);
982
+
983
+ if (!source || !target) return;
984
+
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);
990
+
991
+ !source.links && (source.links = []);
992
+ !target.links && (target.links = []);
993
+ source.links.push(link);
994
+ target.links.push(link);
995
+ });
996
+ }, [graphData]);
429
997
 
430
- return isInRadius && isInAngle;
431
- },
432
- []
433
- );
434
-
435
- // Додаємо обробник руху миші для відстеження наведення на кнопки
436
- useEffect(() => {
437
- const handleCanvasMouseMove = (event: MouseEvent) => {
438
- if (!hoverNode || !fgRef.current || !wrapperRef.current) {
439
- // Скидаємо стани наведення, якщо немає активного вузла
440
- if (hoverTopButton) setHoverTopButton(false);
441
- if (hoverBottomButton) setHoverBottomButton(false);
442
- return;
998
+ // Initialize button images
999
+ useEffect(() => {
1000
+ if (buttons && buttons.length > 0) {
1001
+ setButtonImages(prepareButtonImages(buttons));
443
1002
  }
1003
+ }, [buttons]);
1004
+
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
+ );
1030
+
1031
+ // Utility function to check if a point is inside a button sector
1032
+ const isPointInButtonSector = useCallback(
1033
+ (
1034
+ mouseX: number,
1035
+ mouseY: number,
1036
+ nodeX: number,
1037
+ nodeY: number,
1038
+ radius: number,
1039
+ startAngle: number,
1040
+ endAngle: number
1041
+ ): boolean => {
1042
+ // Calculate distance from node center to mouse point
1043
+ const dx = mouseX - nodeX;
1044
+ const dy = mouseY - nodeY;
1045
+ const distance = Math.sqrt(dx * dx + dy * dy);
1046
+
1047
+ // Calculate angle between point and horizontal axis
1048
+ let angle = Math.atan2(dy, dx);
1049
+ if (angle < 0) angle += 2 * Math.PI; // Convert to [0, 2π] range
1050
+
1051
+ // Expand radius range for easier button interaction
1052
+ const minRadiusRatio = 0.5;
1053
+ const maxRadiusRatio = 1;
1054
+ const isInRadius = distance >= radius * minRadiusRatio && distance <= radius * maxRadiusRatio;
1055
+
1056
+ // Check if the angle is within the sector
1057
+ let isInAngle = false;
1058
+
1059
+ // Top half circle: from Math.PI to Math.PI * 2
1060
+ if (startAngle === Math.PI && endAngle === Math.PI * 2) {
1061
+ isInAngle = angle >= Math.PI && angle <= Math.PI * 2;
1062
+ }
1063
+ // Bottom half circle: from 0 to Math.PI
1064
+ else if (startAngle === 0 && endAngle === Math.PI) {
1065
+ isInAngle = angle >= 0 && angle <= Math.PI;
1066
+ }
1067
+ // General case for arbitrary sectors
1068
+ else {
1069
+ isInAngle =
1070
+ (startAngle <= endAngle && angle >= startAngle && angle <= endAngle) ||
1071
+ (startAngle > endAngle && (angle >= startAngle || angle <= endAngle));
1072
+ }
444
1073
 
445
- const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
446
- const zoom = fgRef.current.zoom() || 1; // Отримуємо поточний зум
1074
+ return isInRadius && isInAngle;
1075
+ },
1076
+ []
1077
+ );
447
1078
 
448
- // Координати вузла в системі координат графа
449
- const nodeX = hoverNode.x;
450
- const nodeY = hoverNode.y;
1079
+ // Handle node hover (similar to Graph2D handleNodeHover)
1080
+ const handleNodeHover = useCallback(
1081
+ (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
1085
+ }
451
1086
 
452
- // Отримуємо позицію canvas відносно вікна
453
- const canvasRect = wrapperRef.current.getBoundingClientRect();
1087
+ // Update last hovered node reference
1088
+ lastHoveredNodeRef.current = node;
454
1089
 
455
- // Координати миші відносно canvas
456
- const mouseX = event.clientX - canvasRect.left;
457
- const mouseY = event.clientY - canvasRect.top;
1090
+ const newHighlightNodes = new Set();
1091
+ const newHighlightLinks = new Set();
458
1092
 
459
- // Враховуємо співвідношення між реальними розмірами canvas та його відображенням на екрані
460
- const canvasScaleX = wrapperRef.current.clientWidth / canvasRect.width;
461
- const canvasScaleY = wrapperRef.current.clientHeight / canvasRect.height;
1093
+ if (node) {
1094
+ newHighlightNodes.add(node);
462
1095
 
463
- // Масштабовані координати миші у внутрішній системі координат canvas
464
- const scaledMouseX = mouseX * canvasScaleX;
465
- const scaledMouseY = mouseY * canvasScaleY;
1096
+ // Add neighboring nodes and links to highlighting
1097
+ if (node.neighbors) {
1098
+ node.neighbors.forEach((neighbor: any) => newHighlightNodes.add(neighbor));
1099
+ }
466
1100
 
467
- // Отримуємо параметри трансформації графа
468
- const graphCenter = {
469
- x: wrapperRef.current.clientWidth / 2,
470
- y: wrapperRef.current.clientHeight / 2,
471
- };
1101
+ if (node.links) {
1102
+ node.links.forEach((link: any) => newHighlightLinks.add(link));
1103
+ }
1104
+ }
472
1105
 
473
- // Визначаємо координати вузла на екрані
474
- let nodeScreenX, nodeScreenY;
1106
+ setHoveredNode(node);
1107
+ if (onNodeHover) onNodeHover(node);
1108
+ setHighlightNodes(newHighlightNodes);
1109
+ setHighlightLinks(newHighlightLinks);
1110
+ },
1111
+ [onNodeHover]
1112
+ );
1113
+
1114
+ // Handle node click
1115
+ const handleNodeClick = useCallback(
1116
+ (node: NodeObject) => {
1117
+ setSelectedNode(node);
1118
+ if (onNodeClick) onNodeClick(node);
1119
+ },
1120
+ [onNodeClick]
1121
+ );
475
1122
 
476
- if (typeof fgRef.current.graph2ScreenCoords === 'function') {
477
- // Використовуємо API графа для перетворення координат
478
- const screenPos = fgRef.current.graph2ScreenCoords(nodeX, nodeY);
479
- if (screenPos) {
480
- nodeScreenX = screenPos.x;
481
- nodeScreenY = screenPos.y;
1123
+ // Handle background click
1124
+ const handleBackgroundClick = useCallback(() => {
1125
+ setSelectedNode(null);
1126
+ 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
1132
+
1133
+ const handleMouseDown = useCallback(
1134
+ (event: React.MouseEvent<HTMLCanvasElement>) => {
1135
+ if (!canvasRef.current || !simulationRef.current) return;
1136
+
1137
+ // Get canvas-relative coordinates
1138
+ const rect = canvasRef.current.getBoundingClientRect();
1139
+ const x = event.clientX - rect.left;
1140
+ const y = event.clientY - rect.top;
1141
+
1142
+ // Зберігаємо початкові координати для подальшого відстеження перетягування
1143
+ mouseStartPosRef.current = { x, y };
1144
+ isDraggingRef.current = false;
1145
+
1146
+ // Try to find a node at the cursor position - we'll process the click on mouseUp if not dragging
1147
+ const node = getNodeAtPosition(x, y);
1148
+ if (node) {
1149
+ // Set as potentially draggable but don't activate simulation yet
1150
+ setDraggedNode(node);
1151
+
1152
+ // Fix the node position temporarily - поки що фіксуємо позицію
1153
+ node.fx = node.x;
1154
+ node.fy = node.y;
1155
+ } else {
1156
+ // If no node was clicked, start panning
1157
+ setIsPanning(true);
1158
+ lastMousePosRef.current = { x, y };
482
1159
  }
483
- }
1160
+ },
1161
+ [getNodeAtPosition]
1162
+ );
484
1163
 
485
- // Якщо метод не доступний, спробуємо обчислити позицію
486
- if (nodeScreenX === undefined || nodeScreenY === undefined) {
487
- nodeScreenX = graphCenter.x + nodeX * zoom;
488
- nodeScreenY = graphCenter.y + nodeY * zoom;
489
- }
1164
+ // Handle mouse move for dragging and hovering
1165
+ const handleMouseMove = useCallback(
1166
+ (event: React.MouseEvent<HTMLCanvasElement>) => {
1167
+ if (!canvasRef.current) return;
1168
+
1169
+ // Get canvas-relative coordinates
1170
+ const rect = canvasRef.current.getBoundingClientRect();
1171
+ const x = event.clientX - rect.left;
1172
+ const y = event.clientY - rect.top;
1173
+
1174
+ // Перевіряємо чи почалось перетягування
1175
+ if (draggedNode && mouseStartPosRef.current && simulationRef.current) {
1176
+ const startX = mouseStartPosRef.current.x;
1177
+ const startY = mouseStartPosRef.current.y;
1178
+
1179
+ // Визначаємо відстань переміщення для виявлення факту перетягування
1180
+ const dx = x - startX;
1181
+ const dy = y - startY;
1182
+ const dragDistance = Math.sqrt(dx * dx + dy * dy);
1183
+
1184
+ // Якщо відстань досить велика - це перетягування, а не просто клік
1185
+ const dragThreshold = 3; // поріг у пікселях
1186
+
1187
+ if (dragDistance > dragThreshold) {
1188
+ // This is definitely a drag operation, not a click
1189
+ isDraggingRef.current = true;
1190
+
1191
+ // If this is the first detection of dragging, configure the simulation
1192
+ if (simulationRef.current.alphaTarget() === 0) {
1193
+ // Set alphaTarget to a value based on node size for appropriate movement intensity
1194
+ const alphaValue = 0.15;
1195
+ simulationRef.current.alphaTarget(alphaValue).restart();
1196
+
1197
+ // Adjust decay based on node size for better stability during drag
1198
+ const decayValue = 0.8;
1199
+ simulationRef.current.velocityDecay(decayValue);
1200
+ }
1201
+ }
490
1202
 
491
- // Перевіряємо наведення на верхню кнопку
492
- const isOverTopButton = isPointInButtonArea(
493
- scaledMouseX,
494
- scaledMouseY,
495
- nodeScreenX,
496
- nodeScreenY,
497
- buttonRadius * zoom,
498
- Math.PI,
499
- Math.PI * 2
500
- );
501
-
502
- // Перевіряємо наведення на нижню кнопку
503
- const isOverBottomButton = isPointInButtonArea(
504
- scaledMouseX,
505
- scaledMouseY,
506
- nodeScreenX,
507
- nodeScreenY,
508
- buttonRadius * zoom,
509
- 0,
510
- Math.PI
511
- );
512
-
513
- // Оновлюємо стани наведення
514
- setHoverTopButton(isOverTopButton);
515
- setHoverBottomButton(isOverBottomButton);
516
- };
1203
+ // Scale coordinates based on device pixel ratio and current transform
1204
+ const pixelRatio = window.devicePixelRatio || 1;
517
1205
 
518
- if (wrapperRef.current) {
519
- wrapperRef.current.addEventListener('mousemove', handleCanvasMouseMove);
520
- }
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;
521
1209
 
522
- return () => {
523
- if (wrapperRef.current) {
524
- wrapperRef.current.removeEventListener('mousemove', handleCanvasMouseMove);
525
- }
526
- };
527
- }, [hoverNode, config, isPointInButtonArea, hoverTopButton, hoverBottomButton]);
1210
+ // Update the fixed positions of the dragged node with smoothing
1211
+ draggedNode.fx = scaledX;
1212
+ draggedNode.fy = scaledY;
1213
+
1214
+ if (isDraggingRef.current) {
1215
+ // Reduce simulation energy during dragging for stability
1216
+ simulationRef.current.alpha(0.1); // Reduce system energy
1217
+ }
528
1218
 
529
- useEffect(() => {
530
- if (fgRef.current) fgRef.current.zoomToFit(0, 20); // Автоматичне масштабування графа при першому рендері
531
- }, [width, height]);
1219
+ // No need to check for hover when dragging
1220
+ return;
1221
+ }
532
1222
 
533
- const truncateText = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
534
- if (!text) return '';
1223
+ // Handle panning
1224
+ if (isPanning && mouseStartPosRef.current) {
1225
+ const dx = x - lastMousePosRef.current.x;
1226
+ const dy = y - lastMousePosRef.current.y;
1227
+
1228
+ // Calculate total distance moved during panning
1229
+ const startX = mouseStartPosRef.current.x;
1230
+ const startY = mouseStartPosRef.current.y;
1231
+ const panDistance = Math.sqrt(Math.pow(x - startX, 2) + Math.pow(y - startY, 2));
1232
+
1233
+ // Використовуємо ту ж саму логіку і поріг відстані як і для перетягування вузла
1234
+ const panThreshold = 3; // Той самий поріг як і для перетягування вузла
1235
+ if (panDistance > panThreshold) {
1236
+ // Це точно панорамування, а не просто клік
1237
+ isDraggingRef.current = true;
1238
+ }
535
1239
 
536
- // Вимірюємо ширину тексту
537
- const textWidth = ctx.measureText(text).width;
1240
+ setTransform((prev) => ({
1241
+ ...prev,
1242
+ x: prev.x + dx,
1243
+ y: prev.y + dy,
1244
+ }));
538
1245
 
539
- // Якщо текст коротший за максимальну ширину, повертаємо як є
540
- if (textWidth <= maxWidth) return text;
1246
+ lastMousePosRef.current = { x, y };
1247
+ return;
1248
+ }
541
1249
 
542
- // Інакше обрізаємо текст і додаємо трикрапку
543
- let truncated = text;
544
- const ellipsis = '...';
1250
+ let hoveredNode;
545
1251
 
546
- // Поступово скорочуємо текст, поки він не поміститься
547
- while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
548
- truncated = truncated.slice(0, -1);
549
- }
1252
+ // Button hover detection logic
1253
+ if (selectedNode && canvasRef.current && buttonImages.length > 0) {
1254
+ const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
550
1255
 
551
- return truncated + ellipsis;
552
- };
1256
+ // Scale coordinates based on canvas display size
1257
+ const canvasScaleX = canvasRef.current.width / rect.width;
1258
+ const canvasScaleY = canvasRef.current.height / rect.height;
553
1259
 
554
- const renderGrid = (ctx: CanvasRenderingContext2D, globalScale: number) => {
555
- if (isRendering || loading) return; // Не малюємо сітку під час рендерингу
556
- // This will be called before each rendering frame
557
- ctx.getTransform();
558
- ctx.save();
1260
+ // Scaled mouse coordinates in canvas coordinate system
1261
+ const scaledMouseX = x * canvasScaleX;
1262
+ const scaledMouseY = y * canvasScaleY;
559
1263
 
560
- // Reset transform to draw the background in screen coordinates
561
- ctx.setTransform(1, 0, 0, 1, 0, 0);
1264
+ // Apply current transformation to get world coordinates
1265
+ const worldX = (scaledMouseX - transform.x) / transform.k;
1266
+ const worldY = (scaledMouseY - transform.y) / transform.k;
562
1267
 
563
- // Draw background dots
564
- const { width, height } = ctx.canvas;
565
- const gridSpacing = config.gridSpacing;
566
- const dotSize = config.dotSize;
1268
+ // Node position
1269
+ const nodeX = selectedNode.x || 0;
1270
+ const nodeY = selectedNode.y || 0;
567
1271
 
568
- ctx.fillStyle = theme.graph2D.grid.dotColor;
1272
+ // Calculate number of buttons and their sectors
1273
+ const buttonCount = Math.min(buttonImages.length, 8);
1274
+ const sectorAngle = Math.min((Math.PI * 2) / buttonCount, Math.PI);
569
1275
 
570
- for (let x = 0; x < width; x += gridSpacing) {
571
- for (let y = 0; y < height; y += gridSpacing) {
572
- ctx.beginPath();
573
- ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
574
- ctx.fill();
575
- }
576
- }
577
-
578
- // Restore original transform for the graph rendering
579
- ctx.restore();
580
- };
581
-
582
- const renderNodePointerAreaPaint = (
583
- node: NodeObject,
584
- color: string,
585
- ctx: CanvasRenderingContext2D,
586
- globalScale: number
587
- ) => {
588
- const { x, y } = node;
589
- const radius = selectedNode === node ? (config.nodeSizeBase * config.nodeAreaFactor) / 2 : config.nodeSizeBase / 2;
590
-
591
- ctx.beginPath();
592
- ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
593
- ctx.fillStyle = color; //має бути обовʼязково колір що прийшов з параметрів ForceGraph
594
- ctx.fill();
595
- };
596
-
597
- const renderNodeCanvasObject = (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
598
- // Функція для обрізання тексту з трикрапкою (аналог text-overflow: ellipsis)
599
-
600
- // Якщо вузол підсвічений, малюємо кільце
601
- if (highlightNodes.has(node)) {
602
- // Якщо це наведений вузол, малюємо кнопки
603
- if (node !== selectedNode) paintRing(node, ctx, globalScale);
604
- }
605
-
606
- if (node === selectedNode) {
607
- paintNodeButtons(node, ctx, globalScale);
608
- }
609
-
610
- const { x, y, color, fontColor, label } = node;
611
-
612
- const size = config.nodeSizeBase;
613
- const radius = config.nodeSizeBase / 2;
614
-
615
- // Малюємо коло
616
- ctx.beginPath();
617
- ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
618
- ctx.fillStyle = color;
619
-
620
- ctx.fill();
621
-
622
- // пігтовока до малювання тексту
623
- ctx.save();
624
- ctx.translate(x as number, y as number);
625
-
626
- const scaledFontSize = calculateFontSize(globalScale);
627
- const maxWidth = size * config.textPaddingFactor;
628
- // Розрахунок максимальної ширини тексту на основі розміру вузла
629
- // Ширина тексту = діаметр вузла * коефіцієнт розширення
630
- // Використовуємо globalScale для визначення пропорцій тексту
631
- ctx.font = `${scaledFontSize}px Sans-Serif`;
632
- ctx.textAlign = 'center';
633
- ctx.textBaseline = 'middle';
634
- ctx.fillStyle = fontColor;
635
-
636
- const truncatedLabel = truncateText(label, maxWidth, ctx);
637
- ctx.fillText(truncatedLabel, 0, 0);
638
-
639
- ctx.restore();
640
- };
641
-
642
- const renderLinkCanvasObject = (link: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
643
- // Отримуємо позиції початку і кінця зв'язку
644
- const { source, target, label } = link;
645
-
646
- // Координати початку і кінця зв'язку
647
- const start = { x: source.x, y: source.y };
648
- const end = { x: target.x, y: target.y };
649
-
650
- // Відстань між вузлами
651
- const dx = end.x - start.x;
652
- const dy = end.y - start.y;
653
- const distance = Math.sqrt(dx * dx + dy * dy);
654
-
655
- // Розмір вузла
656
- const sourceSize = config.nodeSizeBase;
657
- const targetSize = config.nodeSizeBase;
658
-
659
- // Нормалізовані вектори для напрямку
660
- const unitDx = dx / distance;
661
- const unitDy = dy / distance;
662
-
663
- // Скоригований початок і кінець (щоб стрілка не починалася з центру вузла і не закінчувалася в центрі вузла)
664
- const startRadius = sourceSize / 2;
665
- const endRadius = targetSize / 2;
666
-
667
- // Зміщені позиції початку і кінця
668
- const adjustedStart = {
669
- x: start.x + unitDx * startRadius,
670
- y: start.y + unitDy * startRadius,
671
- };
1276
+ let hoveredIndex = null;
672
1277
 
673
- // Для кінцевої точки віднімаємо невелику відстань, щоб стрілка не заходила в вузол
674
- const arrowHeadLength = 4;
675
- const adjustedEnd = {
676
- x: end.x - unitDx * (endRadius + arrowHeadLength),
677
- y: end.y - unitDy * (endRadius + arrowHeadLength),
678
- };
1278
+ // Check if mouse is over any button sector
1279
+ for (let i = 0; i < buttonCount; i++) {
1280
+ const startAngle = i * sectorAngle;
1281
+ const endAngle = (i + 1) * sectorAngle;
679
1282
 
680
- // Позиція для стрілки (трохи ближче до кінцевого вузла)
681
- const adjusteArrowdEnd = {
682
- x: end.x - unitDx * (endRadius + 1),
683
- y: end.y - unitDy * (endRadius + 1),
684
- };
1283
+ if (isPointInButtonSector(worldX, worldY, nodeX, nodeY, buttonRadius, startAngle, endAngle)) {
1284
+ hoveredIndex = i;
1285
+ break;
1286
+ }
1287
+ }
1288
+ if (hoveredIndex !== null) hoveredNode = selectedNode; // Set hoveredNode to selectedNode for further processing
1289
+ setHoveredButtonIndex(hoveredIndex);
1290
+ } else {
1291
+ if (hoveredButtonIndex !== null) setHoveredButtonIndex(null);
1292
+ }
685
1293
 
686
- // Малюємо лінію зв'язку з урахуванням місця для тексту, якщо він є
687
- const lineColor = highlightLinks.has(link) ? theme.graph2D.link.highlighted : theme.graph2D.link.normal;
688
- const lineWidth = highlightLinks.has(link) ? 1.5 : 0.5;
689
-
690
- if (label) {
691
- // Розраховуємо ширину тексту для визначення розміру проміжку
692
- const scaledFontSize = calculateFontSize(globalScale);
693
- ctx.font = `${scaledFontSize}px Sans-Serif`;
694
- const textWidth = ctx.measureText(label).width;
695
-
696
- // Розраховуємо довжину проміжку вздовж лінії
697
- const gapLength = Math.sqrt(textWidth * textWidth + scaledFontSize * scaledFontSize);
698
-
699
- // Загальна довжина лінії між вузлами
700
- const lineLength = distance - startRadius - endRadius - arrowHeadLength;
701
-
702
- // Розрахунок відстані від початку до середини і від середини до кінця
703
- const halfLineLength = lineLength / 2;
704
- const gapHalf = gapLength / 2;
705
-
706
- // Малюємо першу частину лінії (від початку до проміжку)
707
- if (halfLineLength > gapHalf) {
708
- // Розрахунок точок перед проміжком
709
- const gapStart = {
710
- x: adjustedStart.x + unitDx * (halfLineLength - gapHalf),
711
- y: adjustedStart.y + unitDy * (halfLineLength - gapHalf),
712
- };
713
-
714
- ctx.beginPath();
715
- ctx.moveTo(adjustedStart.x, adjustedStart.y);
716
- ctx.lineTo(gapStart.x, gapStart.y);
717
- ctx.strokeStyle = lineColor;
718
- ctx.lineWidth = lineWidth;
719
- ctx.stroke();
720
-
721
- // Розрахунок точок після проміжку
722
- const gapEnd = {
723
- x: adjustedStart.x + unitDx * (halfLineLength + gapHalf),
724
- y: adjustedStart.y + unitDy * (halfLineLength + gapHalf),
725
- };
726
-
727
- ctx.beginPath();
728
- ctx.moveTo(gapEnd.x, gapEnd.y);
729
- ctx.lineTo(adjustedEnd.x, adjustedEnd.y);
730
- ctx.strokeStyle = lineColor;
731
- ctx.lineWidth = lineWidth;
732
- ctx.stroke();
733
- }
734
- } else {
735
- // Якщо немає тексту, малюємо повну лінію
736
- ctx.beginPath();
737
- ctx.moveTo(adjustedStart.x, adjustedStart.y);
738
- ctx.lineTo(adjustedEnd.x, adjustedEnd.y);
739
- ctx.strokeStyle = lineColor;
740
- ctx.lineWidth = lineWidth;
741
- ctx.stroke();
742
- }
743
-
744
- // Малюємо стрілку
745
- const arrowHeadWidth = 2;
746
- const angle = Math.atan2(dy, dx);
747
-
748
- ctx.save();
749
- ctx.translate(adjusteArrowdEnd.x, adjusteArrowdEnd.y);
750
- ctx.rotate(angle);
751
-
752
- // Малюємо наконечник стрілки
753
- ctx.beginPath();
754
- ctx.moveTo(0, 0);
755
- ctx.lineTo(-arrowHeadLength, arrowHeadWidth);
756
- ctx.lineTo(-arrowHeadLength, 0); // Стрілка трохи вдавлена всередину
757
- ctx.lineTo(-arrowHeadLength, -arrowHeadWidth);
758
- ctx.closePath();
759
-
760
- ctx.fillStyle = highlightLinks.has(link) ? theme.graph2D.link.highlighted : theme.graph2D.link.normal;
761
- ctx.fill();
762
- ctx.restore();
763
-
764
- // Якщо немає мітки, не малюємо текст
765
- if (!label) return;
766
-
767
- // Знаходимо середину лінії для розміщення тексту
768
- const middleX = start.x + (end.x - start.x) / 2;
769
- const middleY = start.y + (end.y - start.y) / 2;
770
-
771
- // Використовуємо реверсивне масштабування для тексту
772
- const scaledFontSize = calculateFontSize(globalScale);
773
- ctx.font = `${scaledFontSize}px Sans-Serif`;
774
- ctx.fillStyle = theme.graph2D.link.textColor; // Колір тексту
775
- ctx.textAlign = 'center';
776
- ctx.textBaseline = 'middle';
777
-
778
- // Визначення кута нахилу лінії для повороту тексту
779
- ctx.save();
780
- // Переміщення до центру лінії та поворот тексту
781
- ctx.translate(middleX, middleY);
782
- // Якщо кут близький до вертикального або перевернутий, коригуємо його
783
- if (Math.abs(angle) > Math.PI / 2) {
784
- ctx.rotate(angle + Math.PI);
785
- ctx.textAlign = 'center';
786
- } else {
787
- ctx.rotate(angle);
788
- ctx.textAlign = 'center';
789
- }
790
-
791
- // Рисуємо фон для тексту для кращої читаємості
792
- const textWidth = ctx.measureText(label).width;
793
- const padding = 2;
794
- ctx.fillStyle = highlightLinks.has(link)
795
- ? theme.graph2D.link.highlightedTextBgColor
796
- : theme.graph2D.link.textBgColor;
797
- ctx.fillRect(
798
- -textWidth / 2 - padding,
799
- -scaledFontSize / 2 - padding,
800
- textWidth + padding * 2,
801
- scaledFontSize + padding * 2
1294
+ if (!hoveredNode) {
1295
+ // If no node is hovered, reset hoveredNode
1296
+ hoveredNode = getNodeAtPosition(x, y);
1297
+ }
1298
+ handleNodeHover(hoveredNode);
1299
+ // Check for hover and update highlighting
1300
+
1301
+ // Change cursor style based on hover
1302
+ if (canvasRef.current) {
1303
+ canvasRef.current.style.cursor = hoveredNode ? 'pointer' : 'default';
1304
+ }
1305
+ },
1306
+ [
1307
+ draggedNode,
1308
+ getNodeAtPosition,
1309
+ isPanning,
1310
+ transform,
1311
+ handleNodeHover,
1312
+ selectedNode,
1313
+ buttonImages,
1314
+ config,
1315
+ hoveredButtonIndex,
1316
+ simulationRef,
1317
+ lastMousePosRef,
1318
+ mouseStartPosRef,
1319
+ isDraggingRef,
1320
+ isPointInButtonSector,
1321
+ canvasRef,
1322
+ setTransform,
1323
+ setHoveredButtonIndex,
1324
+ ]
802
1325
  );
803
1326
 
804
- // Малюємо текст
805
- ctx.fillStyle = highlightLinks.has(link) ? theme.graph2D.link.highlightedTextColor : theme.graph2D.link.textColor;
806
- ctx.fillText(label, 0, 0);
807
-
808
- // Відновлення стану контексту
809
- ctx.restore();
810
- };
811
-
812
- const handleNodeClick = (node: NodeObject, event: MouseEvent) => {
813
- if (!node || !fgRef.current) return;
814
-
815
- const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
816
- const zoom = fgRef.current.zoom() || 1; // Отримуємо поточний зум
817
- const canvas = event.target as HTMLCanvasElement;
818
- // Координати вузла в системі координат графа
819
- const nodeX = node.x as number;
820
- const nodeY = node.y as number;
821
-
822
- // // Отримуємо позицію canvas відносно вікна
823
- const canvasRect = canvas.getBoundingClientRect();
824
-
825
- // Координати кліку відносно canvas
826
- // event.clientX/Y - це координати кліку відносно вікна браузера
827
- // віднімаємо координати canvas щоб отримати координати відносно canvas
828
- // враховуємо також можливий скролінг сторінки
829
- const clickX = event.clientX - canvasRect.left;
830
- const clickY = event.clientY - canvasRect.top;
831
-
832
- // Враховуємо співвідношення між реальними розмірами canvas та його відображенням на екрані
833
- // це важливо якщо canvas відмальовується з відмінним від реального розміру
834
- const canvasScaleX = canvas.width / canvasRect.width;
835
- const canvasScaleY = canvas.height / canvasRect.height;
836
-
837
- // Масштабовані координати кліку у внутрішній системі координат canvas
838
- const scaledClickX = clickX * canvasScaleX;
839
- const scaledClickY = clickY * canvasScaleY;
840
-
841
- // Отримуємо параметри трансформації графа
842
- // ForceGraph використовує центр канваса як початок координат
843
- // і застосовує зум і панорамування до всіх координат
844
- const graphCenter = {
845
- x: canvas.width / 2,
846
- y: canvas.height / 2,
847
- };
1327
+ const handleClick = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {
1328
+ if (mustBeStoppedPropagation.current) {
1329
+ event.stopPropagation();
1330
+ event.preventDefault();
1331
+ }
1332
+ mustBeStoppedPropagation.current = false;
1333
+ }, []);
848
1334
 
849
- // Спробуємо отримати реальні координати вузла на екрані
850
- // Якщо доступний метод перетворення координат
851
- let nodeScreenX, nodeScreenY;
1335
+ // Handle mouse up to end dragging
1336
+ const handleMouseUp = useCallback(
1337
+ (event: React.MouseEvent<HTMLCanvasElement>) => {
1338
+ const wasDragging = isDraggingRef.current;
852
1339
 
853
- if (typeof fgRef.current.graph2ScreenCoords === 'function') {
854
- // Використовуємо API графа для перетворення координат
855
- const screenPos = fgRef.current.graph2ScreenCoords(nodeX, nodeY);
856
- if (screenPos) {
857
- nodeScreenX = screenPos.x;
858
- nodeScreenY = screenPos.y;
859
- }
860
- }
861
-
862
- // Якщо метод не доступний, спробуємо обчислити позицію
863
- if (nodeScreenX === undefined || nodeScreenY === undefined) {
864
- // Це наближене обчислення, яке може бути неточним, але краще ніж нічого
865
- // Ми припускаємо, що граф знаходиться в центрі канваса і застосовується масштабування
866
- nodeScreenX = graphCenter.x + nodeX * zoom;
867
- nodeScreenY = graphCenter.y + nodeY * zoom;
868
- }
869
-
870
- // Перевіряємо клік на верхній кнопці (hide)
871
- if (
872
- isPointInButtonArea(
873
- scaledClickX,
874
- scaledClickY,
875
- nodeScreenX,
876
- nodeScreenY,
877
- buttonRadius * zoom, // Масштабуємо радіус відповідно до зуму
878
- Math.PI, // Початковий кут для верхньої півсфери
879
- Math.PI * 2 // Кінцевий кут для верхньої півсфери
880
- )
881
- ) {
882
- handleHideNode(hoverNode);
883
- event.stopPropagation();
884
- return;
885
- }
886
-
887
- // Перевіряємо клік на нижній кнопці
888
- if (
889
- isPointInButtonArea(
890
- scaledClickX,
891
- scaledClickY,
892
- nodeScreenX,
893
- nodeScreenY,
894
- buttonRadius * zoom, // Масштабуємо радіус відповідно до зуму
895
- 0, // Початковий кут для нижньої півсфери
896
- Math.PI // Кінцевий кут для нижньої півсфери
897
- )
898
- ) {
899
- onClickLoadNodes?.(hoverNode);
900
- event.stopPropagation();
901
- return;
902
- }
903
- // Якщо клік не на кнопках, обробляємо клік на вузлі
904
- setSelectedNode(node);
905
- onNodeClick?.(node);
906
- };
907
-
908
- const handleBackgroundClick = (event: MouseEvent) => {
909
- setSelectedNode(null);
910
- onBackgroundClick?.();
911
- };
912
-
913
- return (
914
- <Wrapper ref={wrapperRef}>
915
- {(loading || isRendering) && <GraphLoader width={width} height={height} />}
916
- <ForceGraph2D
917
- ref={fgRef}
918
- width={width}
919
- height={height}
920
- graphData={graphData}
921
- linkTarget={linkTarget}
922
- linkSource={linkSource}
923
- onLinkClick={onLinkClick}
924
- onNodeClick={handleNodeClick}
925
- onBackgroundClick={handleBackgroundClick}
926
- nodeLabel={(node: any) => `${node.label || ''}`} // Показуємо повний текст у тултіпі
927
- linkLabel={(link: any) => link.label}
928
- nodeAutoColorBy="label"
929
- linkCurvature={0}
930
- // Вимикаємо вбудовані стрілки, оскільки використовуємо свою реалізацію
931
- linkDirectionalArrowLength={0}
932
- // Обмеження максимального зуму
933
- //maxZoom={config.maxZoom}
934
- minZoom={0.01}
935
- // Додавання обробників наведення
936
- onNodeHover={handleNodeHover}
937
- onLinkHover={handleLinkHover}
938
- onEngineTick={handleEngineTick}
939
- d3AlphaMin={0.001}
940
- d3VelocityDecay={0.4}
941
- d3AlphaDecay={0.038}
942
- // Виділення зв'язків при наведенні
943
- linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1)}
944
- linkColor={(link: any) =>
945
- highlightLinks.has(link) ? theme.graph2D.link.highlighted : theme.graph2D.link.normal
1340
+ if (wasDragging) {
1341
+ mustBeStoppedPropagation.current = true;
946
1342
  }
947
- onRenderFramePre={renderGrid}
948
- nodePointerAreaPaint={renderNodePointerAreaPaint}
949
- nodeCanvasObject={renderNodeCanvasObject}
950
- linkCanvasObjectMode={() => 'replace'} // 'replace' замість 'after' для повної заміни стандартного рендерингу зв'язків
951
- linkCanvasObject={renderLinkCanvasObject}
952
- linkVisibility={(link: any) => {
953
- if (isRendering) return false; // Не показуємо вузол, якщо граф ще рендериться
954
-
955
- // Перевіряємо, чи вузол прихований
956
- if (hiddenNodes.has(link.source.id) || hiddenNodes.has(link.target.id)) return false;
957
- // Перевіряємо, чи вузол згорнутий
958
- if (unVisibleNodes.has(link.source.id) || unVisibleNodes.has(link.target.id)) return false;
959
-
960
- return true; // Показуємо вузол, якщо не прихований і не згорнутий
961
- }}
962
- nodeVisibility={(node: NodeObject) => {
963
- if (isRendering) return false; // Не показуємо вузол, якщо граф ще рендериться
964
- // Перевіряємо, чи вузол прихований
965
- if (hiddenNodes.has(node.id as string)) return false;
966
- // Перевіряємо, чи вузол згорнутий
967
- if (unVisibleNodes.has(node.id as string)) return false;
968
- return true; // Показуємо вузол, якщо не прихований і не згорнутий
969
- }}
970
- />
971
- </Wrapper>
972
- );
973
- };
1343
+ // Process node clicks or button clicks only if we haven't been dragging
1344
+ if (!wasDragging && mouseStartPosRef.current) {
1345
+ const rect = canvasRef.current?.getBoundingClientRect();
1346
+ if (rect) {
1347
+ const x = event.clientX - rect.left;
1348
+ const y = event.clientY - rect.top;
1349
+
1350
+ // First check if we're clicking on a button of the selected node
1351
+ let isButtonClick = false;
1352
+ if (selectedNode && hoveredButtonIndex !== null && buttons[hoveredButtonIndex]) {
1353
+ // This is a button click, trigger the button's onClick handler
1354
+ const button = buttons[hoveredButtonIndex];
1355
+ if (button && button.onClick) {
1356
+ button.onClick(selectedNode);
1357
+ isButtonClick = true;
1358
+ }
1359
+ }
1360
+
1361
+ // 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();
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ if (draggedNode && simulationRef.current) {
1373
+ // If real dragging occurred, optimize simulation parameters
1374
+ if (wasDragging) {
1375
+ // Gradually reduce the simulation energy
1376
+ simulationRef.current.alphaTarget(0);
1377
+
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
1382
+
1383
+ simulationRef.current.alpha(alphaValue).alphaDecay(alphaDecayValue);
1384
+ simulationRef.current.velocityDecay(velocityDecayValue);
1385
+ } else {
1386
+ // If it was just a click, not a drag, stop simulation immediately
1387
+ simulationRef.current.alphaTarget(0);
1388
+ }
1389
+
1390
+ // Release node position regardless of whether it was dragged or clicked
1391
+ draggedNode.fx = undefined;
1392
+ draggedNode.fy = undefined;
1393
+
1394
+ setDraggedNode(null);
1395
+ }
1396
+
1397
+ // Скидаємо всі стани перетягування
1398
+ isDraggingRef.current = false;
1399
+ mouseStartPosRef.current = null;
1400
+
1401
+ // End panning if active
1402
+ if (isPanning) {
1403
+ setIsPanning(false);
1404
+ }
1405
+ },
1406
+ [draggedNode, isPanning, hoveredButtonIndex, selectedNode, buttons, handleNodeClick, handleBackgroundClick]
1407
+ );
1408
+
1409
+ // Handle wheel event for zooming
1410
+ const handleWheel = useCallback(
1411
+ (event: WheelEvent) => {
1412
+ event.stopPropagation();
1413
+ event.preventDefault();
1414
+
1415
+ if (!canvasRef.current) return;
1416
+
1417
+ // Get canvas-relative coordinates
1418
+ const rect = canvasRef.current.getBoundingClientRect();
1419
+ const x = event.clientX - rect.left;
1420
+ const y = event.clientY - rect.top;
1421
+
1422
+ // Calculate zoom factor
1423
+ const delta = -event.deltaY;
1424
+ const scaleFactor = delta > 0 ? 1.1 : 1 / 1.1;
1425
+
1426
+ // Calculate new transform with zoom around mouse position
1427
+ setTransform((prev) => {
1428
+ // Limit zoom level (optional)
1429
+ const newScale = prev.k * scaleFactor;
1430
+
1431
+ if (newScale < 0.01 || newScale > 10) return prev;
1432
+ const newK = prev.k * scaleFactor;
1433
+
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;
1437
+
1438
+ return {
1439
+ k: newK,
1440
+ x: newX,
1441
+ y: newY,
1442
+ };
1443
+ });
1444
+ },
1445
+ [setTransform]
1446
+ );
1447
+
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
+ useImperativeHandle(
1453
+ ref,
1454
+ () => ({
1455
+ zoomToFit,
1456
+ addNodes,
1457
+ removeNodes,
1458
+ }),
1459
+ [zoomToFit, addNodes, removeNodes]
1460
+ );
1461
+
1462
+ // Add wheel event listener with passive: false
1463
+ useEffect(() => {
1464
+ const canvas = canvasRef.current;
1465
+ if (!canvas) return;
1466
+
1467
+ // Add event listener with passive: false to allow preventDefault
1468
+ canvas.addEventListener('wheel', handleWheel, { passive: false });
1469
+
1470
+ // Clean up when component unmounts
1471
+ return () => {
1472
+ canvas.removeEventListener('wheel', handleWheel);
1473
+ };
1474
+ }, [handleWheel]);
1475
+
1476
+ return (
1477
+ <Wrapper>
1478
+ {(loading || isRendering) && <GraphLoader width={width} height={height} />}
1479
+ <Canvas
1480
+ ref={canvasRef}
1481
+ style={{ width, height, display: isRendering ? 'none' : 'block' }}
1482
+ onMouseDown={handleMouseDown}
1483
+ onMouseMove={handleMouseMove}
1484
+ onMouseUp={handleMouseUp}
1485
+ onMouseLeave={handleMouseUp}
1486
+ onClick={handleClick}
1487
+ // Wheel event is now handled by the useEffect above
1488
+ />
1489
+ </Wrapper>
1490
+ );
1491
+ }
1492
+ );
974
1493
 
975
1494
  const Wrapper = styled.div`
976
- display: block;
1495
+ display: flex;
1496
+ align-items: center;
1497
+ justify-content: center;
977
1498
  width: 100%;
978
1499
  min-width: 0;
979
1500
  position: relative;
980
1501
  `;
1502
+
1503
+ const Canvas = styled.canvas``;
1504
+
1505
+ // Add display name for better debugging
1506
+ Graph2D.displayName = 'Graph2D';