Skip to content

Commit b998f8d

Browse files
committed
feat(charts): sankey
1 parent 0d5bc49 commit b998f8d

File tree

18 files changed

+947
-10
lines changed

18 files changed

+947
-10
lines changed

packages/react-charts/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"types": "dist/esm/index.d.ts",
88
"typesVersions": {
99
"*": {
10+
"echarts": [
11+
"dist/esm/echarts/index.d.ts"
12+
],
1013
"victory": [
1114
"dist/esm/victory/index.d.ts"
1215
]
@@ -43,6 +46,7 @@
4346
"tslib": "^2.7.0"
4447
},
4548
"peerDependencies": {
49+
"echarts": "^5.5.1",
4650
"react": "^17 || ^18",
4751
"react-dom": "^17 || ^18",
4852
"victory-area": "^37.1.1",
@@ -69,7 +73,7 @@
6973
"subpaths": "node ../../scripts/exportSubpaths.mjs --config subpaths.config.json"
7074
},
7175
"devDependencies": {
72-
"@types/lodash": "^4.17.9",
76+
"@types/lodash": "^4.17.7",
7377
"fs-extra": "^11.2.0"
7478
}
7579
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"packageName": "@patternfly/react-charts",
3-
"exclude": ["dist/esm/deprecated/index.js", "dist/esm/next/index.js"]
3+
"exclude": ["dist/esm/deprecated/index.js"]
44
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as React from 'react';
2+
// import * as echarts from 'echarts';
3+
import { render } from '@testing-library/react';
4+
import { Sankey } from './Sankey';
5+
6+
const data = [
7+
{
8+
name: 'a'
9+
},
10+
{
11+
name: 'b'
12+
},
13+
{
14+
name: 'a1'
15+
},
16+
{
17+
name: 'a2'
18+
},
19+
{
20+
name: 'b1'
21+
},
22+
{
23+
name: 'c'
24+
}
25+
];
26+
27+
const links = [
28+
{
29+
source: 'a',
30+
target: 'a1',
31+
value: 5
32+
},
33+
{
34+
source: 'a',
35+
target: 'a2',
36+
value: 3
37+
},
38+
{
39+
source: 'b',
40+
target: 'b1',
41+
value: 8
42+
},
43+
{
44+
source: 'a',
45+
target: 'b1',
46+
value: 3
47+
},
48+
{
49+
source: 'b1',
50+
target: 'a1',
51+
value: 1
52+
},
53+
{
54+
source: 'b1',
55+
target: 'c',
56+
value: 2
57+
}
58+
];
59+
60+
let spy: any;
61+
62+
// beforeAll(() => {
63+
// console.log(`*** TEST 1`);
64+
// spy = jest.spyOn(echarts, 'getInstanceByDom').mockImplementation(
65+
// () =>
66+
// ({
67+
// hideLoading: jest.fn(),
68+
// setOption: jest.fn(),
69+
// showLoading: jest.fn()
70+
// }) as any
71+
// );
72+
// });
73+
//
74+
// afterAll(() => {
75+
// console.log(`*** TEST 2`);
76+
// spy.mockRestore();
77+
// });
78+
79+
// See https://stackoverflow.com/questions/54921743/testing-echarts-react-component-with-jest-echartelement-is-null
80+
xtest('renders component data', () => {
81+
const { asFragment } = render(<Sankey opts={{ renderer: 'svg' }} series={[{ data, links }]} />);
82+
expect(asFragment()).toMatchSnapshot();
83+
});
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/* eslint-disable camelcase */
2+
import chart_voronoi_flyout_stroke_Fill from '@patternfly/react-tokens/dist/esm/chart_voronoi_flyout_stroke_Fill';
3+
import chart_voronoi_labels_Fill from '@patternfly/react-tokens/dist/esm/chart_voronoi_labels_Fill';
4+
5+
import * as React from 'react';
6+
import * as echarts from 'echarts';
7+
import { useCallback, useRef, useState } from 'react';
8+
import defaultsDeep from 'lodash/defaultsDeep';
9+
import { getMutationObserver } from '../utils/observe';
10+
import { getComputedStyle } from '../utils/theme';
11+
12+
// import { BarChart, SankeyChart } from 'echarts/charts';
13+
// import { CanvasRenderer } from 'echarts/renderers';
14+
15+
// import {
16+
// TitleComponent,
17+
// TooltipComponent,
18+
// GridComponent,
19+
// DatasetComponent,
20+
// TransformComponent
21+
// } from 'echarts/components';
22+
23+
// Register the required components
24+
// echarts.use([
25+
// BarChart,
26+
// SankeyChart,
27+
// TitleComponent,
28+
// TooltipComponent,
29+
// GridComponent,
30+
// DatasetComponent,
31+
// TransformComponent,
32+
// LabelLayout,
33+
// UniversalTransition,
34+
// CanvasRenderer
35+
// ]);
36+
37+
import { getTheme } from './theme';
38+
import { getClassName } from '../utils/misc';
39+
40+
/**
41+
*/
42+
export interface SankeyProps {
43+
className?: string;
44+
destinationLabel?: string;
45+
height?: number;
46+
id?: string;
47+
legend?: {
48+
symbolSize?: number; // Todo: move into tooltip?
49+
};
50+
lineStyle?: any;
51+
52+
/**
53+
* This creates a Mutation Observer to watch the given DOM selector.
54+
*
55+
* When the pf-v6-theme-dark selector is added or removed, this component will be notified to update its computed
56+
* theme styles. However, if the dark theme is not updated dynamically (e.g., via a toggle), there is no need to add
57+
* this Mutation Observer.
58+
*
59+
* Note: Don't provide ".pf-v6-theme-dark" as the node selector as it won't exist in the page for light theme.
60+
* The underlying querySelectorAll() function needs to find the element the dark theme selector will be added to.
61+
*
62+
* See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Locating_DOM_elements_using_selectors
63+
*
64+
* @propType string
65+
* @example <Sankey nodeSelector="html" />
66+
* @example <Sankey nodeSelector="#main" />
67+
* @example <Sankey nodeSelector=".chr-scope__default-layout" />
68+
*/
69+
nodeSelector?: string;
70+
opts?: any;
71+
series: any[];
72+
sourceLabel?: string;
73+
theme?: any;
74+
title?: any;
75+
tooltip?: any;
76+
width?: number;
77+
}
78+
79+
export const Sankey: React.FunctionComponent<SankeyProps> = ({
80+
className,
81+
destinationLabel = 'Destination',
82+
height,
83+
id,
84+
legend = {
85+
symbolSize: 10
86+
},
87+
lineStyle = {
88+
color: 'source',
89+
opacity: 0.6
90+
},
91+
nodeSelector,
92+
opts,
93+
series,
94+
sourceLabel = 'Source',
95+
theme,
96+
title,
97+
tooltip = {
98+
valueFormatter: (value: number | string) => value
99+
},
100+
width
101+
}: SankeyProps) => {
102+
const containerRef = useRef<HTMLDivElement>();
103+
const echart = useRef<echarts.ECharts>();
104+
const [chartTheme, setChartTheme] = useState(theme || getTheme());
105+
106+
const getItemColor = useCallback(
107+
(params: any) => {
108+
const serie = series[params.seriesIndex];
109+
const sourceData = serie?.data.find((datum: any) => datum.name === params.data?.source);
110+
const targetData = serie?.data.find((datum: any) => datum.name === params.data?.target);
111+
const sourceColor = sourceData?.itemStyle?.color;
112+
const targetColor = targetData?.itemStyle?.color;
113+
return { sourceColor, targetColor };
114+
},
115+
[series]
116+
);
117+
118+
const getTooltip = useCallback(() => {
119+
const symbolSize = `${legend.symbolSize}px`;
120+
const defaults = {
121+
backgroundColor: getComputedStyle(chart_voronoi_flyout_stroke_Fill),
122+
confine: true,
123+
formatter: (params: any) => {
124+
const result = `
125+
<div style="display: inline-block; background-color: ${params.color}; height: ${symbolSize}; width: ${symbolSize};"></div>
126+
${params.name} ${params.value}
127+
`;
128+
if (params.data.source && params.data.target) {
129+
const { sourceColor, targetColor } = getItemColor(params);
130+
return `
131+
<p>${sourceLabel}</p>
132+
<div style="display: inline-block; background-color: ${sourceColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
133+
${params.data.source}
134+
<p style="padding-top: 10px;">${destinationLabel}</p>
135+
<p style="text-align:left;">
136+
<div style="display: inline-block; background-color: ${targetColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
137+
${params.data.target}
138+
<strong style="float:right;">
139+
${tooltip.valueFormatter(params.value, params.dataIndex)}
140+
</strong>
141+
</p>
142+
`;
143+
}
144+
return result.replace(/\s\s+/g, ' ');
145+
},
146+
textStyle: {
147+
color: getComputedStyle(chart_voronoi_labels_Fill)
148+
},
149+
trigger: 'item',
150+
triggerOn: 'mousemove'
151+
};
152+
return defaultsDeep(tooltip, defaults);
153+
}, [destinationLabel, getItemColor, legend.symbolSize, sourceLabel, tooltip]);
154+
155+
React.useEffect(() => {
156+
echarts.registerTheme('pf-v5-sankey', chartTheme);
157+
echart.current = echarts.init(containerRef.current, 'pf-v5-sankey', { renderer: 'svg' }); // renderer: 'svg'
158+
159+
const newSeries = series.map((serie: any) => {
160+
const defaults = {
161+
data: serie.data.map((datum: any, index: number) => ({
162+
itemStyle: {
163+
color: chartTheme?.color[index % chartTheme?.color.length]
164+
}
165+
})),
166+
emphasis: {
167+
focus: 'adjacency'
168+
},
169+
layout: 'none',
170+
lineStyle,
171+
type: 'sankey'
172+
};
173+
return defaultsDeep(serie, defaults);
174+
});
175+
176+
echart.current?.setOption({
177+
series: newSeries,
178+
title,
179+
tooltip: getTooltip()
180+
});
181+
182+
return () => {
183+
echart.current?.dispose();
184+
};
185+
}, [chartTheme, containerRef, getTooltip, lineStyle, opts, series, title, tooltip]);
186+
187+
// Resize observer
188+
React.useEffect(() => {
189+
echart.current?.resize();
190+
}, [height, width]);
191+
192+
// Dark theme observer
193+
React.useEffect(() => {
194+
let observer = () => {};
195+
observer = getMutationObserver(nodeSelector, () => {
196+
setChartTheme(getTheme());
197+
});
198+
return () => {
199+
observer();
200+
};
201+
}, [nodeSelector]);
202+
203+
const getSize = () => ({
204+
...(height && { height: `${height}px` }),
205+
...(width && { width: `${width}px` })
206+
});
207+
208+
return <div className={getClassName(className)} id={id} ref={containerRef} style={getSize()} />;
209+
};
210+
Sankey.displayName = 'Sankey';

0 commit comments

Comments
 (0)