Skip to content

Commit ed73e90

Browse files
committed
feat(charts): sankey
1 parent 19a20c9 commit ed73e90

File tree

18 files changed

+982
-4
lines changed

18 files changed

+982
-4
lines changed

packages/react-charts/package.json

Lines changed: 4 additions & 0 deletions
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.8.1"
4447
},
4548
"peerDependencies": {
49+
"echarts": "^5.5.1",
4650
"react": "^17 || ^18",
4751
"react-dom": "^17 || ^18",
4852
"victory-area": "^37.3.2",
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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
import { EChartsInitOpts } from 'echarts/types/dist/echarts';
40+
41+
/**
42+
* Sankey diagram is a specific type of streamgraph (can also be seen as a directed acyclic graph) in which the width
43+
* of each branch is shown proportionally to the flow quantity. These graphs are typically used to visualize energy or
44+
* material or cost transfers between processes. They can also visualize the energy accounts, material flow accounts
45+
* on a regional or national level, and also the breakdown of cost of item or services.
46+
*/
47+
export interface SankeyProps {
48+
/**
49+
* The className prop specifies a class name that will be applied to outermost element
50+
*/
51+
className?: string;
52+
/**
53+
* Specify height explicitly, in pixels
54+
*/
55+
height?: number;
56+
/**
57+
* The id prop specifies an ID that will be applied to outermost element.
58+
*/
59+
id?: string;
60+
/**
61+
* This creates a Mutation Observer to watch the given DOM selector.
62+
*
63+
* When the pf-v6-theme-dark selector is added or removed, this component will be notified to update its computed
64+
* theme styles. However, if the dark theme is not updated dynamically (e.g., via a toggle), there is no need to add
65+
* this Mutation Observer.
66+
*
67+
* Note: Don't provide ".pf-v6-theme-dark" as the node selector as it won't exist in the page for light theme.
68+
* The underlying querySelectorAll() function needs to find the element the dark theme selector will be added to.
69+
*
70+
* See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Locating_DOM_elements_using_selectors
71+
*
72+
* @propType string
73+
* @example <Sankey nodeSelector="html" />
74+
* @example <Sankey nodeSelector="#main" />
75+
* @example <Sankey nodeSelector=".chr-scope__default-layout" />
76+
*/
77+
nodeSelector?: string;
78+
/**
79+
* Optional chart configuration
80+
*
81+
* See https://echarts.apache.org/en/api.html#echarts.init
82+
*/
83+
opts?: EChartsInitOpts;
84+
/**
85+
* Series component properties
86+
*
87+
* See https://echarts.apache.org/en/option.html#series-sankey
88+
*/
89+
series: any[];
90+
/**
91+
* The theme prop specifies a theme to use for determining styles and layout properties for a component. Any styles or
92+
* props defined in theme may be overwritten by props specified on the component instance.
93+
*
94+
* See https://echarts.apache.org/handbook/en/concepts/style/#theme
95+
*/
96+
theme?: any; // Todo: Add theme type
97+
/**
98+
* Title component properties
99+
*
100+
* See https://echarts.apache.org/en/option.html#title
101+
*/
102+
title?: any;
103+
/**
104+
* Tooltip component properties
105+
*
106+
* See https://echarts.apache.org/en/option.html#tooltip
107+
*/
108+
tooltip?: any;
109+
/**
110+
* This is the destination label shown in the tooltip
111+
*/
112+
tooltipDestinationLabel?: string;
113+
/**
114+
* The source label shown in the tooltip
115+
*/
116+
tooltipSourceLabel?: string;
117+
/**
118+
* Specify width explicitly, in pixels
119+
*/
120+
width?: number;
121+
}
122+
123+
export const Sankey: React.FunctionComponent<SankeyProps> = ({
124+
className,
125+
height,
126+
id,
127+
nodeSelector,
128+
opts,
129+
series,
130+
131+
theme,
132+
title,
133+
tooltip = {
134+
valueFormatter: (value: number | string) => value
135+
},
136+
tooltipDestinationLabel = 'Destination',
137+
tooltipSourceLabel = 'Source',
138+
width
139+
}: SankeyProps) => {
140+
const containerRef = useRef<HTMLDivElement>();
141+
const echart = useRef<echarts.ECharts>();
142+
const [chartTheme, setChartTheme] = useState(theme || getTheme());
143+
144+
const getItemColor = useCallback(
145+
(params: any) => {
146+
const serie = series[params.seriesIndex];
147+
const sourceData = serie?.data.find((datum: any) => datum.name === params.data?.source);
148+
const targetData = serie?.data.find((datum: any) => datum.name === params.data?.target);
149+
const sourceColor = sourceData?.itemStyle?.color;
150+
const targetColor = targetData?.itemStyle?.color;
151+
return { sourceColor, targetColor };
152+
},
153+
[series]
154+
);
155+
156+
const getSize = () => ({
157+
...(height && { height: `${height}px` }),
158+
...(width && { width: `${width}px` })
159+
});
160+
161+
const getTooltip = useCallback(() => {
162+
const symbolSize = '10px';
163+
const defaults = {
164+
backgroundColor: getComputedStyle(chart_voronoi_flyout_stroke_Fill),
165+
confine: true,
166+
formatter: (params: any) => {
167+
const result = `
168+
<div style="display: inline-block; background-color: ${params.color}; height: ${symbolSize}; width: ${symbolSize};"></div>
169+
${params.name} ${params.value}
170+
`;
171+
if (params.data.source && params.data.target) {
172+
const { sourceColor, targetColor } = getItemColor(params);
173+
return `
174+
<p>${tooltipSourceLabel}</p>
175+
<div style="display: inline-block; background-color: ${sourceColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
176+
${params.data.source}
177+
<p style="padding-top: 10px;">${tooltipDestinationLabel}</p>
178+
<p style="text-align:left;">
179+
<div style="display: inline-block; background-color: ${targetColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
180+
${params.data.target}
181+
<strong style="float:right;">
182+
${tooltip.valueFormatter(params.value, params.dataIndex)}
183+
</strong>
184+
</p>
185+
`;
186+
}
187+
return result.replace(/\s\s+/g, ' ');
188+
},
189+
textStyle: {
190+
color: getComputedStyle(chart_voronoi_labels_Fill)
191+
},
192+
trigger: 'item',
193+
triggerOn: 'mousemove'
194+
};
195+
return defaultsDeep(tooltip, defaults);
196+
}, [getItemColor, tooltipDestinationLabel, tooltipSourceLabel, tooltip]);
197+
198+
React.useEffect(() => {
199+
echarts.registerTheme('pf-sankey', chartTheme);
200+
echart.current = echarts.init(containerRef.current, 'pf-v5-sankey', defaultsDeep(opts, { renderer: 'svg' }));
201+
202+
const newSeries = series.map((serie: any) => {
203+
const defaults = {
204+
data: serie.data.map((datum: any, index: number) => ({
205+
itemStyle: {
206+
color: chartTheme?.color[index % chartTheme?.color.length]
207+
}
208+
})),
209+
emphasis: {
210+
focus: 'adjacency'
211+
},
212+
layout: 'none',
213+
lineStyle: {
214+
color: 'source',
215+
opacity: 0.6
216+
},
217+
type: 'sankey'
218+
};
219+
return defaultsDeep(serie, defaults);
220+
});
221+
222+
echart.current?.setOption({
223+
series: newSeries,
224+
title,
225+
tooltip: getTooltip()
226+
});
227+
228+
return () => {
229+
echart.current?.dispose();
230+
};
231+
}, [chartTheme, containerRef, getTooltip, opts, series, title, tooltip]);
232+
233+
// Resize observer
234+
React.useEffect(() => {
235+
echart.current?.resize();
236+
}, [height, width]);
237+
238+
// Dark theme observer
239+
React.useEffect(() => {
240+
let observer = () => {};
241+
observer = getMutationObserver(nodeSelector, () => {
242+
setChartTheme(getTheme());
243+
});
244+
return () => {
245+
observer();
246+
};
247+
}, [nodeSelector]);
248+
249+
return <div className={getClassName(className)} id={id} ref={containerRef} style={getSize()} />;
250+
};
251+
Sankey.displayName = 'Sankey';

0 commit comments

Comments
 (0)