@cyber-harbour/ui 1.0.24 → 1.0.26
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/cross_light-JTZWFLSV.png +0 -0
- package/dist/cross_light_hover-UQZ7E3CW.png +0 -0
- package/dist/eye_light-EQXRQBFN.png +0 -0
- package/dist/eye_light_hover-5XFRPJS4.png +0 -0
- package/dist/index.d.mts +17 -4
- package/dist/index.d.ts +17 -4
- package/dist/index.js +141 -139
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +117 -115
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/Core/IconComponents/Check.tsx +5 -2
- package/src/Core/IconComponents/Cross.tsx +16 -0
- package/src/Core/IconComponents/Unfold.tsx +20 -0
- package/src/Core/IconComponents/index.ts +2 -0
- package/src/Graph2D/Graph2D.tsx +720 -145
- package/src/Graph2D/cross_light.png +0 -0
- package/src/Graph2D/cross_light_hover.png +0 -0
- package/src/Graph2D/eye_light.png +0 -0
- package/src/Graph2D/eye_light_hover.png +0 -0
- package/src/Graph2D/types.ts +4 -1
- package/src/custom.d.ts +19 -0
package/src/Graph2D/Graph2D.tsx
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import ForceGraph2D, { ForceGraphMethods } from 'react-force-graph-2d';
|
|
1
|
+
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from 'react-force-graph-2d';
|
|
2
2
|
import { Graph2DProps } from './types';
|
|
3
|
-
import { useEffect, useRef,
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
import { forceCollide } from 'd3-force';
|
|
5
|
+
import { styled } from 'styled-components';
|
|
6
|
+
import eyeLightIcon from './eye_light.png';
|
|
7
|
+
import eyeLightHoverIcon from './eye_light_hover.png';
|
|
8
|
+
import crossLightIcon from './cross_light.png';
|
|
9
|
+
import crossLightHoverIcon from './cross_light_hover.png';
|
|
10
|
+
|
|
11
|
+
// Створюємо та налаштовуємо об'єкти зображень
|
|
12
|
+
const imgEyeLightIcon = new Image();
|
|
13
|
+
imgEyeLightIcon.src = eyeLightIcon;
|
|
14
|
+
|
|
15
|
+
const imgEyeLightHoverIcon = new Image();
|
|
16
|
+
imgEyeLightHoverIcon.src = eyeLightHoverIcon;
|
|
17
|
+
|
|
18
|
+
const imgCrossLightIcon = new Image();
|
|
19
|
+
imgCrossLightIcon.src = crossLightIcon;
|
|
20
|
+
|
|
21
|
+
const imgCrossLightHoverIcon = new Image();
|
|
22
|
+
imgCrossLightHoverIcon.src = crossLightHoverIcon;
|
|
4
23
|
|
|
5
24
|
export const Graph2D = ({
|
|
6
25
|
graphData,
|
|
@@ -9,178 +28,734 @@ export const Graph2D = ({
|
|
|
9
28
|
linkTarget,
|
|
10
29
|
linkSource,
|
|
11
30
|
config = {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
textPaddingFactor: 0.
|
|
31
|
+
fontSize: 3, // Максимальний розмір шрифту при максимальному зумі
|
|
32
|
+
nodeSizeBase: 30, // Базовий розмір вузла
|
|
33
|
+
nodeAreaFactor: 2, // Фактор збільшення розміру вузла для відображення області (hover)
|
|
34
|
+
textPaddingFactor: 0.9, // Скільки разів текст може бути ширшим за розмір вузла
|
|
35
|
+
gridSpacing: 20, // Відстань між точками сітки
|
|
36
|
+
dotSize: 1, // Розмір точки сітки,
|
|
37
|
+
maxZoom: 4, // Максимальний зум
|
|
16
38
|
},
|
|
17
39
|
onNodeClick,
|
|
18
40
|
onLinkClick,
|
|
19
41
|
}: Graph2DProps) => {
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
42
|
+
// Стан для підсвічування вузлів і зв'язків
|
|
43
|
+
const [highlightNodes, setHighlightNodes] = useState(new Set());
|
|
44
|
+
const [highlightLinks, setHighlightLinks] = useState(new Set());
|
|
45
|
+
const [hoverNode, setHoverNode] = useState<any>(null);
|
|
46
|
+
const [hiddenNodes, setHiddenNodes] = useState(new Set<string>());
|
|
47
|
+
const [collapsedNodes, setCollapsedNodes] = useState(new Set<string>());
|
|
48
|
+
// Стани для відстеження наведення на кнопки
|
|
49
|
+
const [hoverTopButton, setHoverTopButton] = useState(false);
|
|
50
|
+
const [hoverBottomButton, setHoverBottomButton] = useState(false);
|
|
51
|
+
|
|
52
|
+
const fgRef = useRef<ForceGraphMethods>(null) as React.MutableRefObject<ForceGraphMethods<NodeObject, LinkObject>>;
|
|
53
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
24
54
|
|
|
25
55
|
// Функція для реверсивного масштабування тексту
|
|
26
|
-
// При максимальному зумі текст має розмір
|
|
56
|
+
// При максимальному зумі текст має розмір config.fontSize
|
|
27
57
|
// При зменшенні зуму текст також зменшується
|
|
28
58
|
const calculateFontSize = (scale: number): number => {
|
|
29
|
-
// Обмежуємо масштаб до
|
|
30
|
-
const limitedScale = Math.min(scale,
|
|
59
|
+
// Обмежуємо масштаб до config.maxZoom
|
|
60
|
+
const limitedScale = Math.min(scale, config.maxZoom);
|
|
31
61
|
|
|
32
62
|
// Обчислюємо коефіцієнт масштабування: при максимальному зумі = 1, при мінімальному - менше
|
|
33
|
-
const fontSizeRatio = limitedScale /
|
|
63
|
+
const fontSizeRatio = limitedScale / config.maxZoom;
|
|
34
64
|
|
|
35
|
-
|
|
36
|
-
return Math.max(MAX_FONT_SIZE * fontSizeRatio, MAX_FONT_SIZE / MAX_ZOOM);
|
|
65
|
+
return Math.max(config.fontSize * fontSizeRatio, config.fontSize);
|
|
37
66
|
};
|
|
38
67
|
|
|
39
|
-
|
|
68
|
+
// Обробка подій наведення на вузол
|
|
69
|
+
const handleNodeHover = useCallback((node: NodeObject | null, _: NodeObject | null) => {
|
|
70
|
+
const newHighlightNodes = new Set();
|
|
71
|
+
const newHighlightLinks = new Set();
|
|
72
|
+
|
|
73
|
+
if (node) {
|
|
74
|
+
newHighlightNodes.add(node);
|
|
75
|
+
|
|
76
|
+
// Додавання сусідніх вузлів і зв'язків до підсвічування
|
|
77
|
+
// Перевіряємо наявність сусідів і зв'язків
|
|
78
|
+
if (node.neighbors) {
|
|
79
|
+
node.neighbors.forEach((neighbor: any) => newHighlightNodes.add(neighbor));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (node.links) {
|
|
83
|
+
node.links.forEach((link: any) => newHighlightLinks.add(link));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
40
86
|
|
|
87
|
+
setHoverNode(node || null);
|
|
88
|
+
setHighlightNodes(newHighlightNodes);
|
|
89
|
+
setHighlightLinks(newHighlightLinks);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// Обробка подій наведення на зв'язок
|
|
93
|
+
const handleLinkHover = useCallback((link: any) => {
|
|
94
|
+
const newHighlightNodes = new Set();
|
|
95
|
+
const newHighlightLinks = new Set();
|
|
96
|
+
|
|
97
|
+
if (link) {
|
|
98
|
+
newHighlightLinks.add(link);
|
|
99
|
+
newHighlightNodes.add(link.source);
|
|
100
|
+
newHighlightNodes.add(link.target);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setHighlightNodes(newHighlightNodes);
|
|
104
|
+
setHighlightLinks(newHighlightLinks);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Створення взаємозв'язків між вузлами
|
|
41
108
|
useEffect(() => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
109
|
+
if (!graphData) return;
|
|
110
|
+
|
|
111
|
+
// Прив'язка вузлів до їхніх сусідів та зв'язків
|
|
112
|
+
graphData.links.forEach((link: any) => {
|
|
113
|
+
const source =
|
|
114
|
+
typeof link.source === 'object' ? link.source : graphData.nodes.find((n: any) => n.id === link.source);
|
|
115
|
+
const target =
|
|
116
|
+
typeof link.target === 'object' ? link.target : graphData.nodes.find((n: any) => n.id === link.target);
|
|
117
|
+
|
|
118
|
+
if (!source || !target) return;
|
|
119
|
+
|
|
120
|
+
// Ініціалізація масивів, якщо вони відсутні
|
|
121
|
+
!source.neighbors && (source.neighbors = []);
|
|
122
|
+
!target.neighbors && (target.neighbors = []);
|
|
123
|
+
source.neighbors.push(target);
|
|
124
|
+
target.neighbors.push(source);
|
|
125
|
+
|
|
126
|
+
!source.links && (source.links = []);
|
|
127
|
+
!target.links && (target.links = []);
|
|
128
|
+
source.links.push(link);
|
|
129
|
+
target.links.push(link);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Налаштування відстані між вузлами
|
|
133
|
+
fgRef.current?.d3Force('link')?.distance((link: any) => {
|
|
134
|
+
// Отримуємо вузли на кінцях зв'язку
|
|
135
|
+
const source = link.source;
|
|
136
|
+
const target = link.target;
|
|
137
|
+
|
|
138
|
+
// Базова відстань
|
|
139
|
+
const baseDistance = config.nodeSizeBase * 2;
|
|
140
|
+
|
|
141
|
+
// Динамічна відстань на основі розміру вузлів
|
|
142
|
+
// Більші вузли повинні бути далі один від одного
|
|
143
|
+
const sourceSizeBase = source.size || config.nodeSizeBase;
|
|
144
|
+
const targetSizeBase = target.size || config.nodeSizeBase;
|
|
145
|
+
|
|
146
|
+
// Відстань залежить від суми розмірів вузлів
|
|
147
|
+
// Додаємо базову відстань 100
|
|
148
|
+
return baseDistance + (sourceSizeBase + targetSizeBase);
|
|
46
149
|
});
|
|
150
|
+
|
|
151
|
+
// Додаємо різні сили для уникнення перекриття вузлів
|
|
152
|
+
if (fgRef.current) {
|
|
153
|
+
// 1. Додаємо силу відштовхування між всіма вузлами (charge force)
|
|
154
|
+
const chargeForce = fgRef.current.d3Force('charge');
|
|
155
|
+
if (chargeForce) {
|
|
156
|
+
chargeForce
|
|
157
|
+
.strength(-100) // Збільшуємо силу відштовхування (negative for repulsion)
|
|
158
|
+
.distanceMax(100); // Максимальна дистанція, на якій діє ця сила
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2. Додаємо силу центрування для кращої організації графа
|
|
162
|
+
const centerForce = fgRef.current.d3Force('center');
|
|
163
|
+
if (centerForce) {
|
|
164
|
+
centerForce.strength(0.05); // Невелике притягування до центру
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Додаємо силу колізії через імпортовану функцію forceCollide
|
|
168
|
+
try {
|
|
169
|
+
const collideForce = forceCollide()
|
|
170
|
+
.radius((node: any) => {
|
|
171
|
+
// Визначаємо радіус колізії на основі розміру вузла
|
|
172
|
+
const nodeSize = node.size || config.nodeSizeBase;
|
|
173
|
+
return nodeSize * 1.5; // Більший відступ для кращої сепарації
|
|
174
|
+
})
|
|
175
|
+
.iterations(3) // Більше ітерацій для точнішого розрахунку
|
|
176
|
+
.strength(1); // Максимальна сила (1 - тверде обмеження)
|
|
177
|
+
|
|
178
|
+
fgRef.current.d3Force('collide', collideForce);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error('Error setting up collision force:', err);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Перезапустити симуляцію для застосування змін
|
|
184
|
+
fgRef.current.resumeAnimation();
|
|
185
|
+
}
|
|
47
186
|
}, [graphData]);
|
|
48
187
|
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
188
|
+
// Функція для малювання кільця навколо підсвічених вузлів
|
|
189
|
+
const paintRing = useCallback(
|
|
190
|
+
(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
191
|
+
// Отримуємо розмір вузла
|
|
192
|
+
const radius = (config.nodeSizeBase * config.nodeAreaFactor * 0.75) / 2;
|
|
193
|
+
|
|
194
|
+
// Малюємо кільце навколо вузла
|
|
195
|
+
ctx.beginPath();
|
|
196
|
+
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
|
|
197
|
+
ctx.fillStyle = 'rgba(255, 165, 0, 0.3)';
|
|
198
|
+
ctx.fill();
|
|
199
|
+
},
|
|
200
|
+
[config]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Функція для малювання кнопок навколо вузла при наведенні
|
|
204
|
+
const paintNodeButtons = useCallback(
|
|
205
|
+
(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
206
|
+
const { x, y } = node;
|
|
207
|
+
const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
|
|
208
|
+
|
|
209
|
+
// Зберігаємо стан контексту
|
|
210
|
+
ctx.save();
|
|
211
|
+
|
|
212
|
+
// Кнопка "сховати" (верхня частина кільця)
|
|
213
|
+
ctx.beginPath();
|
|
214
|
+
ctx.arc(x, y, buttonRadius, Math.PI, Math.PI * 2, false);
|
|
215
|
+
ctx.lineWidth = 1;
|
|
216
|
+
ctx.strokeStyle = '#e5e5e5';
|
|
217
|
+
ctx.stroke();
|
|
218
|
+
ctx.fillStyle = hoverTopButton ? 'rgba(230, 230, 230, 0.9)' : 'rgba(255, 255, 255, 0.8)';
|
|
219
|
+
ctx.fill();
|
|
220
|
+
|
|
221
|
+
// Лінія розділення між кнопками
|
|
222
|
+
ctx.beginPath();
|
|
223
|
+
ctx.moveTo(x - buttonRadius, y);
|
|
224
|
+
ctx.lineTo(x + buttonRadius, y);
|
|
225
|
+
ctx.lineWidth = 1;
|
|
226
|
+
ctx.strokeStyle = '#e5e5e5';
|
|
227
|
+
ctx.stroke();
|
|
228
|
+
|
|
229
|
+
// Кнопка "згорнути" (нижня частина кільця)
|
|
230
|
+
ctx.beginPath();
|
|
231
|
+
ctx.arc(x, y, buttonRadius, Math.PI * 2, Math.PI, false);
|
|
232
|
+
ctx.lineWidth = 1;
|
|
233
|
+
ctx.strokeStyle = '#e5e5e5';
|
|
234
|
+
ctx.stroke();
|
|
235
|
+
ctx.fillStyle = hoverBottomButton ? 'rgba(230, 230, 230, 0.9)' : 'rgba(255, 255, 255, 0.8)';
|
|
236
|
+
ctx.fill();
|
|
237
|
+
|
|
238
|
+
// Додаємо іконку хрестика для кнопки "сховати вузол"
|
|
239
|
+
const iconSize = buttonRadius * 0.3; // Розмір іконки відносно радіуса кнопки (зменшено вдвічі)
|
|
240
|
+
|
|
241
|
+
// Вибір іконки в залежності від стану наведення для верхньої кнопки (сховати)
|
|
242
|
+
const crossIcon = hoverTopButton ? imgCrossLightHoverIcon : imgCrossLightIcon;
|
|
243
|
+
const renderCrossIcon = () => {
|
|
244
|
+
ctx.drawImage(crossIcon, x - iconSize / 2, y - (buttonRadius * 2) / 4 - iconSize - 1, iconSize, iconSize);
|
|
245
|
+
};
|
|
246
|
+
// Використовуємо безпосередньо зображення, якщо воно вже завантажене
|
|
247
|
+
if (crossIcon.complete) {
|
|
248
|
+
// Розміщуємо іконку в центрі верхньої половини кнопки
|
|
249
|
+
renderCrossIcon();
|
|
250
|
+
} else {
|
|
251
|
+
// Якщо зображення ще не завантажене, додаємо обробник завершення завантаження
|
|
252
|
+
crossIcon.onload = () => {
|
|
253
|
+
renderCrossIcon();
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Додаємо іконку ока для кнопки "згорнути дочірні вузли"
|
|
258
|
+
const eyeIcon = hoverBottomButton ? imgEyeLightHoverIcon : imgEyeLightIcon;
|
|
259
|
+
const renderEyeIcon = () => {
|
|
260
|
+
ctx.drawImage(eyeIcon, x - iconSize / 2, y + (buttonRadius * 2) / 4 + 1, iconSize, iconSize);
|
|
261
|
+
};
|
|
262
|
+
// Використовуємо безпосередньо зображення, якщо воно вже завантажене
|
|
263
|
+
if (eyeIcon.complete) {
|
|
264
|
+
// Розміщуємо іконку в центрі нижньої половини кнопки
|
|
265
|
+
renderEyeIcon();
|
|
266
|
+
} else {
|
|
267
|
+
// Якщо зображення ще не завантажене, додаємо обробник завершення завантаження
|
|
268
|
+
eyeIcon.onload = () => {
|
|
269
|
+
renderEyeIcon();
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
ctx.restore();
|
|
274
|
+
},
|
|
275
|
+
[config, hoverTopButton, hoverBottomButton]
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const collapseNode = (collapsed: Set<string>, node: NodeObject) => {
|
|
279
|
+
if (node && node.id && !collapsed.has(`${node.id}`) && graphData) {
|
|
280
|
+
// Прив'язка вузлів до їхніх сусідів та зв'язків
|
|
281
|
+
const targets = graphData.links.filter((link: any) => {
|
|
282
|
+
return link.source.id === node.id && link.label !== 'MATCH';
|
|
283
|
+
});
|
|
284
|
+
targets.forEach((link: any) => {
|
|
285
|
+
collapsed.add(`${link.target.id}`);
|
|
286
|
+
collapseNode(collapsed, link.target);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Функція для обробки кліку на кнопку "сховати вузол"
|
|
292
|
+
const handleHideNode = (node: any) => {
|
|
293
|
+
const newHiddenNodes = new Set(hiddenNodes);
|
|
294
|
+
collapseNode(newHiddenNodes, node);
|
|
295
|
+
newHiddenNodes.add(node.id);
|
|
296
|
+
|
|
297
|
+
setHiddenNodes(newHiddenNodes);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Функція для обробки кліку на кнопку "згорнути дочірні вузли"
|
|
301
|
+
const handleCollapseChildren = (node: any) => {
|
|
302
|
+
const newCollapsedNodes = new Set(collapsedNodes);
|
|
303
|
+
|
|
304
|
+
collapseNode(newCollapsedNodes, node);
|
|
305
|
+
setCollapsedNodes(newCollapsedNodes);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Функція для визначення, чи знаходиться точка в межах сектора кола (кнопки)
|
|
309
|
+
const isPointInButtonArea = useCallback(
|
|
310
|
+
(
|
|
311
|
+
x: number, // X координата точки кліку в системі координат canvas
|
|
312
|
+
y: number, // Y координата точки кліку в системі координат canvas
|
|
313
|
+
buttonX: number, // X координата центра вузла в системі координат canvas
|
|
314
|
+
buttonY: number, // Y координата центра вузла в системі координат canvas
|
|
315
|
+
buttonRadius: number, // Радіус кнопки (з урахуванням зуму)
|
|
316
|
+
startAngle: number, // Початковий кут сектора
|
|
317
|
+
endAngle: number // Кінцевий кут сектора
|
|
318
|
+
): boolean => {
|
|
319
|
+
// Обчислюємо відстань від точки кліку до центру вузла
|
|
320
|
+
const dx = x - buttonX;
|
|
321
|
+
const dy = y - buttonY;
|
|
322
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
323
|
+
|
|
324
|
+
// Обчислюємо кут між точкою та горизонтальною віссю
|
|
325
|
+
let angle = Math.atan2(dy, dx);
|
|
326
|
+
if (angle < 0) angle += 2 * Math.PI; // Конвертуємо у діапазон [0, 2π]
|
|
327
|
+
|
|
328
|
+
// Розширюємо діапазон радіусу для легшого потрапляння по кнопці
|
|
329
|
+
// При більшому зумі можна зменшити цей діапазон для більшої точності
|
|
330
|
+
const minRadiusRatio = 0.5; // Більш точне значення
|
|
331
|
+
const maxRadiusRatio = 1.5; // Більш точне значення
|
|
332
|
+
const isInRadius = distance >= buttonRadius * minRadiusRatio && distance <= buttonRadius * maxRadiusRatio;
|
|
333
|
+
|
|
334
|
+
// Перевіряємо чи знаходиться кут у межах сектора
|
|
335
|
+
let isInAngle = false;
|
|
336
|
+
|
|
337
|
+
// Верхня півкуля: від Math.PI до Math.PI * 2
|
|
338
|
+
if (startAngle === Math.PI && endAngle === Math.PI * 2) {
|
|
339
|
+
isInAngle = angle >= Math.PI && angle <= Math.PI * 2;
|
|
340
|
+
}
|
|
341
|
+
// Нижня півкуля: від 0 до Math.PI
|
|
342
|
+
else if (startAngle === 0 && endAngle === Math.PI) {
|
|
343
|
+
isInAngle = angle >= 0 && angle <= Math.PI;
|
|
344
|
+
}
|
|
345
|
+
// Загальний випадок
|
|
346
|
+
else {
|
|
347
|
+
isInAngle =
|
|
348
|
+
(startAngle <= endAngle && angle >= startAngle && angle <= endAngle) ||
|
|
349
|
+
(startAngle > endAngle && (angle >= startAngle || angle <= endAngle));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return isInRadius && isInAngle;
|
|
353
|
+
},
|
|
354
|
+
[]
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Додаємо обробник руху миші для відстеження наведення на кнопки
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
const handleCanvasMouseMove = (event: MouseEvent) => {
|
|
360
|
+
if (!hoverNode || !fgRef.current || !wrapperRef.current) {
|
|
361
|
+
// Скидаємо стани наведення, якщо немає активного вузла
|
|
362
|
+
if (hoverTopButton) setHoverTopButton(false);
|
|
363
|
+
if (hoverBottomButton) setHoverBottomButton(false);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const nodeSize = config.nodeSizeBase;
|
|
368
|
+
const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
|
|
369
|
+
const zoom = fgRef.current.zoom() || 1; // Отримуємо поточний зум
|
|
370
|
+
|
|
371
|
+
// Координати вузла в системі координат графа
|
|
372
|
+
const nodeX = hoverNode.x;
|
|
373
|
+
const nodeY = hoverNode.y;
|
|
374
|
+
|
|
375
|
+
// Отримуємо позицію canvas відносно вікна
|
|
376
|
+
const canvasRect = wrapperRef.current.getBoundingClientRect();
|
|
377
|
+
|
|
378
|
+
// Координати миші відносно canvas
|
|
379
|
+
const mouseX = event.clientX - canvasRect.left;
|
|
380
|
+
const mouseY = event.clientY - canvasRect.top;
|
|
381
|
+
|
|
382
|
+
// Враховуємо співвідношення між реальними розмірами canvas та його відображенням на екрані
|
|
383
|
+
const canvasScaleX = wrapperRef.current.clientWidth / canvasRect.width;
|
|
384
|
+
const canvasScaleY = wrapperRef.current.clientHeight / canvasRect.height;
|
|
385
|
+
|
|
386
|
+
// Масштабовані координати миші у внутрішній системі координат canvas
|
|
387
|
+
const scaledMouseX = mouseX * canvasScaleX;
|
|
388
|
+
const scaledMouseY = mouseY * canvasScaleY;
|
|
389
|
+
|
|
390
|
+
// Отримуємо параметри трансформації графа
|
|
391
|
+
const graphCenter = {
|
|
392
|
+
x: wrapperRef.current.clientWidth / 2,
|
|
393
|
+
y: wrapperRef.current.clientHeight / 2,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Визначаємо координати вузла на екрані
|
|
397
|
+
let nodeScreenX, nodeScreenY;
|
|
398
|
+
|
|
399
|
+
if (typeof fgRef.current.graph2ScreenCoords === 'function') {
|
|
400
|
+
// Використовуємо API графа для перетворення координат
|
|
401
|
+
const screenPos = fgRef.current.graph2ScreenCoords(nodeX, nodeY);
|
|
402
|
+
if (screenPos) {
|
|
403
|
+
nodeScreenX = screenPos.x;
|
|
404
|
+
nodeScreenY = screenPos.y;
|
|
104
405
|
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Якщо метод не доступний, спробуємо обчислити позицію
|
|
409
|
+
if (nodeScreenX === undefined || nodeScreenY === undefined) {
|
|
410
|
+
nodeScreenX = graphCenter.x + nodeX * zoom;
|
|
411
|
+
nodeScreenY = graphCenter.y + nodeY * zoom;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Перевіряємо наведення на верхню кнопку (hide)
|
|
415
|
+
const isOverTopButton = isPointInButtonArea(
|
|
416
|
+
scaledMouseX,
|
|
417
|
+
scaledMouseY,
|
|
418
|
+
nodeScreenX,
|
|
419
|
+
nodeScreenY,
|
|
420
|
+
buttonRadius * zoom,
|
|
421
|
+
Math.PI,
|
|
422
|
+
Math.PI * 2
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
// Перевіряємо наведення на нижню кнопку (collapse)
|
|
426
|
+
const isOverBottomButton = isPointInButtonArea(
|
|
427
|
+
scaledMouseX,
|
|
428
|
+
scaledMouseY,
|
|
429
|
+
nodeScreenX,
|
|
430
|
+
nodeScreenY,
|
|
431
|
+
buttonRadius * zoom,
|
|
432
|
+
0,
|
|
433
|
+
Math.PI
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// Оновлюємо стани наведення
|
|
437
|
+
setHoverTopButton(isOverTopButton);
|
|
438
|
+
setHoverBottomButton(isOverBottomButton);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (wrapperRef.current) {
|
|
442
|
+
wrapperRef.current.addEventListener('mousemove', handleCanvasMouseMove);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return () => {
|
|
446
|
+
if (wrapperRef.current) {
|
|
447
|
+
wrapperRef.current.removeEventListener('mousemove', handleCanvasMouseMove);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}, [hoverNode, config, isPointInButtonArea, hoverTopButton, hoverBottomButton]);
|
|
451
|
+
|
|
452
|
+
const truncateText = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
|
|
453
|
+
if (!text) return '';
|
|
454
|
+
|
|
455
|
+
// Вимірюємо ширину тексту
|
|
456
|
+
const textWidth = ctx.measureText(text).width;
|
|
105
457
|
|
|
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
|
-
|
|
458
|
+
// Якщо текст коротший за максимальну ширину, повертаємо як є
|
|
459
|
+
if (textWidth <= maxWidth) return text;
|
|
460
|
+
|
|
461
|
+
// Інакше обрізаємо текст і додаємо трикрапку
|
|
462
|
+
let truncated = text;
|
|
463
|
+
const ellipsis = '...';
|
|
464
|
+
|
|
465
|
+
// Поступово скорочуємо текст, поки він не поміститься
|
|
466
|
+
while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
|
|
467
|
+
truncated = truncated.slice(0, -1);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return truncated + ellipsis;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const renderGrid = (ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
474
|
+
// This will be called before each rendering frame
|
|
475
|
+
ctx.getTransform();
|
|
476
|
+
ctx.save();
|
|
477
|
+
|
|
478
|
+
// Reset transform to draw the background in screen coordinates
|
|
479
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
480
|
+
|
|
481
|
+
// Draw background dots
|
|
482
|
+
const { width, height } = ctx.canvas;
|
|
483
|
+
const gridSpacing = config.gridSpacing;
|
|
484
|
+
const dotSize = config.dotSize;
|
|
485
|
+
|
|
486
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
487
|
+
|
|
488
|
+
for (let x = 0; x < width; x += gridSpacing) {
|
|
489
|
+
for (let y = 0; y < height; y += gridSpacing) {
|
|
132
490
|
ctx.beginPath();
|
|
133
|
-
ctx.arc(x, y,
|
|
134
|
-
ctx.fillStyle = color;
|
|
491
|
+
ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
|
|
135
492
|
ctx.fill();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
136
495
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ctx.translate(x, y);
|
|
496
|
+
// Restore original transform for the graph rendering
|
|
497
|
+
ctx.restore();
|
|
498
|
+
};
|
|
141
499
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
500
|
+
const renderNodePointerAreaPaint = (
|
|
501
|
+
node: NodeObject,
|
|
502
|
+
color: string,
|
|
503
|
+
ctx: CanvasRenderingContext2D,
|
|
504
|
+
globalScale: number
|
|
505
|
+
) => {
|
|
506
|
+
const { x, y } = node;
|
|
507
|
+
const radius = config.nodeSizeBase;
|
|
145
508
|
|
|
146
|
-
|
|
147
|
-
|
|
509
|
+
ctx.beginPath();
|
|
510
|
+
ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
|
|
511
|
+
ctx.fillStyle = color; //має бути обовʼязково колір що прийшов з параметрів ForceGraph
|
|
512
|
+
ctx.fill();
|
|
513
|
+
};
|
|
148
514
|
|
|
149
|
-
|
|
150
|
-
|
|
515
|
+
const renderNodeCanvasObject = (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
516
|
+
// Функція для обрізання тексту з трикрапкою (аналог text-overflow: ellipsis)
|
|
151
517
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
518
|
+
// Якщо вузол підсвічений, малюємо кільце
|
|
519
|
+
if (highlightNodes.has(node)) {
|
|
520
|
+
// Якщо це наведений вузол, малюємо кнопки
|
|
521
|
+
if (node === hoverNode) {
|
|
522
|
+
paintNodeButtons(node, ctx, globalScale);
|
|
523
|
+
} else {
|
|
524
|
+
paintRing(node, ctx, globalScale);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
155
527
|
|
|
156
|
-
|
|
157
|
-
while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
|
|
158
|
-
truncated = truncated.slice(0, -1);
|
|
159
|
-
}
|
|
528
|
+
const { x, y, color, label } = node;
|
|
160
529
|
|
|
161
|
-
|
|
162
|
-
|
|
530
|
+
const size = config.nodeSizeBase;
|
|
531
|
+
const radius = config.nodeSizeBase / 2;
|
|
532
|
+
|
|
533
|
+
// Малюємо коло
|
|
534
|
+
ctx.beginPath();
|
|
535
|
+
ctx.arc(x as number, y as number, radius, 0, 2 * Math.PI);
|
|
536
|
+
ctx.fillStyle = color; // Колір контуру TODO: додати прив'язку до значення label
|
|
537
|
+
ctx.fill();
|
|
538
|
+
|
|
539
|
+
// пігтовока до малювання тексту
|
|
540
|
+
ctx.save();
|
|
541
|
+
ctx.translate(x as number, y as number);
|
|
542
|
+
|
|
543
|
+
const scaledFontSize = calculateFontSize(globalScale);
|
|
544
|
+
const maxWidth = size * config.textPaddingFactor;
|
|
545
|
+
// Розрахунок максимальної ширини тексту на основі розміру вузла
|
|
546
|
+
// Ширина тексту = діаметр вузла * коефіцієнт розширення
|
|
547
|
+
// Використовуємо globalScale для визначення пропорцій тексту
|
|
548
|
+
ctx.font = `${scaledFontSize}px Sans-Serif`;
|
|
549
|
+
ctx.textAlign = 'center';
|
|
550
|
+
ctx.textBaseline = 'middle';
|
|
551
|
+
ctx.fillStyle = 'black';
|
|
163
552
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
553
|
+
const truncatedLabel = truncateText(label, maxWidth, ctx);
|
|
554
|
+
ctx.fillText(truncatedLabel, 0, 0);
|
|
555
|
+
|
|
556
|
+
ctx.restore();
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const renderLinkCanvasObject = (link: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
560
|
+
// Отримуємо позиції початку і кінця зв'язку
|
|
561
|
+
const { source, target, label } = link;
|
|
562
|
+
|
|
563
|
+
if (!label) return; // Пропускаємо, якщо немає мітки
|
|
564
|
+
|
|
565
|
+
// Координати початку і кінця зв'язку
|
|
566
|
+
const start = { x: source.x, y: source.y };
|
|
567
|
+
const end = { x: target.x, y: target.y };
|
|
568
|
+
// Знаходимо середину лінії для розміщення тексту
|
|
569
|
+
const middleX = start.x + (end.x - start.x) / 2;
|
|
570
|
+
const middleY = start.y + (end.y - start.y) / 2;
|
|
571
|
+
|
|
572
|
+
// Використовуємо реверсивне масштабування для тексту
|
|
573
|
+
const scaledFontSize = calculateFontSize(globalScale);
|
|
574
|
+
ctx.font = `${scaledFontSize}px Sans-Serif`;
|
|
575
|
+
ctx.fillStyle = '#666'; // Колір тексту
|
|
576
|
+
ctx.textAlign = 'center';
|
|
577
|
+
ctx.textBaseline = 'middle';
|
|
578
|
+
|
|
579
|
+
// Визначення кута нахилу лінії для повороту тексту
|
|
580
|
+
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
|
581
|
+
ctx.save();
|
|
582
|
+
// Переміщення до центру лінії та поворот тексту
|
|
583
|
+
ctx.translate(middleX, middleY);
|
|
584
|
+
// Якщо кут близький до вертикального або перевернутий, коригуємо його
|
|
585
|
+
if (Math.abs(angle) > Math.PI / 2) {
|
|
586
|
+
ctx.rotate(angle + Math.PI);
|
|
587
|
+
ctx.textAlign = 'center';
|
|
588
|
+
} else {
|
|
589
|
+
ctx.rotate(angle);
|
|
590
|
+
ctx.textAlign = 'center';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Рисуємо фон для тексту для кращої читаємості
|
|
594
|
+
const textWidth = ctx.measureText(label).width;
|
|
595
|
+
const padding = 2;
|
|
596
|
+
ctx.fillStyle = highlightLinks.has(link) ? 'rgba(255, 230, 204, 0.9)' : 'rgba(255, 255, 255, 0.8)';
|
|
597
|
+
ctx.fillRect(
|
|
598
|
+
-textWidth / 2 - padding,
|
|
599
|
+
-scaledFontSize / 2 - padding,
|
|
600
|
+
textWidth + padding * 2,
|
|
601
|
+
scaledFontSize + padding * 2
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
// Малюємо текст
|
|
605
|
+
ctx.fillStyle = highlightLinks.has(link) ? '#663300' : '#666';
|
|
606
|
+
ctx.fillText(label, 0, 0);
|
|
607
|
+
|
|
608
|
+
// Відновлення стану контексту
|
|
609
|
+
ctx.restore();
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const handleNodeClick = (node: NodeObject, event: MouseEvent) => {
|
|
613
|
+
if (!node || !fgRef.current) return;
|
|
614
|
+
|
|
615
|
+
const buttonRadius = (config.nodeSizeBase * config.nodeAreaFactor) / 2;
|
|
616
|
+
const zoom = fgRef.current.zoom() || 1; // Отримуємо поточний зум
|
|
617
|
+
const canvas = event.target as HTMLCanvasElement;
|
|
618
|
+
// Координати вузла в системі координат графа
|
|
619
|
+
const nodeX = node.x as number;
|
|
620
|
+
const nodeY = node.y as number;
|
|
621
|
+
|
|
622
|
+
// // Отримуємо позицію canvas відносно вікна
|
|
623
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
624
|
+
|
|
625
|
+
// Координати кліку відносно canvas
|
|
626
|
+
// event.clientX/Y - це координати кліку відносно вікна браузера
|
|
627
|
+
// віднімаємо координати canvas щоб отримати координати відносно canvas
|
|
628
|
+
// враховуємо також можливий скролінг сторінки
|
|
629
|
+
const clickX = event.clientX - canvasRect.left;
|
|
630
|
+
const clickY = event.clientY - canvasRect.top;
|
|
631
|
+
|
|
632
|
+
// Враховуємо співвідношення між реальними розмірами canvas та його відображенням на екрані
|
|
633
|
+
// це важливо якщо canvas відмальовується з відмінним від реального розміру
|
|
634
|
+
const canvasScaleX = canvas.width / canvasRect.width;
|
|
635
|
+
const canvasScaleY = canvas.height / canvasRect.height;
|
|
636
|
+
|
|
637
|
+
// Масштабовані координати кліку у внутрішній системі координат canvas
|
|
638
|
+
const scaledClickX = clickX * canvasScaleX;
|
|
639
|
+
const scaledClickY = clickY * canvasScaleY;
|
|
640
|
+
|
|
641
|
+
// Отримуємо параметри трансформації графа
|
|
642
|
+
// ForceGraph використовує центр канваса як початок координат
|
|
643
|
+
// і застосовує зум і панорамування до всіх координат
|
|
644
|
+
const graphCenter = {
|
|
645
|
+
x: canvas.width / 2,
|
|
646
|
+
y: canvas.height / 2,
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// Спробуємо отримати реальні координати вузла на екрані
|
|
650
|
+
// Якщо доступний метод перетворення координат
|
|
651
|
+
let nodeScreenX, nodeScreenY;
|
|
652
|
+
|
|
653
|
+
if (typeof fgRef.current.graph2ScreenCoords === 'function') {
|
|
654
|
+
// Використовуємо API графа для перетворення координат
|
|
655
|
+
const screenPos = fgRef.current.graph2ScreenCoords(nodeX, nodeY);
|
|
656
|
+
if (screenPos) {
|
|
657
|
+
nodeScreenX = screenPos.x;
|
|
658
|
+
nodeScreenY = screenPos.y;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Якщо метод не доступний, спробуємо обчислити позицію
|
|
663
|
+
if (nodeScreenX === undefined || nodeScreenY === undefined) {
|
|
664
|
+
// Це наближене обчислення, яке може бути неточним, але краще ніж нічого
|
|
665
|
+
// Ми припускаємо, що граф знаходиться в центрі канваса і застосовується масштабування
|
|
666
|
+
nodeScreenX = graphCenter.x + nodeX * zoom;
|
|
667
|
+
nodeScreenY = graphCenter.y + nodeY * zoom;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Перевіряємо клік на верхній кнопці (hide)
|
|
671
|
+
if (
|
|
672
|
+
isPointInButtonArea(
|
|
673
|
+
scaledClickX,
|
|
674
|
+
scaledClickY,
|
|
675
|
+
nodeScreenX,
|
|
676
|
+
nodeScreenY,
|
|
677
|
+
buttonRadius * zoom, // Масштабуємо радіус відповідно до зуму
|
|
678
|
+
Math.PI, // Початковий кут для верхньої півсфери
|
|
679
|
+
Math.PI * 2 // Кінцевий кут для верхньої півсфери
|
|
680
|
+
)
|
|
681
|
+
) {
|
|
682
|
+
handleHideNode(hoverNode);
|
|
683
|
+
event.stopPropagation();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Перевіряємо клік на нижній кнопці (collapse)
|
|
688
|
+
if (
|
|
689
|
+
isPointInButtonArea(
|
|
690
|
+
scaledClickX,
|
|
691
|
+
scaledClickY,
|
|
692
|
+
nodeScreenX,
|
|
693
|
+
nodeScreenY,
|
|
694
|
+
buttonRadius * zoom, // Масштабуємо радіус відповідно до зуму
|
|
695
|
+
0, // Початковий кут для нижньої півсфери
|
|
696
|
+
Math.PI // Кінцевий кут для нижньої півсфери
|
|
697
|
+
)
|
|
698
|
+
) {
|
|
699
|
+
handleCollapseChildren(hoverNode);
|
|
700
|
+
event.stopPropagation();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Якщо клік не на кнопках, обробляємо клік на вузлі
|
|
705
|
+
if (onNodeClick) onNodeClick(node);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
return (
|
|
709
|
+
<Wrapper ref={wrapperRef}>
|
|
710
|
+
<ForceGraph2D
|
|
711
|
+
ref={fgRef}
|
|
712
|
+
width={width}
|
|
713
|
+
height={height}
|
|
714
|
+
graphData={graphData}
|
|
715
|
+
linkTarget={linkTarget}
|
|
716
|
+
linkSource={linkSource}
|
|
717
|
+
onLinkClick={onLinkClick}
|
|
718
|
+
onNodeClick={handleNodeClick}
|
|
719
|
+
nodeLabel={(node: any) => `${node.label || ''}`} // Показуємо повний текст у тултіпі
|
|
720
|
+
linkLabel={(link: any) => link.label}
|
|
721
|
+
nodeAutoColorBy="label"
|
|
722
|
+
linkDirectionalArrowLength={3.5}
|
|
723
|
+
linkDirectionalArrowRelPos={1}
|
|
724
|
+
linkCurvature={0}
|
|
725
|
+
// Обмеження максимального зуму
|
|
726
|
+
maxZoom={config.maxZoom}
|
|
727
|
+
minZoom={1}
|
|
728
|
+
// Додавання обробників наведення
|
|
729
|
+
onNodeHover={handleNodeHover}
|
|
730
|
+
onLinkHover={handleLinkHover}
|
|
731
|
+
// Виділення зв'язків при наведенні
|
|
732
|
+
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1)}
|
|
733
|
+
linkColor={(link: any) => (highlightLinks.has(link) ? '#ff9900' : '#999')}
|
|
734
|
+
onRenderFramePre={renderGrid}
|
|
735
|
+
nodePointerAreaPaint={renderNodePointerAreaPaint}
|
|
736
|
+
nodeCanvasObject={renderNodeCanvasObject}
|
|
737
|
+
linkCanvasObjectMode={() => 'after'}
|
|
738
|
+
linkCanvasObject={renderLinkCanvasObject}
|
|
739
|
+
linkVisibility={(link: any) => {
|
|
740
|
+
// Перевіряємо, чи вузол прихований
|
|
741
|
+
if (hiddenNodes.has(link.source.id) || hiddenNodes.has(link.target.id)) return false;
|
|
742
|
+
// Перевіряємо, чи вузол згорнутий
|
|
743
|
+
if (collapsedNodes.has(link.source.id) || collapsedNodes.has(link.target.id)) return false;
|
|
744
|
+
|
|
745
|
+
return true; // Показуємо вузол, якщо не прихований і не згорнутий
|
|
746
|
+
}}
|
|
747
|
+
nodeVisibility={(node: NodeObject) => {
|
|
748
|
+
// Перевіряємо, чи вузол прихований
|
|
749
|
+
if (hiddenNodes.has(node.id as string)) return false;
|
|
750
|
+
// Перевіряємо, чи вузол згорнутий
|
|
751
|
+
if (collapsedNodes.has(node.id as string)) return false;
|
|
752
|
+
return true; // Показуємо вузол, якщо не прихований і не згорнутий
|
|
753
|
+
}}
|
|
754
|
+
/>
|
|
755
|
+
</Wrapper>
|
|
185
756
|
);
|
|
186
757
|
};
|
|
758
|
+
|
|
759
|
+
const Wrapper = styled.div`
|
|
760
|
+
display: inline-block;
|
|
761
|
+
`;
|