Skip to content

Commit 08a6e95

Browse files
committed
feat(charts): add sankey chart
1 parent 882686a commit 08a6e95

File tree

59 files changed

+2710
-28
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+2710
-28
lines changed

jest.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ const config: Config = {
1919
'^.+\\.m?[jt]sx?$': 'babel-jest',
2020
'^.+\\.svg$': 'jest-transform-stub'
2121
},
22-
setupFilesAfterEnv: ['<rootDir>/packages/testSetup.ts'],
22+
setupFilesAfterEnv: ['<rootDir>/packages/testSetup.ts', 'jest-canvas-mock'],
2323
testPathIgnorePatterns: ['<rootDir>/packages/react-integration/'],
24-
transformIgnorePatterns: ['node_modules/victory-*/', '/node_modules/(?!(case-anything)/)'],
24+
transformIgnorePatterns: ['/node_modules/victory-*/', '/node_modules/(?!(chart\\.js|echarts|zrender)).*\\.js$'],
2525
coveragePathIgnorePatterns: ['/dist/'],
2626
moduleNameMapper: {
2727
'\\.(css|less)$': '<rootDir>/packages/react-styles/__mocks__/styleMock.js'

packages/react-charts/package.json

Lines changed: 9 additions & 4 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
]
@@ -42,7 +45,13 @@
4245
"lodash": "^4.17.21",
4346
"tslib": "^2.8.1"
4447
},
48+
"devDependencies": {
49+
"@types/lodash": "^4.17.15",
50+
"fs-extra": "^11.3.0",
51+
"jest-canvas-mock": "^2.5.2"
52+
},
4553
"peerDependencies": {
54+
"echarts": "^5.6.0",
4655
"react": "^17 || ^18",
4756
"react-dom": "^17 || ^18",
4857
"victory-area": "^37.3.6",
@@ -120,9 +129,5 @@
120129
"clean": "rimraf dist echarts victory",
121130
"build:single:packages": "node ../../scripts/build-single-packages.mjs --config single-packages.config.json",
122131
"subpaths": "node ../../scripts/exportSubpaths.mjs --config subpaths.config.json"
123-
},
124-
"devDependencies": {
125-
"@types/lodash": "^4.17.15",
126-
"fs-extra": "^11.3.0"
127132
}
128133
}
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const echarts: any = jest.createMockFromModule('echarts');
2+
echarts.init = jest.fn(() => ({
3+
setOption: jest.fn(),
4+
dispose: jest.fn()
5+
}));
6+
module.exports = echarts;
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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 { useCallback, useReducer, useRef } from 'react';
7+
import cloneDeep from 'lodash/cloneDeep';
8+
import defaultsDeep from 'lodash/defaultsDeep';
9+
import { getMutationObserver } from '../utils/observe';
10+
import { getComputedStyleValue } from '../utils/styles';
11+
12+
import * as echarts from 'echarts/core';
13+
import { EChartsOption } from 'echarts/types/dist/option';
14+
import { SankeyChart } from 'echarts/charts';
15+
import { TitleComponent, TooltipComponent } from 'echarts/components';
16+
import { SVGRenderer } from 'echarts/renderers';
17+
18+
// Register minimal required components
19+
echarts.use([SankeyChart, SVGRenderer, TitleComponent, TooltipComponent]);
20+
21+
import { EChartsInitOpts } from 'echarts/types/dist/echarts';
22+
import { ThemeDefinition } from '../themes/Theme';
23+
import { getClassName } from '../utils/styles';
24+
import { getComputedTheme, getTheme } from '../utils/theme';
25+
import { ThemeColor } from '../themes/ThemeColor';
26+
27+
/**
28+
* The Sankey diagram is a specific type of streamgraph (can also be seen as a directed acyclic graph) in which the
29+
* width of each branch is shown proportionally to the flow quantity. These graphs are typically used to visualize
30+
* energy or material or cost transfers between processes. They can also visualize the energy accounts, material flow
31+
* accounts on a regional or national level, and also the breakdown of cost of item or services.
32+
*
33+
* Note: Only the minimum requirements for Samkey are imported from echarts. This includes components to support
34+
* the series, title, and tooltip properties. To include a toolbox; for example, you must include the component
35+
* (in addition to toolbox props) like so:
36+
*
37+
* import * as echarts from 'echarts/core';
38+
* import { ToolboxComponent } from 'echarts/components';
39+
* echarts.use([ToolboxComponent]);
40+
*
41+
* @beta
42+
*/
43+
export interface SankeyProps {
44+
/**
45+
* The className prop specifies a class name that will be applied to outermost element
46+
*/
47+
className?: string;
48+
/**
49+
* Specify height explicitly, in pixels
50+
*/
51+
height?: number;
52+
/**
53+
* The id prop specifies an ID that will be applied to outermost element.
54+
*/
55+
id?: string;
56+
/**
57+
* Flag indicating the theme should use computed property values (default). For Sankey, using CSS variables can cause
58+
* flashing (e.g., on mouse hover), but computed values eliminate flashing.
59+
*
60+
* Note: This is experimental (for testing purposes) and may not apply to all properties nor custom themes
61+
*
62+
* @private
63+
* @hide
64+
*/
65+
isComputedTheme?: boolean;
66+
/**
67+
* Flag indicating to use the legend tooltip (default)
68+
*/
69+
isLegendTooltip?: boolean;
70+
/**
71+
* This creates a Mutation Observer to watch the given DOM selector.
72+
*
73+
* When the pf-v6-theme-dark selector is added or removed, this component will be notified to update its computed
74+
* theme styles. However, if the dark theme is not updated dynamically (e.g., via a toggle), there is no need to add
75+
* this Mutation Observer.
76+
*
77+
* Note: Don't provide ".pf-v6-theme-dark" as the node selector as it won't exist in the page for light theme.
78+
* The underlying querySelectorAll() function needs to find the element the dark theme selector will be added to.
79+
*
80+
* See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Locating_DOM_elements_using_selectors
81+
*
82+
* @propType string
83+
* @example <Sankey nodeSelector="html" />
84+
* @example <Sankey nodeSelector="#main" />
85+
* @example <Sankey nodeSelector=".chr-scope__default-layout" />
86+
*/
87+
nodeSelector?: string;
88+
/**
89+
* ECharts uses this object to configure its properties; for example, series, title, and tooltip
90+
*
91+
* See https://echarts.apache.org/en/option.html
92+
*/
93+
option?: EChartsOption;
94+
/**
95+
* Optional chart configuration
96+
*
97+
* See https://echarts.apache.org/en/api.html#echarts.init
98+
*/
99+
opts?: EChartsInitOpts;
100+
/**
101+
* The theme prop specifies a theme to use for determining styles and layout properties for a component. Any styles or
102+
* props defined in theme may be overwritten by props specified on the component instance.
103+
*
104+
* See https://echarts.apache.org/handbook/en/concepts/style/#theme
105+
*/
106+
theme?: ThemeDefinition;
107+
/**
108+
* Specifies the theme color. Valid values are 'blue', 'green', 'multi', etc.
109+
*
110+
* Note: Not compatible with theme prop
111+
*
112+
* @example themeColor={ChartThemeColor.blue}
113+
*/
114+
themeColor?: string;
115+
/**
116+
* The destination label shown in the tooltip
117+
*/
118+
tooltipDestinationLabel?: string;
119+
/**
120+
* The source label shown in the tooltip
121+
*/
122+
tooltipSourceLabel?: string;
123+
/**
124+
* Specify width explicitly, in pixels
125+
*/
126+
width?: number;
127+
}
128+
129+
export const Sankey: React.FunctionComponent<SankeyProps> = ({
130+
className,
131+
height,
132+
id,
133+
isComputedTheme = true,
134+
isLegendTooltip = true,
135+
nodeSelector,
136+
option,
137+
opts,
138+
theme,
139+
themeColor,
140+
tooltipDestinationLabel = 'Destination',
141+
tooltipSourceLabel = 'Source',
142+
width,
143+
...rest
144+
}: SankeyProps) => {
145+
const containerRef = useRef<HTMLDivElement>();
146+
const echart = useRef<echarts.ECharts>();
147+
const [update, forceUpdate] = useReducer((x) => x + 1, 0);
148+
149+
const series: any = cloneDeep(option?.series);
150+
const tooltip: any = cloneDeep(option?.tooltip);
151+
152+
const getItemColor = useCallback(
153+
(params: any) => {
154+
const serie = series[params.seriesIndex];
155+
const sourceData = serie?.data.find((datum: any) => datum.name === params.data?.source);
156+
const targetData = serie?.data.find((datum: any) => datum.name === params.data?.target);
157+
const sourceColor = sourceData?.itemStyle?.color;
158+
const targetColor = targetData?.itemStyle?.color;
159+
return { sourceColor, targetColor };
160+
},
161+
[series]
162+
);
163+
164+
const getSize = () => ({
165+
...(height && { height: `${height}px` }),
166+
...(width && { width: `${width}px` })
167+
});
168+
169+
const getLegendTooltip = useCallback(() => {
170+
const symbolSize = '10px';
171+
const valueFormatter = tooltip?.valueFormatter ? tooltip.valueFormatter : (value: number | string) => value;
172+
const defaults = {
173+
backgroundColor: getComputedStyleValue(chart_voronoi_flyout_stroke_Fill),
174+
confine: true,
175+
formatter: (params: any) => {
176+
const result = `
177+
<div style="display: inline-block; background-color: ${params.color}; height: ${symbolSize}; width: ${symbolSize};"></div>
178+
${params.name} ${params.value}
179+
`;
180+
if (params.data.source && params.data.target) {
181+
const { sourceColor, targetColor } = getItemColor(params);
182+
return `
183+
<p>${tooltipSourceLabel}</p>
184+
<div style="display: inline-block; background-color: ${sourceColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
185+
${params.data.source}
186+
<p style="padding-top: 10px;">${tooltipDestinationLabel}</p>
187+
<p style="text-align:left;">
188+
<div style="display: inline-block; background-color: ${targetColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
189+
${params.data.target}
190+
<strong style="float:right;">
191+
${valueFormatter(params.value, params.dataIndex)}
192+
</strong>
193+
</p>
194+
`;
195+
}
196+
return result.replace(/\s\s+/g, ' ');
197+
},
198+
textStyle: {
199+
color: getComputedStyleValue(chart_voronoi_labels_Fill)
200+
},
201+
trigger: 'item',
202+
triggerOn: 'mousemove'
203+
};
204+
return defaultsDeep(tooltip, defaults);
205+
}, [getItemColor, tooltip, tooltipDestinationLabel, tooltipSourceLabel]);
206+
207+
React.useEffect(() => {
208+
const isSkeleton = themeColor === ThemeColor.skeleton;
209+
const getChartTheme = () => (isComputedTheme ? getComputedTheme(themeColor) : getTheme(themeColor));
210+
const chartTheme = theme ? theme : getChartTheme();
211+
echart.current = echarts.init(
212+
containerRef.current,
213+
chartTheme,
214+
defaultsDeep(opts, { height, renderer: 'svg', width })
215+
);
216+
217+
const getSeries = () =>
218+
series.map((serie: any) => {
219+
const defaults = {
220+
data: serie.data.map((datum: any, index: number) => ({
221+
itemStyle: {
222+
color: chartTheme?.color[index % chartTheme?.color.length]
223+
}
224+
})),
225+
...(isSkeleton ? { draggable: false } : {}),
226+
emphasis: {
227+
...(isSkeleton ? { disabled: true } : { focus: 'adjacency' })
228+
},
229+
layout: 'none',
230+
lineStyle: {
231+
color: 'source',
232+
opacity: 0.6
233+
},
234+
type: 'sankey'
235+
};
236+
return defaultsDeep(serie, defaults);
237+
});
238+
239+
echart.current?.setOption({
240+
...option,
241+
...(isLegendTooltip && { tooltip: getLegendTooltip() }),
242+
...(isSkeleton && { tooltip: undefined }), // Skeleton should not have any interactions
243+
series: getSeries()
244+
});
245+
246+
return () => {
247+
echart.current?.dispose();
248+
};
249+
}, [containerRef, getLegendTooltip, isComputedTheme, option, opts, series, theme, themeColor, update]);
250+
251+
// Resize observer
252+
React.useEffect(() => {
253+
echart.current?.resize();
254+
}, [height, width]);
255+
256+
// Dark theme observer
257+
React.useEffect(() => {
258+
let observer = () => {};
259+
observer = getMutationObserver(nodeSelector, () => {
260+
forceUpdate();
261+
});
262+
return () => {
263+
observer();
264+
};
265+
}, [nodeSelector]);
266+
267+
return <div className={getClassName(className)} id={id} ref={containerRef} style={getSize()} {...rest} />;
268+
};
269+
Sankey.displayName = 'Sankey';

0 commit comments

Comments
 (0)