@centreon/ui 24.4.1-MON-34377-implement-tree-chart-on-centreon-ui.2 → 24.4.1-MON-34377-implement-tree-chart-on-centreon-ui.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "24.4.1-MON-34377-implement-tree-chart-on-centreon-ui.2",
3
+ "version": "24.4.1-MON-34377-implement-tree-chart-on-centreon-ui.3",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -117,10 +117,11 @@
117
117
  "@visx/clip-path": "^3.3.0",
118
118
  "@visx/curve": "^2.1.0",
119
119
  "@visx/group": "^3.3.0",
120
+ "@visx/hierarchy": "^3.3.0",
120
121
  "@visx/legend": "^3.5.0",
121
122
  "@visx/pattern": "^3.3.0",
122
123
  "@visx/scale": "^3.5.0",
123
- "@visx/shape": "^2.12.2",
124
+ "@visx/shape": "^2.18.0",
124
125
  "@visx/text": "^3.3.0",
125
126
  "@visx/threshold": "^2.12.2",
126
127
  "@visx/visx": "2.16.0",
@@ -0,0 +1,51 @@
1
+ import { HierarchyPointNode } from '@visx/hierarchy/lib/types';
2
+ import { Group } from '@visx/group';
3
+ import { pluck } from 'ramda';
4
+
5
+ import { BaseProp, Node, TreeProps } from './models';
6
+
7
+ interface Props<TData> extends Pick<TreeProps<TData>, 'children'> {
8
+ descendants: Array<HierarchyPointNode<Node<TData>>>;
9
+ expandCollapseNode: (targetNode: Node<TData>) => void;
10
+ getExpanded: (d: Node<TData>) => Array<Node<TData>> | undefined;
11
+ nodeSize: {
12
+ height: number;
13
+ width: number;
14
+ };
15
+ }
16
+
17
+ const DescendantNodes = <TData extends BaseProp>({
18
+ descendants,
19
+ children,
20
+ expandCollapseNode,
21
+ getExpanded,
22
+ nodeSize
23
+ }: Props<TData>): Array<JSX.Element> => {
24
+ return descendants.map((node) => {
25
+ const top = node.x;
26
+ const left = node.y;
27
+ const ancestorIds = node
28
+ .ancestors()
29
+ .map((ancestor) => ancestor.data.data.id);
30
+ const descendantIds = node
31
+ .descendants()
32
+ .map((ancestor) => ancestor.data.data.id);
33
+
34
+ const key = `${node.data.data.id}-${node.data.data.name}-${ancestorIds.toString()}-${descendantIds.toString()}`;
35
+
36
+ return (
37
+ <Group key={key} left={left} top={top}>
38
+ {children({
39
+ ancestors: pluck('data', node.ancestors()),
40
+ depth: node.depth,
41
+ expandCollapseNode,
42
+ isExpanded: !!getExpanded(node.data),
43
+ node: node.data,
44
+ nodeSize
45
+ })}
46
+ </Group>
47
+ );
48
+ });
49
+ };
50
+
51
+ export default DescendantNodes;
@@ -0,0 +1,64 @@
1
+ import { LinkHorizontal } from '@visx/shape';
2
+ import { HierarchyPointLink } from '@visx/hierarchy/lib/types';
3
+
4
+ import { useTheme } from '@mui/material';
5
+
6
+ import { TreeProps, Node, BaseProp } from './models';
7
+
8
+ interface Props<TData> extends Pick<TreeProps<TData>, 'treeLink'> {
9
+ links: Array<HierarchyPointLink<Node<TData>>>;
10
+ }
11
+
12
+ const Links = <TData extends BaseProp>({
13
+ links,
14
+ treeLink
15
+ }: Props<TData>): Array<JSX.Element> => {
16
+ const theme = useTheme();
17
+
18
+ return links.map((link) => {
19
+ const ancestorIds = link.target
20
+ .ancestors()
21
+ .map((ancestor) => ancestor.data.data.id);
22
+
23
+ const descendantIds = link.target
24
+ .descendants()
25
+ .map((ancestor) => ancestor.data.data.id);
26
+
27
+ const key = `${link.source.data.data.id}-${link.source.data.data.name}-${ancestorIds}_${link.target.data.data.id}-${link.target.data.data.name}-${descendantIds}`;
28
+
29
+ return (
30
+ <LinkHorizontal
31
+ data={link}
32
+ data-testid={`${link.source.data.data.id}_to_${link.target.data.data.id}`}
33
+ fill="none"
34
+ key={key}
35
+ stroke={
36
+ treeLink.getStroke?.({
37
+ source: link.source.data.data,
38
+ target: link.target.data.data
39
+ }) || theme.palette.text.primary
40
+ }
41
+ strokeDasharray={
42
+ treeLink.getStrokeDasharray?.({
43
+ source: link.source.data.data,
44
+ target: link.target.data.data
45
+ }) || '0'
46
+ }
47
+ strokeOpacity={
48
+ treeLink.getStrokeOpacity?.({
49
+ source: link.source.data.data,
50
+ target: link.target.data.data
51
+ }) || 1
52
+ }
53
+ strokeWidth={
54
+ treeLink.getStrokeWidth?.({
55
+ source: link.source.data.data,
56
+ target: link.target.data.data
57
+ }) || '2'
58
+ }
59
+ />
60
+ );
61
+ });
62
+ };
63
+
64
+ export default Links;
@@ -0,0 +1,16 @@
1
+ import { ParentSize } from '../..';
2
+
3
+ import { BaseProp, TreeProps } from './models';
4
+ import { Tree } from './Tree';
5
+
6
+ export const StandaloneTree = <TData extends BaseProp>(
7
+ props: Omit<TreeProps<TData>, 'containerHeight' | 'containerWidth'>
8
+ ): JSX.Element => (
9
+ <ParentSize>
10
+ {({ width, height }) => (
11
+ <svg height={height} width={width}>
12
+ <Tree {...props} containerHeight={height} containerWidth={width} />
13
+ </svg>
14
+ )}
15
+ </ParentSize>
16
+ );
@@ -0,0 +1,64 @@
1
+ import { Meta, StoryObj } from '@storybook/react';
2
+ import { equals } from 'ramda';
3
+
4
+ import { SimpleData, simpleData } from './stories/datas';
5
+ import { SimpleContent } from './stories/contents';
6
+
7
+ import { StandaloneTree } from '.';
8
+
9
+ const meta: Meta<typeof StandaloneTree> = {
10
+ component: StandaloneTree
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof StandaloneTree>;
15
+
16
+ const StandaloneTreeTemplate = (args): JSX.Element => (
17
+ <div style={{ height: '90vh', width: '100%' }}>
18
+ <StandaloneTree<SimpleData> {...args} />
19
+ </div>
20
+ );
21
+
22
+ export const DefaultStandaloneTree: Story = {
23
+ args: {
24
+ children: SimpleContent,
25
+ data: simpleData,
26
+ node: {
27
+ height: 90,
28
+ width: 90
29
+ }
30
+ },
31
+ render: StandaloneTreeTemplate
32
+ };
33
+
34
+ export const WithDefaultExpandFilter: Story = {
35
+ args: {
36
+ children: SimpleContent,
37
+ data: simpleData,
38
+ node: {
39
+ height: 90,
40
+ isDefaultExpanded: (data: SimpleData) => equals('critical', data.status),
41
+ width: 90
42
+ }
43
+ },
44
+ render: StandaloneTreeTemplate
45
+ };
46
+
47
+ export const WithCustomLinks: Story = {
48
+ args: {
49
+ children: SimpleContent,
50
+ data: simpleData,
51
+ node: {
52
+ height: 90,
53
+ width: 90
54
+ },
55
+ treeLink: {
56
+ getStroke: ({ target }) => (target.status === 'ok' ? 'grey' : 'black'),
57
+ getStrokeDasharray: ({ target }) =>
58
+ target.status === 'ok' ? '5,5' : '0',
59
+ getStrokeOpacity: ({ target }) => (target.status === 'ok' ? 0.8 : 1),
60
+ getStrokeWidth: ({ target }) => (target.status === 'ok' ? 1 : 2)
61
+ }
62
+ },
63
+ render: StandaloneTreeTemplate
64
+ };
@@ -0,0 +1,108 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ import { Group } from '@visx/group';
4
+ import { hierarchy, Tree as VisxTree } from '@visx/hierarchy';
5
+ import { isNil } from 'ramda';
6
+
7
+ import { useDeepCompare } from '../../utils';
8
+
9
+ import { margins, nodeMargins } from './constants';
10
+ import { BaseProp, Node, TreeProps } from './models';
11
+ import { updateNodeFromTree } from './utils';
12
+ import Links from './Links';
13
+ import DescendantNodes from './DescendantNodes';
14
+
15
+ export const Tree = <TData extends BaseProp>({
16
+ containerHeight,
17
+ containerWidth,
18
+ data,
19
+ node,
20
+ treeLink = {},
21
+ children
22
+ }: TreeProps<TData>): JSX.Element => {
23
+ const formattedData: Node<TData> = useMemo(
24
+ () => ({
25
+ ...data,
26
+ isExpanded: true
27
+ }),
28
+ useDeepCompare([data])
29
+ );
30
+
31
+ const [tree, setTree] = useState(formattedData);
32
+
33
+ const toggleTreeNodesExpanded = useCallback(
34
+ ({ currentTree, targetNode }): Node<TData> => {
35
+ return updateNodeFromTree({
36
+ callback: (subTree) => {
37
+ if (isNil(subTree.isExpanded) && isNil(node.isDefaultExpanded)) {
38
+ return {
39
+ isExpanded: false
40
+ };
41
+ }
42
+
43
+ return {
44
+ isExpanded: isNil(subTree.isExpanded)
45
+ ? !node.isDefaultExpanded?.(subTree.data)
46
+ : !subTree.isExpanded || false
47
+ };
48
+ },
49
+ targetNode,
50
+ tree: currentTree
51
+ });
52
+ },
53
+ [node.isDefaultExpanded]
54
+ );
55
+
56
+ const expandCollapseNode = useCallback((targetNode: Node<TData>): void => {
57
+ setTree((currentTree) => {
58
+ return toggleTreeNodesExpanded({ currentTree, targetNode });
59
+ });
60
+ }, []);
61
+
62
+ const getExpanded = useCallback(
63
+ (d: Node<TData>): Array<Node<TData>> | undefined | null => {
64
+ if (isNil(d.isExpanded) && isNil(node.isDefaultExpanded)) {
65
+ return d.children;
66
+ }
67
+ if (isNil(d.isExpanded)) {
68
+ return node.isDefaultExpanded?.(d.data) ? d.children : null;
69
+ }
70
+
71
+ return d.isExpanded ? d.children : null;
72
+ },
73
+ [node.isDefaultExpanded]
74
+ );
75
+
76
+ const origin = useMemo(
77
+ () => ({ x: 0, y: containerHeight / 2 }),
78
+ [containerHeight]
79
+ );
80
+
81
+ return (
82
+ <Group left={node.width / 2} top={margins.top}>
83
+ <VisxTree
84
+ nodeSize={[node.width + nodeMargins.y, node.height + nodeMargins.x]}
85
+ root={hierarchy(tree, getExpanded)}
86
+ separation={() => 1}
87
+ size={[containerWidth, containerHeight]}
88
+ >
89
+ {(subTree) => (
90
+ <Group left={origin.x} top={origin.y}>
91
+ <Links links={subTree.links()} treeLink={treeLink} />
92
+ <DescendantNodes
93
+ descendants={subTree.descendants()}
94
+ expandCollapseNode={expandCollapseNode}
95
+ getExpanded={getExpanded}
96
+ nodeSize={{
97
+ height: node.height,
98
+ width: node.width
99
+ }}
100
+ >
101
+ {children}
102
+ </DescendantNodes>
103
+ </Group>
104
+ )}
105
+ </VisxTree>
106
+ </Group>
107
+ );
108
+ };
@@ -0,0 +1,2 @@
1
+ export const margins = { bottom: 30, left: 30, right: 30, top: 30 };
2
+ export const nodeMargins = { x: 120, y: 16 };
@@ -0,0 +1,4 @@
1
+ export { Tree } from './Tree';
2
+ export { StandaloneTree } from './StandaloneTree';
3
+ export * from './utils';
4
+ export type { Node, TreeProps, ChildrenProps } from './models';
@@ -0,0 +1,47 @@
1
+ export interface Node<T> {
2
+ children?: Array<Node<T>>;
3
+ data: T;
4
+ isExpanded?: boolean;
5
+ }
6
+
7
+ export interface LinkProps<T> {
8
+ source: T;
9
+ target: T;
10
+ }
11
+
12
+ export interface BaseProp {
13
+ id: number;
14
+ name: string;
15
+ }
16
+
17
+ export interface ChildrenProps<TData> {
18
+ ancestors: Array<Node<TData>>;
19
+ depth: number;
20
+ expandCollapseNode: (targetNode: Node<TData>) => void;
21
+ isExpanded: boolean;
22
+ node: Node<TData>;
23
+ nodeSize: {
24
+ height: number;
25
+ width: number;
26
+ };
27
+ }
28
+
29
+ export interface TreeProps<TData> {
30
+ children: (props: ChildrenProps<TData>) => JSX.Element;
31
+ containerHeight: number;
32
+ containerWidth: number;
33
+ data: Node<TData>;
34
+ node: {
35
+ height: number;
36
+ isDefaultExpanded?: (data: TData) => boolean;
37
+ width: number;
38
+ };
39
+ treeLink: {
40
+ getStroke?: (props: LinkProps<TData>) => string | undefined;
41
+ getStrokeDasharray?: (
42
+ props: LinkProps<TData>
43
+ ) => string | number | undefined;
44
+ getStrokeOpacity?: (props: LinkProps<TData>) => string | number | undefined;
45
+ getStrokeWidth?: (props: LinkProps<TData>) => string | number | undefined;
46
+ };
47
+ }
@@ -0,0 +1,80 @@
1
+ import { always, cond, equals, T } from 'ramda';
2
+
3
+ import { useTheme } from '@mui/material';
4
+
5
+ import { ChildrenProps } from '..';
6
+
7
+ import { SimpleData } from './datas';
8
+
9
+ const symbol = (
10
+ <symbol id="SpaIcon" viewBox="0 0 24 24">
11
+ <path d="M8.55 12c-1.07-.71-2.25-1.27-3.53-1.61 1.28.34 2.46.9 3.53 1.61m10.43-1.61c-1.29.34-2.49.91-3.57 1.64 1.08-.73 2.28-1.3 3.57-1.64" />
12
+ <path d="M15.49 9.63c-.18-2.79-1.31-5.51-3.43-7.63-2.14 2.14-3.32 4.86-3.55 7.63 1.28.68 2.46 1.56 3.49 2.63 1.03-1.06 2.21-1.94 3.49-2.63m-6.5 2.65c-.14-.1-.3-.19-.45-.29.15.11.31.19.45.29m6.42-.25c-.13.09-.27.16-.4.26.13-.1.27-.17.4-.26M12 15.45C9.85 12.17 6.18 10 2 10c0 5.32 3.36 9.82 8.03 11.49.63.23 1.29.4 1.97.51.68-.12 1.33-.29 1.97-.51C18.64 19.82 22 15.32 22 10c-4.18 0-7.85 2.17-10 5.45" />
13
+ </symbol>
14
+ );
15
+
16
+ export const SimpleContent = ({
17
+ node,
18
+ depth,
19
+ isExpanded,
20
+ nodeSize,
21
+ expandCollapseNode
22
+ }: ChildrenProps<SimpleData>): JSX.Element => {
23
+ const theme = useTheme();
24
+ const fillColor = cond([
25
+ [equals('critical'), always(theme.palette.error.main)],
26
+ [equals('warning'), always(theme.palette.warning.main)],
27
+ [T, always(theme.palette.success.main)]
28
+ ])(node.data.status);
29
+
30
+ if (equals(depth, 0)) {
31
+ return (
32
+ <>
33
+ <circle fill={fillColor} r={nodeSize.width / 2} />
34
+ <text textAnchor="middle">{node.data.name}</text>
35
+ <text alignmentBaseline="text-before-edge" textAnchor="middle">
36
+ {isExpanded ? 'Expanded' : 'Collapsed'}
37
+ </text>
38
+ </>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <>
44
+ {symbol}
45
+ <rect
46
+ fill={fillColor}
47
+ height={nodeSize.height}
48
+ rx={10}
49
+ width={nodeSize.width}
50
+ x={-nodeSize.width / 2}
51
+ y={-nodeSize.height / 2}
52
+ />
53
+ <text textAnchor="middle">{node.data.name}</text>
54
+ {node.children ? (
55
+ <text textAnchor="middle" y={nodeSize.height / 2.5}>
56
+ {isExpanded ? 'Expanded' : 'Collapsed'}
57
+ </text>
58
+ ) : (
59
+ <use
60
+ fill="black"
61
+ height="24"
62
+ href="#SpaIcon"
63
+ width="24"
64
+ x={nodeSize.width / 5}
65
+ y={-nodeSize.height / 2.2}
66
+ />
67
+ )}
68
+ <rect
69
+ fill="transparent"
70
+ height={nodeSize.height}
71
+ rx={10}
72
+ style={{ cursor: node.children ? 'pointer' : 'default' }}
73
+ width={nodeSize.width}
74
+ x={-nodeSize.width / 2}
75
+ y={-nodeSize.height / 2}
76
+ onClick={() => expandCollapseNode(node)}
77
+ />
78
+ </>
79
+ );
80
+ };
@@ -0,0 +1,60 @@
1
+ import { Node } from '../models';
2
+
3
+ export interface SimpleData {
4
+ id: number;
5
+ name: string;
6
+ status: 'critical' | 'warning' | 'ok';
7
+ }
8
+
9
+ export const simpleData: Node<SimpleData> = {
10
+ children: [
11
+ {
12
+ children: [
13
+ { data: { id: 2, name: 'A1', status: 'ok' } },
14
+ { data: { id: 3, name: 'A2', status: 'ok' } },
15
+ { data: { id: 4, name: 'A3', status: 'critical' } },
16
+ {
17
+ children: [
18
+ {
19
+ data: { id: 10, name: 'C1', status: 'warning' }
20
+ },
21
+ {
22
+ children: [
23
+ {
24
+ data: { id: 11, name: 'D1', status: 'ok' }
25
+ },
26
+ {
27
+ data: { id: 12, name: 'D2', status: 'ok' }
28
+ },
29
+ {
30
+ data: { id: 13, name: 'D3', status: 'ok' }
31
+ }
32
+ ],
33
+ data: { id: 14, name: 'D', status: 'ok' }
34
+ },
35
+ {
36
+ children: [
37
+ {
38
+ data: { id: 21, name: 'E1', status: 'critical' }
39
+ }
40
+ ],
41
+ data: { id: 20, name: 'E', status: 'critical' }
42
+ }
43
+ ],
44
+ data: { id: 3, name: 'C', status: 'critical' }
45
+ }
46
+ ],
47
+ data: { id: 6273286320, name: 'A', status: 'critical' }
48
+ },
49
+ { data: { id: 1, name: 'Z', status: 'ok' } },
50
+ {
51
+ children: [{ data: { id: 50, name: 'B1', status: 'warning' } }],
52
+ data: { id: 2, name: 'B', status: 'warning' }
53
+ }
54
+ ],
55
+ data: {
56
+ id: 0,
57
+ name: 'T',
58
+ status: 'critical'
59
+ }
60
+ };
@@ -0,0 +1,33 @@
1
+ import { equals } from 'ramda';
2
+
3
+ import { BaseProp, Node } from './models';
4
+
5
+ interface UpdateNodeFromTreeProps<TData> {
6
+ callback: (tree: Node<TData>) => Partial<Node<TData>>;
7
+ targetNode: Node<TData>;
8
+ tree: Node<TData>;
9
+ }
10
+
11
+ export const updateNodeFromTree = <TData extends BaseProp>({
12
+ tree,
13
+ targetNode,
14
+ callback
15
+ }: UpdateNodeFromTreeProps<TData>): Node<TData> => {
16
+ if (!tree.children) {
17
+ return tree;
18
+ }
19
+
20
+ if (equals(tree.data, targetNode.data)) {
21
+ return {
22
+ ...tree,
23
+ ...callback(tree)
24
+ };
25
+ }
26
+
27
+ return {
28
+ ...tree,
29
+ children: tree.children?.map((child) =>
30
+ updateNodeFromTree({ callback, targetNode, tree: child })
31
+ )
32
+ };
33
+ };
@@ -6,3 +6,4 @@ export { Text as GraphText } from './Text';
6
6
  export { HeatMap } from './HeatMap';
7
7
  export { BarStack } from './BarStack';
8
8
  export { PieChart } from './PieChart';
9
+ export * from './Tree';