@cyber-harbour/ui 1.0.19 → 1.0.21
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 -5
- package/dist/index.d.ts +89 -5
- package/dist/index.js +170 -71
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +170 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/Core/ContextMenu/ContextMenu.tsx +131 -0
- package/src/Core/ContextMenu/ContextMenuDelimiter.tsx +13 -0
- package/src/Core/ContextMenu/index.ts +3 -0
- package/src/Core/ContextMenu/useContextMenuControl.ts +21 -0
- package/src/Core/IconComponents/Plus.tsx +20 -0
- package/src/Core/IconComponents/index.ts +1 -0
- package/src/Core/Select/Select.tsx +116 -0
- package/src/Core/Select/index.ts +1 -0
- package/src/Core/index.ts +2 -0
- package/src/Graph2D/Graph2D.tsx +186 -0
- package/src/Graph2D/index.ts +2 -0
- package/src/Graph2D/json_test.json +3685 -0
- package/src/Graph2D/types.ts +22 -0
- package/src/Layouts/PageLayout/PageLayout.tsx +5 -1
- package/src/Theme/GlobalStyle.tsx +3 -0
- package/src/Theme/theme.ts +71 -0
- package/src/Theme/types.ts +22 -0
- package/src/Theme/utils.ts +15 -3
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyber-harbour/ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.mjs",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"license": "ISC",
|
|
28
28
|
"description": "",
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"react-force-graph-2d": "^1.27.1",
|
|
31
|
+
"react-tiny-popover": "^8.1.6",
|
|
30
32
|
"styled-components": "^6.1.18"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ButtonSize, getButtonSizeStyles } from '../../Theme';
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { Popover, PopoverAlign, PopoverPosition } from 'react-tiny-popover';
|
|
4
|
+
import { styled, useTheme } from 'styled-components';
|
|
5
|
+
import { ChevronDownIcon, ChevronUpIcon } from '../IconComponents';
|
|
6
|
+
|
|
7
|
+
interface ContextMenuProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onClick: () => void;
|
|
10
|
+
onClickOutside: (e: MouseEvent) => void;
|
|
11
|
+
size?: ButtonSize;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
fullWidth?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
children?: any;
|
|
16
|
+
anchor?: any;
|
|
17
|
+
positions?: PopoverPosition[] | PopoverPosition;
|
|
18
|
+
align?: PopoverAlign;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ContextMenu = ({
|
|
22
|
+
isOpen,
|
|
23
|
+
onClickOutside,
|
|
24
|
+
onClick,
|
|
25
|
+
anchor,
|
|
26
|
+
size = 'medium',
|
|
27
|
+
disabled,
|
|
28
|
+
fullWidth,
|
|
29
|
+
className,
|
|
30
|
+
positions = ['bottom'],
|
|
31
|
+
align = 'start',
|
|
32
|
+
children,
|
|
33
|
+
}: ContextMenuProps) => {
|
|
34
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
35
|
+
|
|
36
|
+
const theme = useTheme();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Popover
|
|
40
|
+
padding={theme.contextMenu.padding}
|
|
41
|
+
isOpen={isOpen}
|
|
42
|
+
positions={positions}
|
|
43
|
+
align={align}
|
|
44
|
+
onClickOutside={onClickOutside}
|
|
45
|
+
content={children}
|
|
46
|
+
containerStyle={{
|
|
47
|
+
backgroundColor: theme.colors.background,
|
|
48
|
+
border: `1px solid ${theme.colors.stroke.light}`,
|
|
49
|
+
boxShadow: '0px 0px 10px 0px rgba(0, 0, 0, 0.25)',
|
|
50
|
+
borderRadius: '5px',
|
|
51
|
+
overflow: 'auto',
|
|
52
|
+
maxHeight: '500px',
|
|
53
|
+
zIndex: `${9999}`,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<StyledButton
|
|
57
|
+
ref={buttonRef}
|
|
58
|
+
onClick={onClick}
|
|
59
|
+
$disabled={disabled}
|
|
60
|
+
$fullWidth={fullWidth}
|
|
61
|
+
$size={size}
|
|
62
|
+
className={className}
|
|
63
|
+
type="button"
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
>
|
|
66
|
+
<div>{anchor}</div>
|
|
67
|
+
{isOpen ? (
|
|
68
|
+
<ChevronUpIcon width={theme.contextMenu.icon.size} height={theme.contextMenu.icon.size} />
|
|
69
|
+
) : (
|
|
70
|
+
<ChevronDownIcon width={theme.contextMenu.icon.size} height={theme.contextMenu.icon.size} />
|
|
71
|
+
)}
|
|
72
|
+
</StyledButton>
|
|
73
|
+
</Popover>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Створюємо стилізований компонент, що використовує уніфіковану палітру
|
|
78
|
+
const StyledButton = styled.button<{
|
|
79
|
+
$size: ButtonSize;
|
|
80
|
+
$disabled?: boolean;
|
|
81
|
+
$fullWidth?: boolean;
|
|
82
|
+
}>`
|
|
83
|
+
${({ $size, $disabled, $fullWidth, theme }) => {
|
|
84
|
+
const sizes = getButtonSizeStyles(theme, $size);
|
|
85
|
+
return `
|
|
86
|
+
background: ${theme.contextMenu.button.default.background};
|
|
87
|
+
color: ${theme.contextMenu.button.default.text};
|
|
88
|
+
border-color: ${theme.contextMenu.button.default.border};
|
|
89
|
+
box-shadow: ${theme.contextMenu.button.default.boxShadow};
|
|
90
|
+
font-size: ${sizes.fontSize};
|
|
91
|
+
gap: ${sizes.gap};
|
|
92
|
+
padding-block: ${sizes.paddingBlock};
|
|
93
|
+
padding-inline: ${sizes.paddingInline};
|
|
94
|
+
border-radius: ${sizes.borderRadius};
|
|
95
|
+
border-width: ${sizes.borderWidth};
|
|
96
|
+
border-style: solid;
|
|
97
|
+
width: ${$fullWidth ? '100%' : 'auto'};
|
|
98
|
+
cursor: ${$disabled ? 'not-allowed' : 'pointer'};
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: ${$fullWidth ? 'space-between' : 'center'};
|
|
103
|
+
text-decoration: none;
|
|
104
|
+
transition: all 0.2s ease;
|
|
105
|
+
outline: none;
|
|
106
|
+
flex-direction: row;
|
|
107
|
+
|
|
108
|
+
&:hover {
|
|
109
|
+
background: ${theme.contextMenu.button.hover.background};
|
|
110
|
+
color: ${theme.contextMenu.button.hover.text};
|
|
111
|
+
border-color: ${theme.contextMenu.button.hover.border};
|
|
112
|
+
box-shadow: ${theme.contextMenu.button.hover.boxShadow};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
&:active {
|
|
116
|
+
background: ${theme.contextMenu.button.active.background};
|
|
117
|
+
color: ${theme.contextMenu.button.active.text};
|
|
118
|
+
border-color: ${theme.contextMenu.button.active.border};
|
|
119
|
+
box-shadow: ${theme.contextMenu.button.active.boxShadow};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&:disabled {
|
|
123
|
+
background: ${theme.contextMenu.button.disabled.background};
|
|
124
|
+
color: ${theme.contextMenu.button.disabled.text};
|
|
125
|
+
border-color: ${theme.contextMenu.button.disabled.border};
|
|
126
|
+
box-shadow: ${theme.contextMenu.button.disabled.boxShadow};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
`;
|
|
130
|
+
}}
|
|
131
|
+
`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { styled } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
interface StyledProps {}
|
|
4
|
+
|
|
5
|
+
export const ContextMenuDelimiter = styled.div<StyledProps>(
|
|
6
|
+
({ theme }) => `
|
|
7
|
+
margin-inline: ${theme.contextMenu.delimeter.marginInline};
|
|
8
|
+
margin-block: ${theme.contextMenu.delimeter.marginBlock};
|
|
9
|
+
border-top-width: ${theme.contextMenu.delimeter.thickness};
|
|
10
|
+
border-top-style: ${theme.contextMenu.delimeter.style};
|
|
11
|
+
border-top-color: ${theme.contextMenu.delimeter.color};
|
|
12
|
+
`
|
|
13
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export const useContextMenuControl = () => {
|
|
4
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
5
|
+
|
|
6
|
+
const toggleMenu = () => {
|
|
7
|
+
setIsOpen((prev) => !prev);
|
|
8
|
+
};
|
|
9
|
+
const closeMenu = () => {
|
|
10
|
+
setIsOpen(false);
|
|
11
|
+
};
|
|
12
|
+
const openMenu = () => {
|
|
13
|
+
setIsOpen(true);
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
isOpen,
|
|
17
|
+
toggleMenu,
|
|
18
|
+
closeMenu,
|
|
19
|
+
openMenu,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
interface PrintIconProps extends SVGProps<SVGSVGElement> {
|
|
4
|
+
fill?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const PlusIcon = ({ fill = 'currentColor', ...props }: PrintIconProps) => {
|
|
8
|
+
return (
|
|
9
|
+
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
10
|
+
<path
|
|
11
|
+
d="M9.21053 5.78952H0.789474C0.357895 5.78952 0 5.43162 0 5.00005C0 4.56847 0.357895 4.21057 0.789474 4.21057H9.21053C9.64211 4.21057 10 4.56847 10 5.00005C10 5.43162 9.64211 5.78952 9.21053 5.78952Z"
|
|
12
|
+
fill={fill}
|
|
13
|
+
/>
|
|
14
|
+
<path
|
|
15
|
+
d="M5.00041 10C4.56883 10 4.21094 9.64211 4.21094 9.21053V0.789474C4.21094 0.357895 4.56883 0 5.00041 0C5.43199 0 5.78988 0.357895 5.78988 0.789474V9.21053C5.78988 9.64211 5.43199 10 5.00041 10Z"
|
|
16
|
+
fill={fill}
|
|
17
|
+
/>
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { PopoverAlign, PopoverPosition } from 'react-tiny-popover';
|
|
3
|
+
import { ContextMenu } from '../ContextMenu';
|
|
4
|
+
import { ButtonSize, getButtonSizeStyles } from '@/Theme';
|
|
5
|
+
import { styled } from 'styled-components';
|
|
6
|
+
|
|
7
|
+
interface SelectProps<T extends string | number> {
|
|
8
|
+
selected?: T;
|
|
9
|
+
options: { value: T; inputDisplay?: string }[];
|
|
10
|
+
handleSelect: (id: T) => void;
|
|
11
|
+
placeholder: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
positions?: PopoverPosition[] | PopoverPosition;
|
|
14
|
+
align?: PopoverAlign;
|
|
15
|
+
size?: ButtonSize;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Select = <T extends string | number>({
|
|
19
|
+
options,
|
|
20
|
+
selected,
|
|
21
|
+
handleSelect,
|
|
22
|
+
placeholder,
|
|
23
|
+
disabled = false,
|
|
24
|
+
positions = ['bottom'],
|
|
25
|
+
align = 'start',
|
|
26
|
+
size = 'small',
|
|
27
|
+
}: SelectProps<T>) => {
|
|
28
|
+
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
29
|
+
const handleToggle = useCallback(() => {
|
|
30
|
+
if (!disabled) setIsOpen((prev) => !prev);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ContextMenu
|
|
35
|
+
isOpen={isOpen}
|
|
36
|
+
onClickOutside={() => setIsOpen(false)}
|
|
37
|
+
onClick={handleToggle}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
anchor={!selected ? placeholder : options.find((option) => option.value === selected)?.inputDisplay || selected}
|
|
40
|
+
fullWidth
|
|
41
|
+
positions={positions}
|
|
42
|
+
align={align}
|
|
43
|
+
size={size}
|
|
44
|
+
>
|
|
45
|
+
<StyledWrapper>
|
|
46
|
+
{options.map((item) => (
|
|
47
|
+
<StyledItem
|
|
48
|
+
onClick={() => {
|
|
49
|
+
handleSelect(item.value);
|
|
50
|
+
setIsOpen(false);
|
|
51
|
+
}}
|
|
52
|
+
type="button"
|
|
53
|
+
$selected={item.value === selected}
|
|
54
|
+
key={item.value}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
$size={size}
|
|
57
|
+
>
|
|
58
|
+
{item.inputDisplay || item.value}
|
|
59
|
+
</StyledItem>
|
|
60
|
+
))}
|
|
61
|
+
</StyledWrapper>
|
|
62
|
+
</ContextMenu>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const StyledWrapper = styled.div`
|
|
67
|
+
padding-block: 7px;
|
|
68
|
+
padding-inline: 5px;
|
|
69
|
+
button:not(:last-of-type) {
|
|
70
|
+
margin-bottom: 4px;
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const StyledItem = styled.button<{ $size: ButtonSize; $selected: boolean }>`
|
|
75
|
+
${({ theme, $size, $selected }) => {
|
|
76
|
+
const sizes = getButtonSizeStyles(theme, $size);
|
|
77
|
+
return `
|
|
78
|
+
background: ${theme.select.item.default.background};
|
|
79
|
+
color: ${theme.select.item.default.text};
|
|
80
|
+
font-size: ${sizes.fontSize};
|
|
81
|
+
gap: ${sizes.gap};
|
|
82
|
+
padding-block: ${sizes.paddingBlock};
|
|
83
|
+
padding-inline: ${sizes.paddingInline};
|
|
84
|
+
border-radius: ${sizes.borderRadius};
|
|
85
|
+
border-width: ${sizes.borderWidth};
|
|
86
|
+
border: none;
|
|
87
|
+
width: 100%;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-weight: 400;
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: flex-start;
|
|
93
|
+
text-decoration: none;
|
|
94
|
+
transition: all 0.2s ease;
|
|
95
|
+
outline: none;
|
|
96
|
+
flex-direction: row;
|
|
97
|
+
|
|
98
|
+
&:hover {
|
|
99
|
+
background-color: ${theme.select.item.hover.background};
|
|
100
|
+
color: ${theme.select.item.hover.text};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
${
|
|
104
|
+
$selected &&
|
|
105
|
+
`background: ${theme.select.item.active.background};
|
|
106
|
+
color: ${theme.select.item.active.text};`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
&:disabled {
|
|
110
|
+
background: ${theme.select.item.disabled.background};
|
|
111
|
+
color: ${theme.select.item.disabled.text};
|
|
112
|
+
cursor: not-allowed;
|
|
113
|
+
}
|
|
114
|
+
`;
|
|
115
|
+
}}
|
|
116
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Select';
|
package/src/Core/index.ts
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import ForceGraph2D, { ForceGraphMethods } from 'react-force-graph-2d';
|
|
2
|
+
import { Graph2DProps } from './types';
|
|
3
|
+
import { useEffect, useRef, MutableRefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
export const Graph2D = ({
|
|
6
|
+
graphData,
|
|
7
|
+
width,
|
|
8
|
+
height,
|
|
9
|
+
linkTarget,
|
|
10
|
+
linkSource,
|
|
11
|
+
config = {
|
|
12
|
+
nodeSizeFactor: 2, // Множник для розміру вузла
|
|
13
|
+
fontSize: 14, // Базовий розмір шрифту
|
|
14
|
+
nodeSizeBase: 5, // Базовий розмір вузла (перед застосуванням множника)
|
|
15
|
+
textPaddingFactor: 0.95, // Скільки разів текст може бути ширший за розмір вузла
|
|
16
|
+
},
|
|
17
|
+
onNodeClick,
|
|
18
|
+
onLinkClick,
|
|
19
|
+
}: Graph2DProps) => {
|
|
20
|
+
// Максимальний рівень зуму
|
|
21
|
+
const MAX_ZOOM = 4;
|
|
22
|
+
// Максимальний розмір шрифту при максимальному зумі
|
|
23
|
+
const MAX_FONT_SIZE = 8;
|
|
24
|
+
|
|
25
|
+
// Функція для реверсивного масштабування тексту
|
|
26
|
+
// При максимальному зумі текст має розмір MAX_FONT_SIZE
|
|
27
|
+
// При зменшенні зуму текст також зменшується
|
|
28
|
+
const calculateFontSize = (scale: number): number => {
|
|
29
|
+
// Обмежуємо масштаб до MAX_ZOOM
|
|
30
|
+
const limitedScale = Math.min(scale, MAX_ZOOM);
|
|
31
|
+
|
|
32
|
+
// Обчислюємо коефіцієнт масштабування: при максимальному зумі = 1, при мінімальному - менше
|
|
33
|
+
const fontSizeRatio = limitedScale / MAX_ZOOM;
|
|
34
|
+
|
|
35
|
+
// Розраховуємо розмір шрифту в діапазоні від (MAX_FONT_SIZE / MAX_ZOOM) до MAX_FONT_SIZE
|
|
36
|
+
return Math.max(MAX_FONT_SIZE * fontSizeRatio, MAX_FONT_SIZE / MAX_ZOOM);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fgRef = useRef<ForceGraphMethods>(null) as MutableRefObject<ForceGraphMethods | undefined>;
|
|
40
|
+
|
|
41
|
+
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;
|
|
46
|
+
});
|
|
47
|
+
}, [graphData]);
|
|
48
|
+
|
|
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';
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
// Малюємо коло
|
|
132
|
+
ctx.beginPath();
|
|
133
|
+
ctx.arc(x, y, nodeSize, 0, 2 * Math.PI);
|
|
134
|
+
ctx.fillStyle = color;
|
|
135
|
+
ctx.fill();
|
|
136
|
+
|
|
137
|
+
// Зберігаємо стан контексту перед поворотом
|
|
138
|
+
ctx.save();
|
|
139
|
+
// Переміщуємось до позиції вузла
|
|
140
|
+
ctx.translate(x, y);
|
|
141
|
+
|
|
142
|
+
// Функція для обрізання тексту з трикрапкою (аналог text-overflow: ellipsis)
|
|
143
|
+
const truncateText = (text: string, maxWidth: number): string => {
|
|
144
|
+
if (!text) return '';
|
|
145
|
+
|
|
146
|
+
// Вимірюємо ширину тексту
|
|
147
|
+
const textWidth = ctx.measureText(text).width;
|
|
148
|
+
|
|
149
|
+
// Якщо текст коротший за максимальну ширину, повертаємо як є
|
|
150
|
+
if (textWidth <= maxWidth) return text;
|
|
151
|
+
|
|
152
|
+
// Інакше обрізаємо текст і додаємо трикрапку
|
|
153
|
+
let truncated = text;
|
|
154
|
+
const ellipsis = '...';
|
|
155
|
+
|
|
156
|
+
// Поступово скорочуємо текст, поки він не поміститься
|
|
157
|
+
while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
|
|
158
|
+
truncated = truncated.slice(0, -1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return truncated + ellipsis;
|
|
162
|
+
};
|
|
163
|
+
|
|
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
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
};
|