@cyber-harbour/ui 1.0.25 → 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.
@@ -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, MutableRefObject } from 'react';
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
- nodeSizeFactor: 2, // Множник для розміру вузла
13
- fontSize: 14, // Базовий розмір шрифту
14
- nodeSizeBase: 5, // Базовий розмір вузла (перед застосуванням множника)
15
- textPaddingFactor: 0.95, // Скільки разів текст може бути ширший за розмір вузла
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 MAX_ZOOM = 4;
22
- // Максимальний розмір шрифту при максимальному зумі
23
- const MAX_FONT_SIZE = 8;
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
- // При максимальному зумі текст має розмір MAX_FONT_SIZE
56
+ // При максимальному зумі текст має розмір config.fontSize
27
57
  // При зменшенні зуму текст також зменшується
28
58
  const calculateFontSize = (scale: number): number => {
29
- // Обмежуємо масштаб до MAX_ZOOM
30
- const limitedScale = Math.min(scale, MAX_ZOOM);
59
+ // Обмежуємо масштаб до config.maxZoom
60
+ const limitedScale = Math.min(scale, config.maxZoom);
31
61
 
32
62
  // Обчислюємо коефіцієнт масштабування: при максимальному зумі = 1, при мінімальному - менше
33
- const fontSizeRatio = limitedScale / MAX_ZOOM;
63
+ const fontSizeRatio = limitedScale / config.maxZoom;
34
64
 
35
- // Розраховуємо розмір шрифту в діапазоні від (MAX_FONT_SIZE / MAX_ZOOM) до MAX_FONT_SIZE
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
- const fgRef = useRef<ForceGraphMethods>(null) as MutableRefObject<ForceGraphMethods | undefined>;
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
- fgRef.current?.d3Force('link')?.distance(() => {
43
- return 100; // Set a constant distance of 100 for all links
44
- // Or use a function for dynamic distance based on link properties:
45
- // return link.value * 50;
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
- return (
50
- <ForceGraph2D
51
- ref={fgRef}
52
- width={width}
53
- height={height}
54
- graphData={graphData}
55
- linkTarget={linkTarget}
56
- linkSource={linkSource}
57
- onNodeClick={onNodeClick}
58
- onLinkClick={onLinkClick}
59
- nodeLabel={(node: any) => `${node.label}`} // Показуємо повний текст у тултіпі
60
- linkLabel={(link: any) => link.label}
61
- nodeAutoColorBy="label"
62
- linkDirectionalArrowLength={3.5}
63
- linkDirectionalArrowRelPos={1}
64
- linkCurvature={0}
65
- // Обмеження максимального зуму
66
- maxZoom={MAX_ZOOM}
67
- linkCanvasObjectMode={() => 'after'}
68
- linkCanvasObject={(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
69
- // Отримуємо позиції початку і кінця зв'язку
70
- const { source, target, label } = link;
71
- if (!label) return; // Пропускаємо, якщо немає мітки
72
-
73
- // Координати початку і кінця зв'язку
74
- const start = { x: source.x, y: source.y };
75
- const end = { x: target.x, y: target.y };
76
-
77
- // Знаходимо середину лінії для розміщення тексту
78
- const middleX = start.x + (end.x - start.x) / 2;
79
- const middleY = start.y + (end.y - start.y) / 2;
80
-
81
- // Використовуємо реверсивне масштабування для тексту
82
- const scaledFontSize = calculateFontSize(globalScale);
83
- ctx.font = `${scaledFontSize}px Sans-Serif`;
84
- ctx.fillStyle = '#666'; // Колір тексту
85
- ctx.textAlign = 'center';
86
- ctx.textBaseline = 'middle';
87
-
88
- // Визначення кута нахилу лінії для повороту тексту
89
- const angle = Math.atan2(end.y - start.y, end.x - start.x);
90
-
91
- // Збереження поточного стану контексту
92
- ctx.save();
93
-
94
- // Переміщення до центру лінії та поворот тексту
95
- ctx.translate(middleX, middleY);
96
-
97
- // Якщо кут близький до вертикального або перевернутий, коригуємо його
98
- if (Math.abs(angle) > Math.PI / 2) {
99
- ctx.rotate(angle + Math.PI);
100
- ctx.textAlign = 'center';
101
- } else {
102
- ctx.rotate(angle);
103
- ctx.textAlign = 'center';
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
- const textWidth = ctx.measureText(label).width;
108
- const padding = 2;
109
- ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
110
- ctx.fillRect(
111
- -textWidth / 2 - padding,
112
- -scaledFontSize / 2 - padding,
113
- textWidth + padding * 2,
114
- scaledFontSize + padding * 2
115
- );
116
-
117
- // Малюємо текст
118
- ctx.fillStyle = '#666';
119
- ctx.fillText(label, 0, 0);
120
-
121
- // Відновлення стану контексту
122
- ctx.restore();
123
- }}
124
- nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
125
- // Отримуємо дані вузла та конфігурацію
126
- const { x, y, color, label } = node;
127
-
128
- // Розраховуємо розмір вузла
129
- const nodeSize = config.nodeSizeBase * config.nodeSizeFactor;
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, nodeSize, 0, 2 * Math.PI);
134
- ctx.fillStyle = color;
491
+ ctx.arc(x, y, dotSize, 0, 2 * Math.PI);
135
492
  ctx.fill();
493
+ }
494
+ }
136
495
 
137
- // Зберігаємо стан контексту перед поворотом
138
- ctx.save();
139
- // Переміщуємось до позиції вузла
140
- ctx.translate(x, y);
496
+ // Restore original transform for the graph rendering
497
+ ctx.restore();
498
+ };
141
499
 
142
- // Функція для обрізання тексту з трикрапкою (аналог text-overflow: ellipsis)
143
- const truncateText = (text: string, maxWidth: number): string => {
144
- if (!text) return '';
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
- const textWidth = ctx.measureText(text).width;
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
- if (textWidth <= maxWidth) return text;
515
+ const renderNodeCanvasObject = (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
516
+ // Функція для обрізання тексту з трикрапкою (аналог text-overflow: ellipsis)
151
517
 
152
- // Інакше обрізаємо текст і додаємо трикрапку
153
- let truncated = text;
154
- const ellipsis = '...';
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
- return truncated + ellipsis;
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
- const scaledFontSize = calculateFontSize(globalScale);
166
- ctx.font = `${scaledFontSize}px Sans-Serif`;
167
- ctx.textAlign = 'center';
168
- ctx.textBaseline = 'middle';
169
- ctx.fillStyle = 'black';
170
-
171
- // Розрахунок максимальної ширини тексту на основі розміру вузла
172
- // Ширина тексту = діаметр вузла * коефіцієнт розширення
173
- // Використовуємо globalScale для визначення пропорцій тексту
174
- const maxWidth = (nodeSize * config.textPaddingFactor) / globalScale;
175
-
176
- // Малюємо тип вузла з обрізанням (з меншою шириною)
177
- ctx.font = `${scaledFontSize * 0.8}px Sans-Serif`;
178
- const truncatedLabel = truncateText(label, maxWidth);
179
- ctx.fillText(truncatedLabel, 0, 0);
180
-
181
- // Відновлюємо стан контексту
182
- ctx.restore();
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
+ `;