Skip to content

Commit 793cf0f

Browse files
committed
feat(charts): add Sankey chart
1 parent 9b41c4f commit 793cf0f

File tree

71 files changed

+3397
-44
lines changed

Some content is hidden

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

71 files changed

+3397
-44
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: 12 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",
@@ -64,6 +73,9 @@
6473
"victory-zoom-container": "^37.3.6"
6574
},
6675
"peerDependenciesMeta": {
76+
"echarts": {
77+
"optional": true
78+
},
6779
"victory-area": {
6880
"optional": true
6981
},
@@ -120,9 +132,5 @@
120132
"clean": "rimraf dist echarts victory",
121133
"build:single:packages": "node ../../scripts/build-single-packages.mjs --config single-packages.config.json",
122134
"subpaths": "node ../../scripts/exportSubpaths.mjs --config subpaths.config.json"
123-
},
124-
"devDependencies": {
125-
"@types/lodash": "^4.17.15",
126-
"fs-extra": "^11.3.0"
127135
}
128136
}
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: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import * as React from 'react';
2+
import { useCallback, useReducer, useRef } from 'react';
3+
import cloneDeep from 'lodash/cloneDeep';
4+
import defaultsDeep from 'lodash/defaultsDeep';
5+
import { getMutationObserver } from '../utils/observe';
6+
7+
import * as echarts from 'echarts/core';
8+
import { EChartsOption } from 'echarts/types/dist/option';
9+
import { LineChart } from 'echarts/charts';
10+
import { GridComponent, TitleComponent, TooltipComponent } from 'echarts/components';
11+
import { SVGRenderer } from 'echarts/renderers';
12+
13+
// Register minimal required components
14+
echarts.use([GridComponent, LineChart, SVGRenderer, TitleComponent, TooltipComponent]);
15+
16+
import { EChartsInitOpts } from 'echarts/types/dist/echarts';
17+
import { ThemeDefinition } from '../themes/Theme';
18+
import { getClassName } from '../utils/styles';
19+
import { getTheme } from '../utils/theme';
20+
import { ThemeColor } from '../themes/ThemeColor';
21+
22+
/**
23+
* The Line chart relates all the data point symbols by broken lines, which is used to show the trend of data changing
24+
*
25+
* Note: Only the minimum requirements are imported from echarts. This includes components to support the grid, series,
26+
* title, and tooltip properties. To include a toolbox; for example, you must include the component (in addition to
27+
* toolbox props) like so:
28+
*
29+
* import * as echarts from 'echarts/core';
30+
* import { ToolboxComponent } from 'echarts/components';
31+
* echarts.use([ToolboxComponent]);
32+
*
33+
* @beta
34+
*/
35+
export interface LineProps {
36+
/**
37+
* The className prop specifies a class name that will be applied to outermost element
38+
*/
39+
className?: string;
40+
/**
41+
* Specify height explicitly, in pixels
42+
*/
43+
height?: number;
44+
/**
45+
* The id prop specifies an ID that will be applied to outermost element.
46+
*/
47+
id?: string;
48+
/**
49+
* Flag indicating to use the legend tooltip (default). This may be overridden by the `option.tooltip` property.
50+
*/
51+
isLegendTooltip?: boolean;
52+
/**
53+
* Flag indicating to use the SVG renderer (default). This may be overridden by the `opts.renderer` property.
54+
*/
55+
isSvgRenderer?: boolean;
56+
/**
57+
* This creates a Mutation Observer to watch the given DOM selector.
58+
*
59+
* When the pf-v6-theme-dark selector is added or removed, this component will be notified to update its computed
60+
* theme styles. However, if the dark theme is not updated dynamically (e.g., via a toggle), there is no need to add
61+
* this Mutation Observer.
62+
*
63+
* Note: Don't provide ".pf-v6-theme-dark" as the node selector as it won't exist in the page for light theme.
64+
* The underlying querySelectorAll() function needs to find the element the dark theme selector will be added to.
65+
*
66+
* See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Locating_DOM_elements_using_selectors
67+
*
68+
* @propType string
69+
* @example <Line nodeSelector="html" />
70+
* @example <Line nodeSelector="#main" />
71+
* @example <Line nodeSelector=".chr-scope__default-layout" />
72+
*/
73+
nodeSelector?: string;
74+
/**
75+
* ECharts uses this object to configure its properties; for example, series, title, and tooltip
76+
*
77+
* See https://echarts.apache.org/en/option.html
78+
*/
79+
option?: EChartsOption;
80+
/**
81+
* Optional chart configuration
82+
*
83+
* See https://echarts.apache.org/en/api.html#echarts.init
84+
*/
85+
opts?: EChartsInitOpts;
86+
/**
87+
* The theme prop specifies a theme to use for determining styles and layout properties for a component. Any styles or
88+
* props defined in theme may be overwritten by props specified on the component instance.
89+
*
90+
* See https://echarts.apache.org/handbook/en/concepts/style/#theme
91+
*/
92+
theme?: ThemeDefinition;
93+
/**
94+
* Specifies the theme color. Valid values are 'blue', 'green', 'multi', etc.
95+
*
96+
* Note: Not compatible with theme prop
97+
*
98+
* @example themeColor={ChartThemeColor.blue}
99+
*/
100+
themeColor?: string;
101+
/**
102+
* Specify width explicitly, in pixels
103+
*/
104+
width?: number;
105+
}
106+
107+
export const Line: React.FunctionComponent<LineProps> = ({
108+
className,
109+
height,
110+
id,
111+
isLegendTooltip = true,
112+
isSvgRenderer = true,
113+
nodeSelector,
114+
option,
115+
opts,
116+
theme,
117+
themeColor,
118+
width,
119+
...rest
120+
}: LineProps) => {
121+
const containerRef = useRef<HTMLDivElement>();
122+
const echart = useRef<echarts.ECharts>();
123+
const [update, forceUpdate] = useReducer((x) => x + 1, 0);
124+
125+
const series: any = cloneDeep(option?.series);
126+
const tooltip: any = cloneDeep(option?.tooltip);
127+
128+
const getSize = () => ({
129+
...(height && { height: `${height}px` }),
130+
...(width && { width: `${width}px` })
131+
});
132+
133+
const getLegendTooltip = useCallback(() => {
134+
const symbolSize = '10px';
135+
const valueFormatter = tooltip?.valueFormatter ? tooltip.valueFormatter : (value: number | string) => value;
136+
137+
const defaults = {
138+
confine: true,
139+
formatter: (params: any) => {
140+
let result = '';
141+
params?.map((param, index) => {
142+
if (index === 0) {
143+
result += `<p style="text-align:left;">${param.name}</p>`;
144+
}
145+
// Todo: Replace param.marker with custom icon -- see https://github.com/apache/echarts/issues/19826
146+
result += `
147+
<p style="text-align:left;">
148+
<div style="display: inline-block; background-color: ${param.color}; height: ${symbolSize}; width: ${symbolSize};"></div>
149+
${param.seriesName}
150+
<strong style="float:right; margin-left: 20px">
151+
${valueFormatter(param.value, param.dataIndex)}
152+
</strong>
153+
</p>
154+
`;
155+
});
156+
return result;
157+
},
158+
trigger: 'axis'
159+
};
160+
return defaultsDeep(tooltip, defaults);
161+
}, [tooltip]);
162+
163+
React.useEffect(() => {
164+
const isSkeleton = themeColor === ThemeColor.skeleton;
165+
const chartTheme = theme ? theme : getTheme(themeColor);
166+
const renderer = isSvgRenderer ? 'svg' : 'canvas';
167+
168+
echart.current = echarts.init(
169+
containerRef.current,
170+
chartTheme,
171+
defaultsDeep(opts, { height, renderer, width }) // height and width are necessary here for unit tests
172+
);
173+
174+
const getSeries = () =>
175+
series.map((serie: any) => {
176+
const defaults = {
177+
emphasis: {
178+
...(isSkeleton ? { disabled: true } : { focus: 'adjacency' })
179+
},
180+
type: 'line'
181+
};
182+
return defaultsDeep(serie, defaults);
183+
});
184+
185+
// Todo: Tooltip symbol workaround -- see https://github.com/apache/echarts/issues/18016
186+
echart.current?.setOption({
187+
...option,
188+
...(!isSkeleton && isLegendTooltip && { tooltip: getLegendTooltip() }),
189+
...(isSkeleton && { tooltip: undefined }), // Skeleton should not have any interactions
190+
series: getSeries()
191+
});
192+
193+
return () => {
194+
echart.current?.dispose();
195+
};
196+
}, [containerRef, getLegendTooltip, option, opts, series, theme, themeColor, update]);
197+
198+
// Resize observer
199+
React.useEffect(() => {
200+
echart.current?.resize();
201+
}, [height, width]);
202+
203+
// Dark theme observer
204+
React.useEffect(() => {
205+
let observer = () => {};
206+
observer = getMutationObserver(nodeSelector, () => {
207+
forceUpdate();
208+
});
209+
return () => {
210+
observer();
211+
};
212+
}, [nodeSelector]);
213+
214+
return <div className={getClassName(className)} id={id} ref={containerRef} style={getSize()} {...rest} />;
215+
};
216+
Line.displayName = 'Line';
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { setupJestCanvasMock } from 'jest-canvas-mock';
3+
import { render, screen } from '@testing-library/react';
4+
import { Line } from '../Line';
5+
6+
beforeEach(() => {
7+
jest.resetAllMocks();
8+
jest.mock('echarts');
9+
setupJestCanvasMock();
10+
});
11+
12+
const props: any = {
13+
height: 400,
14+
id: 'line-chart',
15+
option: {
16+
xAxis: {
17+
type: 'category',
18+
data: ['2015', '2016', '2017', '2018']
19+
},
20+
yAxis: {
21+
axisLabel: {
22+
formatter: (value) => (value !== 0 ? `${value}` : '')
23+
},
24+
splitNumber: 3,
25+
type: 'value'
26+
},
27+
series: [
28+
{
29+
data: [1, 2, 5, 3],
30+
name: 'Cats'
31+
},
32+
{
33+
data: [2, 1, 7, 4],
34+
name: 'Dogs',
35+
lineStyle: {
36+
type: 'dashed'
37+
}
38+
},
39+
{
40+
data: [3, 4, 9, 5],
41+
name: 'Birds'
42+
},
43+
{
44+
data: [3, 3, 8, 7],
45+
name: 'Mice'
46+
}
47+
],
48+
title: {
49+
text: 'This is a Line chart'
50+
}
51+
},
52+
width: 800
53+
};
54+
55+
// Remove dynamic _echarts_instance_ ID
56+
const removeInstanceID = (fragment) => {
57+
fragment.getElementById('line-chart').removeAttribute('_echarts_instance_');
58+
return fragment;
59+
};
60+
61+
test('renders component', () => {
62+
const { asFragment } = render(<Line {...props} />);
63+
expect(removeInstanceID(asFragment())).toMatchSnapshot();
64+
});
65+
66+
test('renders title', async () => {
67+
render(<Line {...props} />);
68+
69+
const title = await screen.findByText(props.option.title.text);
70+
expect(title).toMatchSnapshot();
71+
});
72+
73+
test('renders height and width', async () => {
74+
const { asFragment } = render(<Line {...props} />);
75+
76+
const svg = asFragment().querySelector('svg');
77+
expect(svg).toHaveAttribute('height', `${props.height}`);
78+
expect(svg).toHaveAttribute('width', `${props.width}`);
79+
});

0 commit comments

Comments
 (0)