@cyber-harbour/ui 1.0.45 → 1.0.47
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 +89 -22
- package/dist/index.d.ts +89 -22
- package/dist/index.js +121 -123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +149 -151
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/src/Core/IconComponents/BusIcon.tsx +16 -0
- package/src/Core/IconComponents/CarIcon.tsx +16 -0
- package/src/Core/IconComponents/FileIcon.tsx +16 -0
- package/src/Core/IconComponents/PlaneIcon.tsx +16 -0
- package/src/Core/IconComponents/ShipIcon.tsx +33 -0
- package/src/Core/IconComponents/WayIcon.tsx +24 -0
- package/src/Core/IconComponents/index.ts +6 -0
- package/src/Graph2D/Graph2D.tsx +1439 -913
- package/src/Graph2D/GraphLoader.tsx +0 -4
- package/src/Graph2D/json_test.json +44443 -3684
- package/src/Graph2D/types.ts +69 -21
- package/src/Theme/themes/dark.ts +1 -0
- package/src/Theme/themes/light.ts +1 -0
- package/src/Theme/types.ts +1 -0
- package/dist/eye_light-3WS4REO5.png +0 -0
- package/dist/eye_light_hover-PVS4UAB4.png +0 -0
- package/dist/group_light-RVCSCGRJ.png +0 -0
- package/dist/group_light_hover-LVI5PRZM.png +0 -0
- /package/src/Graph2D/{eye_light.png → icons/eye_light.png} +0 -0
- /package/src/Graph2D/{eye_light_hover.png → icons/eye_light_hover.png} +0 -0
- /package/src/Graph2D/{group_light.png → icons/group_light.png} +0 -0
- /package/src/Graph2D/{group_light_hover.png → icons/group_light_hover.png} +0 -0
package/src/Graph2D/Graph2D.tsx
CHANGED
|
@@ -1,980 +1,1506 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Graph2DProps } from './types';
|
|
3
|
-
|
|
4
|
-
import
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
ctx
|
|
261
|
-
ctx
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
1074
|
+
return isInRadius && isInAngle;
|
|
1075
|
+
},
|
|
1076
|
+
[]
|
|
1077
|
+
);
|
|
447
1078
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
1087
|
+
// Update last hovered node reference
|
|
1088
|
+
lastHoveredNodeRef.current = node;
|
|
454
1089
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const mouseY = event.clientY - canvasRect.top;
|
|
1090
|
+
const newHighlightNodes = new Set();
|
|
1091
|
+
const newHighlightLinks = new Set();
|
|
458
1092
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const canvasScaleY = wrapperRef.current.clientHeight / canvasRect.height;
|
|
1093
|
+
if (node) {
|
|
1094
|
+
newHighlightNodes.add(node);
|
|
462
1095
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
};
|
|
1101
|
+
if (node.links) {
|
|
1102
|
+
node.links.forEach((link: any) => newHighlightLinks.add(link));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
472
1105
|
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
1219
|
+
// No need to check for hover when dragging
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
532
1222
|
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
1240
|
+
setTransform((prev) => ({
|
|
1241
|
+
...prev,
|
|
1242
|
+
x: prev.x + dx,
|
|
1243
|
+
y: prev.y + dy,
|
|
1244
|
+
}));
|
|
538
1245
|
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const dotSize = config.dotSize;
|
|
1268
|
+
// Node position
|
|
1269
|
+
const nodeX = selectedNode.x || 0;
|
|
1270
|
+
const nodeY = selectedNode.y || 0;
|
|
567
1271
|
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
1335
|
+
// Handle mouse up to end dragging
|
|
1336
|
+
const handleMouseUp = useCallback(
|
|
1337
|
+
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
1338
|
+
const wasDragging = isDraggingRef.current;
|
|
852
1339
|
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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:
|
|
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';
|