feat: Adds the ECharts Sunburst chart (#22833)

This commit is contained in:
Michael S. Molina 2023-01-31 11:39:18 -05:00 committed by GitHub
parent cd6fc35f60
commit 30abefb519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 166 additions and 103 deletions

View File

@ -0,0 +1,57 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
import { boolean, withKnobs } from '@storybook/addon-knobs';
import {
EchartsSunburstChartPlugin,
SunburstTransformProps,
} from '@superset-ui/plugin-chart-echarts';
import { withResizableChartDemo } from '../../../../shared/components/ResizableChartDemo';
import data from './data';
new EchartsSunburstChartPlugin()
.configure({ key: 'echarts-sunburst' })
.register();
getChartTransformPropsRegistry().registerValue(
'echarts-sunburst',
SunburstTransformProps,
);
export default {
title: 'Chart Plugins/plugin-chart-echarts/Sunburst',
decorators: [withKnobs, withResizableChartDemo],
};
export const Sunburst = ({ width, height }) => (
<SuperChart
chartType="echarts-sunburst"
width={width}
height={height}
queriesData={[{ data }]}
formData={{
columns: ['genre', 'platform'],
metric: 'count',
showLabels: boolean('Show labels', true),
showTotal: boolean('Show total', true),
}}
/>
);

View File

@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export default [
{ genre: 'Adventure', platform: 'Wii', count: 84 },
{ genre: 'Adventure', platform: 'N64', count: 14 },
{ genre: 'Adventure', platform: 'XOne', count: 12 },
{ genre: 'Adventure', platform: 'PS4', count: 19 },
{ genre: 'Strategy', platform: 'Wii', count: 25 },
{ genre: 'Strategy', platform: 'PS4', count: 15 },
{ genre: 'Strategy', platform: 'N64', count: 29 },
{ genre: 'Strategy', platform: 'XOne', count: 23 },
{ genre: 'Simulation', platform: 'PS4', count: 15 },
{ genre: 'Simulation', platform: 'XOne', count: 36 },
{ genre: 'Simulation', platform: 'N64', count: 20 },
{ genre: 'Simulation', platform: 'Wii', count: 50 },
];

View File

@ -94,22 +94,22 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const { data } = eventParams;
const { records } = data;
const treePath = extractTreePathInfo(eventParams.treePathInfo);
if (treePath.length > 0) {
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
if (columns) {
treePath.forEach((path, i) =>
filters.push({
col: columns[i],
op: '==',
val: path,
formattedVal: path,
}),
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
if (columns?.length) {
treePath.forEach((path, i) =>
filters.push({
col: columns[i],
op: '==',
val: records[i],
formattedVal: path,
}),
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
}
},
};

View File

@ -189,9 +189,10 @@ const config: ControlPanelConfig = {
controls?.secondary_metric?.value !== controls?.metric.value,
),
},
groupby: {
columns: {
label: t('Hierarchy'),
description: t('This defines the level of the hierarchy'),
description: t(`Sets the hierarchy levels of the chart. Each level is
represented by one ring with the innermost circle as the top of the hierarchy.`),
},
},
formDataOverrides: formData => ({

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View File

@ -21,6 +21,8 @@ import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import example1 from './images/Sunburst1.png';
import example2 from './images/Sunburst2.png';
export default class EchartsSunburstChartPlugin extends ChartPlugin {
constructor() {
@ -29,13 +31,13 @@ export default class EchartsSunburstChartPlugin extends ChartPlugin {
controlPanel,
loadChart: () => import('./EchartsSunburst'),
metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART],
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'],
description: t(
'Uses circles to visualize the flow of data through different stages of a system. Hover over individual paths in the visualization to understand the stages a value took. Useful for multi-stage, multi-group visualizing funnels and pipelines.',
),
exampleGallery: [],
exampleGallery: [{ url: example1 }, { url: example2 }],
name: t('Sunburst Chart v2'),
tags: [
t('ECharts'),

View File

@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
CategoricalColorNamespace,
DataRecordValue,
getColumnLabel,
getMetricLabel,
getNumberFormatter,
@ -26,21 +26,23 @@ import {
getTimeFormatter,
NumberFormats,
NumberFormatter,
SupersetTheme,
t,
} from '@superset-ui/core';
import { EChartsCoreOption } from 'echarts';
import { SunburstSeriesNodeItemOption } from 'echarts/types/src/chart/sunburst/SunburstSeries';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import { OpacityEnum } from '../constants';
import { defaultGrid, defaultTooltip } from '../defaults';
import { defaultGrid } from '../defaults';
import { Refs } from '../types';
import { formatSeriesName, getColtypesMapping } from '../utils/series';
import { treeBuilder, TreeNode } from '../utils/treeBuilder';
import {
EchartsSunburstChartProps,
EchartsSunburstLabelType,
NodeItemOption,
SunburstTransformedProps,
} from './types';
import { getDefaultTooltip } from '../utils/tooltip';
export function getLinearDomain(
treeData: TreeNode[],
@ -96,6 +98,7 @@ export function formatTooltip({
totalValue,
metricLabel,
secondaryMetricLabel,
theme,
}: {
params: CallbackDataParams & {
treePathInfo: {
@ -109,9 +112,9 @@ export function formatTooltip({
totalValue: number;
metricLabel: string;
secondaryMetricLabel?: string;
theme: SupersetTheme;
}): string {
const { data, treePathInfo = [] } = params;
treePathInfo.shift();
const node = data as TreeNode;
const formattedValue = numberFormatter(node.value);
const formattedSecondaryValue = numberFormatter(node.secondaryValue);
@ -121,33 +124,43 @@ export function formatTooltip({
node.secondaryValue / node.value,
);
const absolutePercentage = percentFormatter(node.value / totalValue);
const parentNode = treePathInfo[treePathInfo.length - 1];
const parentNode =
treePathInfo.length > 2 ? treePathInfo[treePathInfo.length - 2] : undefined;
const result = [
`<div style="font-size: 14px;font-weight: 600">${absolutePercentage} of total</div>`,
`<div style="
font-size: ${theme.typography.sizes.m}px;
color: ${theme.colors.grayscale.base}"
>`,
`<div style="font-weight: ${theme.typography.weights.bold}">
${node.name}
</div>`,
`<div">
${absolutePercentage} of total
</div>`,
];
if (parentNode) {
const conditionalPercentage = percentFormatter(
node.value / parentNode.value,
);
result.push(`
<div style="font-size: 12px;">
${conditionalPercentage} of parent
<div>
${conditionalPercentage} of ${parentNode.name}
</div>`);
}
result.push(
`<div style="color: '#666666'">
`<div>
${metricLabel}: ${formattedValue}${
colorByCategory
? ''
: `, ${secondaryMetricLabel}: ${formattedSecondaryValue}`
}
</div>`,
</div>`,
colorByCategory
? ''
: `<div style="color: '#666666'">
${metricLabel}/${secondaryMetricLabel}: ${compareValuePercentage}
</div>`,
: `<div>${metricLabel}/${secondaryMetricLabel}: ${compareValuePercentage}</div>`,
);
result.push('</div>');
return result.join('\n');
}
@ -247,9 +260,14 @@ export default function transformProps(
linearColorScale(totalSecondaryValue / totalValue);
}
const traverse = (treeNodes: TreeNode[], path: string[]) =>
const traverse = (
treeNodes: TreeNode[],
path: string[],
pathRecords?: DataRecordValue[],
) =>
treeNodes.map(treeNode => {
const { name: nodeName, value, secondaryValue, groupBy } = treeNode;
const records = [...(pathRecords || []), nodeName];
let name = formatSeriesName(nodeName, {
numberFormatter,
timeFormatter: getTimeFormatter(dateFormat),
@ -258,10 +276,10 @@ export default function transformProps(
}),
});
const newPath = path.concat(name);
let item: SunburstSeriesNodeItemOption = {
let item: NodeItemOption = {
records,
name,
value,
// @ts-ignore
secondaryValue,
itemStyle: {
color: colorByCategory
@ -270,7 +288,7 @@ export default function transformProps(
},
};
if (treeNode.children?.length) {
item.children = traverse(treeNode.children, newPath);
item.children = traverse(treeNode.children, newPath, records);
} else {
name = newPath.join(',');
}
@ -295,7 +313,7 @@ export default function transformProps(
...defaultGrid,
},
tooltip: {
...defaultTooltip,
...getDefaultTooltip(refs),
show: !inContextMenu,
trigger: 'item',
formatter: (params: any) =>
@ -306,6 +324,7 @@ export default function transformProps(
totalValue,
metricLabel,
secondaryMetricLabel,
theme,
}),
},
series: [

View File

@ -20,10 +20,12 @@
import {
ChartDataResponseResult,
ChartProps,
DataRecordValue,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import { SunburstSeriesNodeItemOption } from 'echarts/types/src/chart/sunburst/SunburstSeries';
import {
BaseTransformedProps,
ContextMenuTransformedProps,
@ -62,3 +64,8 @@ export type SunburstTransformedProps =
BaseTransformedProps<EchartsSunburstFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps;
export type NodeItemOption = SunburstSeriesNodeItemOption & {
records: DataRecordValue[];
secondaryValue: number;
};

View File

@ -1,7 +1,3 @@
import { CallbackDataParams } from 'echarts/types/src/util/types';
import { LegendOrientation } from './types';
import { TOOLTIP_POINTER_MARGIN, TOOLTIP_OVERFLOW_MARGIN } from './constants';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@ -20,66 +16,12 @@ import { TOOLTIP_POINTER_MARGIN, TOOLTIP_OVERFLOW_MARGIN } from './constants';
* specific language governing permissions and limitations
* under the License.
*/
import { LegendOrientation } from './types';
export const defaultGrid = {
containLabel: true,
};
export const defaultTooltip = {
position: (
canvasMousePos: [number, number],
params: CallbackDataParams,
tooltipDom: HTMLElement,
rect: any,
sizes: { contentSize: [number, number]; viewSize: [number, number] },
) => {
// algorithm copy-pasted from here:
// https://github.com/apache/echarts/issues/5004#issuecomment-559668309
// The chart canvas position
const canvasRect = tooltipDom.parentElement
?.getElementsByTagName('canvas')[0]
.getBoundingClientRect();
// The mouse coordinates relative to the whole window
// The first parameter to the position function is the mouse position relative to the canvas
const mouseX = canvasMousePos[0] + (canvasRect?.x || 0);
const mouseY = canvasMousePos[1] + (canvasRect?.y || 0);
// The width and height of the tooltip dom element
const tooltipWidth = sizes.contentSize[0];
const tooltipHeight = sizes.contentSize[1];
// Start by placing the tooltip top and right relative to the mouse position
let xPos = mouseX + TOOLTIP_POINTER_MARGIN;
let yPos = mouseY - TOOLTIP_POINTER_MARGIN - tooltipHeight;
// The tooltip is overflowing past the right edge of the window
if (xPos + tooltipWidth >= document.documentElement.clientWidth) {
// Attempt to place the tooltip to the left of the mouse position
xPos = mouseX - TOOLTIP_POINTER_MARGIN - tooltipWidth;
// The tooltip is overflowing past the left edge of the window
if (xPos <= 0)
// Place the tooltip a fixed distance from the left edge of the window
xPos = TOOLTIP_OVERFLOW_MARGIN;
}
// The tooltip is overflowing past the top edge of the window
if (yPos <= 0) {
// Attempt to place the tooltip to the bottom of the mouse position
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
// The tooltip is overflowing past the bottom edge of the window
if (yPos + tooltipHeight >= document.documentElement.clientHeight)
// Place the tooltip a fixed distance from the top edge of the window
yPos = TOOLTIP_OVERFLOW_MARGIN;
}
// Return the position (converted back to a relative position on the canvas)
return [xPos - (canvasRect?.x || 0), yPos - (canvasRect?.y || 0)];
},
};
export const defaultYAxis = {
scale: true,
};

View File

@ -16,17 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DataRecord } from '@superset-ui/core';
import { DataRecord, DataRecordValue } from '@superset-ui/core';
import _ from 'lodash';
export type TreeNode = {
name: string;
name: DataRecordValue;
value: number;
secondaryValue: number;
groupBy: string;
children?: TreeNode[];
};
function getMetricValue(datum: DataRecord, metric: string) {
return _.isNumber(datum[metric]) ? (datum[metric] as number) : 0;
}
export function treeBuilder(
data: DataRecord[],
groupBy: string[],
@ -37,7 +41,8 @@ export function treeBuilder(
const curData = _.groupBy(data, curGroupBy);
return _.transform(
curData,
(result, value, name) => {
(result, value, key) => {
const name = curData[key][0][curGroupBy]!;
if (!restGroupby.length) {
(value ?? []).forEach(datum => {
const metricValue = getMetricValue(datum, metric);
@ -81,7 +86,3 @@ export function treeBuilder(
[] as TreeNode[],
);
}
function getMetricValue(datum: DataRecord, metric: string) {
return _.isNumber(datum[metric]) ? (datum[metric] as number) : 0;
}

View File

@ -68,6 +68,7 @@ import {
EchartsTreemapChartPlugin,
EchartsMixedTimeseriesChartPlugin,
EchartsTreeChartPlugin,
EchartsSunburstChartPlugin,
} from '@superset-ui/plugin-chart-echarts';
import {
SelectFilterPlugin,
@ -165,6 +166,7 @@ export default class MainPreset extends Preset {
new TimeColumnFilterPlugin().configure({ key: 'filter_timecolumn' }),
new TimeGrainFilterPlugin().configure({ key: 'filter_timegrain' }),
new EchartsTreeChartPlugin().configure({ key: 'tree_chart' }),
new EchartsSunburstChartPlugin().configure({ key: 'sunburst_v2' }),
new HandlebarsChartPlugin().configure({ key: 'handlebars' }),
...experimentalplugins,
],