feat: implement ECharts pie chart (#772)

This commit is contained in:
Ville Brofeldt 2020-09-11 00:56:38 +03:00 committed by Yongjie Zhao
parent 6ac6880fd9
commit 2169a0b37e
16 changed files with 385 additions and 82 deletions

View File

@ -25,7 +25,7 @@ export default {
{ {
label: t('Query'), label: t('Query'),
expanded: true, expanded: true,
controlSetRows: [['metric'], ['adhoc_filters'], ['groupby'], ['row_limit']], controlSetRows: [['groupby'], ['metric'], ['adhoc_filters'], ['row_limit']],
}, },
{ {
label: t('Chart Options'), label: t('Chart Options'),

View File

@ -17,9 +17,9 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { EchartsPieProps } from './types'; import { EchartsProps } from '../types';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
export default function EchartsPie({ height, width, echartOptions }: EchartsPieProps) { export default function EchartsPie({ height, width, echartOptions }: EchartsProps) {
return <Echart height={height} width={width} echartOptions={echartOptions} />; return <Echart height={height} width={width} echartOptions={echartOptions} />;
} }

View File

@ -17,19 +17,98 @@
* under the License. * under the License.
*/ */
import { t, validateNonEmpty } from '@superset-ui/core'; import { t, validateNonEmpty } from '@superset-ui/core';
import { ControlPanelConfig } from '@superset-ui/chart-controls'; import { ControlPanelConfig, D3_FORMAT_OPTIONS } from '@superset-ui/chart-controls';
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
controlPanelSections: [ controlPanelSections: [
{ {
label: t('Query'), label: t('Query'),
expanded: true, expanded: true,
controlSetRows: [['groupby'], ['metrics'], ['adhoc_filters'], ['row_limit', null]], controlSetRows: [['groupby'], ['metric'], ['adhoc_filters'], ['row_limit', null]],
}, },
{ {
label: t('Chart Options'), label: t('Chart Options'),
expanded: true, expanded: true,
controlSetRows: [ controlSetRows: [
[
{
name: 'pie_label_type',
config: {
type: 'SelectControl',
label: t('Label Type'),
default: 'key',
renderTrigger: true,
choices: [
['key', 'Category Name'],
['value', 'Value'],
['percent', 'Percentage'],
['key_value', 'Category and Value'],
['key_percent', 'Category and Percentage'],
['key_value_percent', 'Category, Value and Percentage'],
],
description: t('What should be shown on the label?'),
},
},
{
name: 'number_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Number format'),
renderTrigger: true,
default: 'SMART_NUMBER',
choices: D3_FORMAT_OPTIONS,
description: `${t('D3 format syntax: https://github.com/d3/d3-format')} ${t(
'Only applies when "Label Type" is set to show values.',
)}`,
},
},
],
[
{
name: 'donut',
config: {
type: 'CheckboxControl',
label: t('Donut'),
default: false,
renderTrigger: true,
description: t('Do you want a donut or a pie?'),
},
},
{
name: 'show_legend',
config: {
type: 'CheckboxControl',
label: t('Legend'),
renderTrigger: true,
default: true,
description: t('Whether to display a legend for the chart'),
},
},
],
[
{
name: 'show_labels',
config: {
type: 'CheckboxControl',
label: t('Show Labels'),
renderTrigger: true,
default: true,
description: t('Whether to display the labels.'),
},
},
{
name: 'labels_outside',
config: {
type: 'CheckboxControl',
label: t('Put labels outside'),
default: true,
renderTrigger: true,
description: t('Put the labels outside of the pie?'),
},
},
],
['color_scheme', 'label_colors'],
[ [
{ {
name: 'outerRadius', name: 'outerRadius',
@ -40,10 +119,12 @@ const config: ControlPanelConfig = {
min: 10, min: 10,
max: 100, max: 100,
step: 1, step: 1,
default: 70, default: 80,
description: t('Outer edge of Pie chart'), description: t('Outer edge of Pie chart'),
}, },
}, },
],
[
{ {
name: 'innerRadius', name: 'innerRadius',
config: { config: {
@ -53,7 +134,7 @@ const config: ControlPanelConfig = {
min: 0, min: 0,
max: 100, max: 100,
step: 1, step: 1,
default: 50, default: 40,
description: t('Inner radius of donut hole'), description: t('Inner radius of donut hole'),
}, },
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -22,12 +22,6 @@ import controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: 'Pie chart using ECharts',
name: t('EchartsPie'),
thumbnail,
});
export default class EchartsPieChartPlugin extends ChartPlugin { export default class EchartsPieChartPlugin extends ChartPlugin {
/** /**
* The constructor is used to pass relevant metadata and callbacks that get * The constructor is used to pass relevant metadata and callbacks that get
@ -44,7 +38,11 @@ export default class EchartsPieChartPlugin extends ChartPlugin {
buildQuery, buildQuery,
controlPanel, controlPanel,
loadChart: () => import('./EchartsPie'), loadChart: () => import('./EchartsPie'),
metadata, metadata: new ChartMetadata({
description: 'Pie chart using ECharts',
name: t('ECharts Pie'),
thumbnail,
}),
transformProps, transformProps,
}); });
} }

View File

@ -16,60 +16,126 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ChartProps, DataRecord } from '@superset-ui/core'; import {
import { EchartsPieProps } from './types'; CategoricalColorNamespace,
ChartProps,
convertMetric,
DataRecord,
getNumberFormatter,
NumberFormats,
NumberFormatter,
} from '@superset-ui/core';
import { EchartsPieLabelType, PieChartFormData } from './types';
import { EchartsProps } from '../types';
import { extractGroupbyLabel } from '../utils/series';
export default function transformProps(chartProps: ChartProps): EchartsPieProps { const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
/*
TODO: export function formatPieLabel({
- add support for multiple groupby (requires post transform op) params,
- add support for ad-hoc metrics (currently only supports datasource metrics) pieLabelType,
- add support for superset colors numberFormatter,
- add support for control values in legacy pie chart }: {
*/ params: echarts.EChartOption.Tooltip.Format;
pieLabelType: EchartsPieLabelType;
numberFormatter: NumberFormatter;
}): string {
const { name = '', value, percent } = params;
const formattedValue = numberFormatter(value as number);
const formattedPercent = percentFormatter((percent as number) / 100);
if (pieLabelType === 'key') return name;
if (pieLabelType === 'value') return formattedValue;
if (pieLabelType === 'percent') return formattedPercent;
if (pieLabelType === 'key_value') return `${name}: ${formattedValue}`;
if (pieLabelType === 'key_value_percent')
return `${name}: ${formattedValue} (${formattedPercent})`;
if (pieLabelType === 'key_percent') return `${name}: ${formattedPercent}`;
return name;
}
export default function transformProps(chartProps: ChartProps): EchartsProps {
const { width, height, formData, queryData } = chartProps; const { width, height, formData, queryData } = chartProps;
const data: DataRecord[] = queryData.data || []; const data: DataRecord[] = queryData.data || [];
const { innerRadius = 50, outerRadius = 70, groupby = [], metrics = [] } = formData; const {
colorScheme,
donut = false,
groupby,
innerRadius = 40,
labelsOutside = true,
metric,
numberFormat,
outerRadius = 80,
pieLabelType = 'value',
showLabels = true,
showLegend = false,
} = formData as PieChartFormData;
const { label: metricLabel } = convertMetric(metric);
const keys = Array.from(new Set(data.map(datum => datum[groupby[0]]))); const keys = data.map(datum => extractGroupbyLabel(datum, groupby));
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const transformedData = data.map(datum => { const transformedData = data.map(datum => {
const name = extractGroupbyLabel(datum, groupby);
return { return {
value: datum[metrics[0]], value: datum[metricLabel],
name: datum[groupby[0]], name,
itemStyle: {
color: colorFn(name),
},
}; };
}); });
const echartOptions = { const formatter = (params: { name: string; value: number; percent: number }) =>
formatPieLabel({ params, numberFormatter, pieLabelType });
const echartOptions: echarts.EChartOption<echarts.EChartOption.SeriesPie> = {
tooltip: { tooltip: {
confine: true,
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)', formatter: params => {
}, return formatPieLabel({
legend: { params: params as echarts.EChartOption.Tooltip.Format,
orient: 'vertical', numberFormatter,
left: 10, pieLabelType: 'key_value_percent',
data: keys, });
},
}, },
legend: showLegend
? {
orient: 'horizontal',
left: 10,
data: keys,
}
: undefined,
series: [ series: [
{ {
type: 'pie', type: 'pie',
radius: [`${innerRadius}%`, `${outerRadius}%`], radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
avoidLabelOverlap: false, avoidLabelOverlap: true,
label: { labelLine: labelsOutside ? { show: true } : { show: false },
show: false, label: labelsOutside
position: 'center', ? {
}, formatter,
position: 'outer',
show: showLabels,
alignTo: 'none',
bleedMargin: 5,
}
: {
formatter,
position: 'inner',
show: showLabels,
},
emphasis: { emphasis: {
label: { label: {
show: true, show: true,
fontSize: '30', fontSize: 30,
fontWeight: 'bold', fontWeight: 'bold',
}, },
}, },
labelLine: { // @ts-ignore
show: false,
},
data: transformedData, data: transformedData,
}, },
], ],
@ -78,7 +144,6 @@ export default function transformProps(chartProps: ChartProps): EchartsPieProps
return { return {
width, width,
height, height,
// @ts-ignore
echartOptions, echartOptions,
}; };
} }

View File

@ -17,26 +17,24 @@
* under the License. * under the License.
*/ */
import { QueryFormData } from '@superset-ui/core'; import { QueryFormData } from '@superset-ui/core';
import { EchartsProps } from '../types';
export type PieChartFormData = QueryFormData & { export type PieChartFormData = QueryFormData & {
groupby?: string[]; groupby: string[];
metrics?: string[]; metric: string;
outerRadius?: number; outerRadius?: number;
innerRadius?: number; innerRadius?: number;
colorScheme?: string;
donut?: boolean;
showLegend?: boolean;
showLabels?: boolean;
labelsOutside?: boolean;
numberFormat?: string;
}; };
export type EchartsPieProps = EchartsProps & { export type EchartsPieLabelType =
formData: PieChartFormData; | 'key'
area: number; | 'value'
colorScheme: string; | 'percent'
contributionMode?: string; | 'key_value'
zoomable?: boolean; | 'key_percent'
seriesType: string; | 'key_value_percent';
logAxis: boolean;
stack: boolean;
markerEnabled: boolean;
markerSize: number;
minorSplitLine: boolean;
opacity: number;
};

View File

@ -40,7 +40,7 @@ export default function buildQuery(formData: QueryFormData) {
return [ return [
{ {
...baseQueryObject, ...baseQueryObject,
// Time series charts need to set the `is_timeseries` flag to true groupby: formData.series,
is_timeseries: true, is_timeseries: true,
post_processing: [ post_processing: [
{ {

View File

@ -22,12 +22,6 @@ import controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: 'ECharts Timeseries',
name: t('ECharts Timeseries'),
thumbnail,
});
export default class EchartsTimeseriesChartPlugin extends ChartPlugin { export default class EchartsTimeseriesChartPlugin extends ChartPlugin {
/** /**
* The constructor is used to pass relevant metadata and callbacks that get * The constructor is used to pass relevant metadata and callbacks that get
@ -44,7 +38,11 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin {
buildQuery, buildQuery,
controlPanel, controlPanel,
loadChart: () => import('./EchartsTimeseries'), loadChart: () => import('./EchartsTimeseries'),
metadata, metadata: new ChartMetadata({
description: 'ECharts Timeseries',
name: t('ECharts Timeseries'),
thumbnail,
}),
transformProps, transformProps,
}); });
} }

View File

@ -52,7 +52,6 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const rebasedData = rebaseTimeseriesDatum(queryData.data || []); const rebasedData = rebaseTimeseriesDatum(queryData.data || []);
const rawSeries = extractTimeseriesSeries(rebasedData); const rawSeries = extractTimeseriesSeries(rebasedData);

View File

@ -17,9 +17,8 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { TimeseriesDataRecord } from '@superset-ui/core'; import { DataRecord, TimeseriesDataRecord } from '@superset-ui/core';
// eslint-disable-next-line import/prefer-default-export
export function extractTimeseriesSeries( export function extractTimeseriesSeries(
data: TimeseriesDataRecord[], data: TimeseriesDataRecord[],
): echarts.EChartOption.Series[] { ): echarts.EChartOption.Series[] {
@ -39,3 +38,8 @@ export function extractTimeseriesSeries(
data: rows.map(datum => [datum.__timestamp, datum[key]]), data: rows.map(datum => [datum.__timestamp, datum[key]]),
})); }));
} }
export function extractGroupbyLabel(datum: DataRecord, groupby: string[]): string {
// TODO: apply formatting to dates and numbers
return groupby.map(val => `${datum[val]}`).join(', ');
}

View File

@ -0,0 +1,36 @@
/**
* 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 buildQuery from '../../src/Pie/buildQuery';
describe('Pie buildQuery', () => {
const formData = {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'foo',
groupby: ['bar'],
viz_type: 'my_chart',
};
it('should build groupby with series in form data', () => {
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual([{ label: 'foo' }]);
expect(query.groupby).toEqual(['bar']);
});
});

View File

@ -0,0 +1,87 @@
/**
* 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 'babel-polyfill';
import { ChartProps, getNumberFormatter } from '@superset-ui/core';
import transformProps, { formatPieLabel } from '../../src/Pie/transformProps';
describe('Pie tranformProps', () => {
const formData = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum__num',
groupby: ['foo', 'bar'],
};
const chartProps = new ChartProps({
formData,
width: 800,
height: 600,
queryData: {
data: [
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
],
},
});
it('should tranform chart props for viz', () => {
expect(transformProps(chartProps)).toEqual(
expect.objectContaining({
width: 800,
height: 600,
echartOptions: expect.objectContaining({
series: [
expect.objectContaining({
avoidLabelOverlap: true,
data: expect.arrayContaining([
expect.objectContaining({
name: 'Arnold, 2',
value: 2.5,
}),
expect.objectContaining({
name: 'Sylvester, 1',
value: 10,
}),
]),
}),
],
}),
}),
);
});
});
describe('formatPieLabel', () => {
it('should generate a valid pie chart label', () => {
const numberFormatter = getNumberFormatter();
const params = { name: 'My Label', value: 1234, percent: 12.34 };
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'key' })).toEqual('My Label');
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'value' })).toEqual('1.23k');
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'percent' })).toEqual('12.34%');
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'key_value' })).toEqual(
'My Label: 1.23k',
);
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'key_percent' })).toEqual(
'My Label: 12.34%',
);
expect(formatPieLabel({ params, numberFormatter, pieLabelType: 'key_value_percent' })).toEqual(
'My Label: 1.23k (12.34%)',
);
});
});

View File

@ -18,18 +18,19 @@
*/ */
import buildQuery from '../../src/Timeseries/buildQuery'; import buildQuery from '../../src/Timeseries/buildQuery';
describe('EchartsTimeseries buildQuery', () => { describe('Timeseries buildQuery', () => {
const formData = { const formData = {
datasource: '5__table', datasource: '5__table',
granularity_sqla: 'ds', granularity_sqla: 'ds',
series: 'foo', series: ['foo'],
metrics: ['bar', 'baz'],
viz_type: 'my_chart', viz_type: 'my_chart',
queryFields: { series: 'groupby' },
}; };
it('should build groupby with series in form data', () => { it('should build groupby with series in form data', () => {
const queryContext = buildQuery(formData); const queryContext = buildQuery(formData);
const [query] = queryContext.queries; const [query] = queryContext.queries;
expect(query.groupby).toEqual(['foo']); expect(query.groupby).toEqual(['foo']);
expect(query.metrics).toEqual([{ label: 'bar' }, { label: 'baz' }]);
}); });
}); });

View File

@ -26,14 +26,17 @@ describe('EchartsTimeseries tranformProps', () => {
datasource: '3__table', datasource: '3__table',
granularity_sqla: 'ds', granularity_sqla: 'ds',
metric: 'sum__num', metric: 'sum__num',
series: 'name', series: ['foo', 'bar'],
}; };
const chartProps = new ChartProps({ const chartProps = new ChartProps({
formData, formData,
width: 800, width: 800,
height: 600, height: 600,
queryData: { queryData: {
data: [{ sum__num: 1, __timestamp: 599616000000 }], data: [
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
],
}, },
}); });
@ -43,10 +46,23 @@ describe('EchartsTimeseries tranformProps', () => {
width: 800, width: 800,
height: 600, height: 600,
echartOptions: expect.objectContaining({ echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York'],
}),
series: expect.arrayContaining([ series: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
data: [[new Date(599616000000), 1]], data: [
id: 'sum__num', [new Date(599616000000), 1],
[new Date(599916000000), 3],
],
name: 'San Francisco',
}),
expect.objectContaining({
data: [
[new Date(599616000000), 2],
[new Date(599916000000), 4],
],
name: 'New York',
}), }),
]), ]),
}), }),

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { extractTimeseriesSeries } from '../../src/utils/series'; import { extractGroupbyLabel, extractTimeseriesSeries } from '../../src/utils/series';
describe('extractTimeseriesSeries', () => { describe('extractTimeseriesSeries', () => {
it('should generate a valid ECharts timeseries series object', () => { it('should generate a valid ECharts timeseries series object', () => {
@ -57,3 +57,23 @@ describe('extractTimeseriesSeries', () => {
]); ]);
}); });
}); });
describe('extractGroupbyLabel', () => {
it('should join together multiple groupby labels', () => {
expect(extractGroupbyLabel({ a: 'abc', b: 'qwerty' }, ['a', 'b'])).toEqual('abc, qwerty');
});
it('should handle a single groupby', () => {
expect(extractGroupbyLabel({ xyz: 'qqq' }, ['xyz'])).toEqual('qqq');
});
it('should handle mixed types', () => {
expect(
extractGroupbyLabel({ strcol: 'abc', intcol: 123, floatcol: 0.123 }, [
'strcol',
'intcol',
'floatcol',
]),
).toEqual('abc, 123, 0.123');
});
});