Skip to content

Commit

Permalink
feat(charts): sankey
Browse files Browse the repository at this point in the history
  • Loading branch information
dlabrecq committed Nov 22, 2024
1 parent 19a20c9 commit ed73e90
Show file tree
Hide file tree
Showing 18 changed files with 982 additions and 4 deletions.
4 changes: 4 additions & 0 deletions packages/react-charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"types": "dist/esm/index.d.ts",
"typesVersions": {
"*": {
"echarts": [
"dist/esm/echarts/index.d.ts"
],
"victory": [
"dist/esm/victory/index.d.ts"
]
Expand Down Expand Up @@ -43,6 +46,7 @@
"tslib": "^2.8.1"
},
"peerDependencies": {
"echarts": "^5.5.1",
"react": "^17 || ^18",
"react-dom": "^17 || ^18",
"victory-area": "^37.3.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/react-charts/single-packages.config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"packageName": "@patternfly/react-charts",
"exclude": ["dist/esm/deprecated/index.js", "dist/esm/next/index.js"]
"exclude": ["dist/esm/deprecated/index.js"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as React from 'react';
// import * as echarts from 'echarts';
import { render } from '@testing-library/react';
import { Sankey } from './Sankey';

const data = [
{
name: 'a'
},
{
name: 'b'
},
{
name: 'a1'
},
{
name: 'a2'
},
{
name: 'b1'
},
{
name: 'c'
}
];

const links = [
{
source: 'a',
target: 'a1',
value: 5
},
{
source: 'a',
target: 'a2',
value: 3
},
{
source: 'b',
target: 'b1',
value: 8
},
{
source: 'a',
target: 'b1',
value: 3
},
{
source: 'b1',
target: 'a1',
value: 1
},
{
source: 'b1',
target: 'c',
value: 2
}
];

let spy: any;

// beforeAll(() => {
// console.log(`*** TEST 1`);
// spy = jest.spyOn(echarts, 'getInstanceByDom').mockImplementation(
// () =>
// ({
// hideLoading: jest.fn(),
// setOption: jest.fn(),
// showLoading: jest.fn()
// }) as any
// );
// });
//
// afterAll(() => {
// console.log(`*** TEST 2`);
// spy.mockRestore();
// });

// See https://stackoverflow.com/questions/54921743/testing-echarts-react-component-with-jest-echartelement-is-null
xtest('renders component data', () => {
const { asFragment } = render(<Sankey opts={{ renderer: 'svg' }} series={[{ data, links }]} />);
expect(asFragment()).toMatchSnapshot();
});
251 changes: 251 additions & 0 deletions packages/react-charts/src/echarts/components/Sankey/Sankey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/* eslint-disable camelcase */
import chart_voronoi_flyout_stroke_Fill from '@patternfly/react-tokens/dist/esm/chart_voronoi_flyout_stroke_Fill';
import chart_voronoi_labels_Fill from '@patternfly/react-tokens/dist/esm/chart_voronoi_labels_Fill';

import * as React from 'react';
import * as echarts from 'echarts';
import { useCallback, useRef, useState } from 'react';
import defaultsDeep from 'lodash/defaultsDeep';
import { getMutationObserver } from '../utils/observe';
import { getComputedStyle } from '../utils/theme';

// import { BarChart, SankeyChart } from 'echarts/charts';
// import { CanvasRenderer } from 'echarts/renderers';

// import {
// TitleComponent,
// TooltipComponent,
// GridComponent,
// DatasetComponent,
// TransformComponent
// } from 'echarts/components';

// Register the required components
// echarts.use([
// BarChart,
// SankeyChart,
// TitleComponent,
// TooltipComponent,
// GridComponent,
// DatasetComponent,
// TransformComponent,
// LabelLayout,
// UniversalTransition,
// CanvasRenderer
// ]);

import { getTheme } from './theme';
import { getClassName } from '../utils/misc';
import { EChartsInitOpts } from 'echarts/types/dist/echarts';

/**
* Sankey diagram is a specific type of streamgraph (can also be seen as a directed acyclic graph) in which the width
* of each branch is shown proportionally to the flow quantity. These graphs are typically used to visualize energy or
* material or cost transfers between processes. They can also visualize the energy accounts, material flow accounts
* on a regional or national level, and also the breakdown of cost of item or services.
*/
export interface SankeyProps {
/**
* The className prop specifies a class name that will be applied to outermost element
*/
className?: string;
/**
* Specify height explicitly, in pixels
*/
height?: number;
/**
* The id prop specifies an ID that will be applied to outermost element.
*/
id?: string;
/**
* This creates a Mutation Observer to watch the given DOM selector.
*
* When the pf-v6-theme-dark selector is added or removed, this component will be notified to update its computed
* theme styles. However, if the dark theme is not updated dynamically (e.g., via a toggle), there is no need to add
* this Mutation Observer.
*
* Note: Don't provide ".pf-v6-theme-dark" as the node selector as it won't exist in the page for light theme.
* The underlying querySelectorAll() function needs to find the element the dark theme selector will be added to.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Locating_DOM_elements_using_selectors
*
* @propType string
* @example <Sankey nodeSelector="html" />
* @example <Sankey nodeSelector="#main" />
* @example <Sankey nodeSelector=".chr-scope__default-layout" />
*/
nodeSelector?: string;
/**
* Optional chart configuration
*
* See https://echarts.apache.org/en/api.html#echarts.init
*/
opts?: EChartsInitOpts;
/**
* Series component properties
*
* See https://echarts.apache.org/en/option.html#series-sankey
*/
series: any[];
/**
* The theme prop specifies a theme to use for determining styles and layout properties for a component. Any styles or
* props defined in theme may be overwritten by props specified on the component instance.
*
* See https://echarts.apache.org/handbook/en/concepts/style/#theme
*/
theme?: any; // Todo: Add theme type
/**
* Title component properties
*
* See https://echarts.apache.org/en/option.html#title
*/
title?: any;
/**
* Tooltip component properties
*
* See https://echarts.apache.org/en/option.html#tooltip
*/
tooltip?: any;
/**
* This is the destination label shown in the tooltip
*/
tooltipDestinationLabel?: string;
/**
* The source label shown in the tooltip
*/
tooltipSourceLabel?: string;
/**
* Specify width explicitly, in pixels
*/
width?: number;
}

export const Sankey: React.FunctionComponent<SankeyProps> = ({
className,
height,
id,
nodeSelector,
opts,
series,

theme,
title,
tooltip = {
valueFormatter: (value: number | string) => value
},
tooltipDestinationLabel = 'Destination',
tooltipSourceLabel = 'Source',
width
}: SankeyProps) => {
const containerRef = useRef<HTMLDivElement>();
const echart = useRef<echarts.ECharts>();
const [chartTheme, setChartTheme] = useState(theme || getTheme());

const getItemColor = useCallback(
(params: any) => {
const serie = series[params.seriesIndex];
const sourceData = serie?.data.find((datum: any) => datum.name === params.data?.source);
const targetData = serie?.data.find((datum: any) => datum.name === params.data?.target);
const sourceColor = sourceData?.itemStyle?.color;
const targetColor = targetData?.itemStyle?.color;
return { sourceColor, targetColor };
},
[series]
);

const getSize = () => ({
...(height && { height: `${height}px` }),
...(width && { width: `${width}px` })
});

const getTooltip = useCallback(() => {
const symbolSize = '10px';
const defaults = {
backgroundColor: getComputedStyle(chart_voronoi_flyout_stroke_Fill),
confine: true,
formatter: (params: any) => {
const result = `
<div style="display: inline-block; background-color: ${params.color}; height: ${symbolSize}; width: ${symbolSize};"></div>
${params.name} ${params.value}
`;
if (params.data.source && params.data.target) {
const { sourceColor, targetColor } = getItemColor(params);
return `
<p>${tooltipSourceLabel}</p>
<div style="display: inline-block; background-color: ${sourceColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
${params.data.source}
<p style="padding-top: 10px;">${tooltipDestinationLabel}</p>
<p style="text-align:left;">
<div style="display: inline-block; background-color: ${targetColor}; height: ${symbolSize}; width: ${symbolSize};"></div>
${params.data.target}
<strong style="float:right;">
${tooltip.valueFormatter(params.value, params.dataIndex)}
</strong>
</p>
`;
}
return result.replace(/\s\s+/g, ' ');
},
textStyle: {
color: getComputedStyle(chart_voronoi_labels_Fill)
},
trigger: 'item',
triggerOn: 'mousemove'
};
return defaultsDeep(tooltip, defaults);
}, [getItemColor, tooltipDestinationLabel, tooltipSourceLabel, tooltip]);

React.useEffect(() => {
echarts.registerTheme('pf-sankey', chartTheme);
echart.current = echarts.init(containerRef.current, 'pf-v5-sankey', defaultsDeep(opts, { renderer: 'svg' }));

const newSeries = series.map((serie: any) => {
const defaults = {
data: serie.data.map((datum: any, index: number) => ({
itemStyle: {
color: chartTheme?.color[index % chartTheme?.color.length]
}
})),
emphasis: {
focus: 'adjacency'
},
layout: 'none',
lineStyle: {
color: 'source',
opacity: 0.6
},
type: 'sankey'
};
return defaultsDeep(serie, defaults);
});

echart.current?.setOption({
series: newSeries,
title,
tooltip: getTooltip()
});

return () => {
echart.current?.dispose();
};
}, [chartTheme, containerRef, getTooltip, opts, series, title, tooltip]);

// Resize observer
React.useEffect(() => {
echart.current?.resize();
}, [height, width]);

// Dark theme observer
React.useEffect(() => {
let observer = () => {};
observer = getMutationObserver(nodeSelector, () => {
setChartTheme(getTheme());
});
return () => {
observer();
};
}, [nodeSelector]);

return <div className={getClassName(className)} id={id} ref={containerRef} style={getSize()} />;
};
Sankey.displayName = 'Sankey';
Loading

0 comments on commit ed73e90

Please sign in to comment.