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'),
expanded: true,
controlSetRows: [['metric'], ['adhoc_filters'], ['groupby'], ['row_limit']],
controlSetRows: [['groupby'], ['metric'], ['adhoc_filters'], ['row_limit']],
},
{
label: t('Chart Options'),

View File

@ -17,9 +17,9 @@
* under the License.
*/
import React from 'react';
import { EchartsPieProps } from './types';
import { EchartsProps } from '../types';
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} />;
}

View File

@ -17,19 +17,98 @@
* under the License.
*/
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 = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [['groupby'], ['metrics'], ['adhoc_filters'], ['row_limit', null]],
controlSetRows: [['groupby'], ['metric'], ['adhoc_filters'], ['row_limit', null]],
},
{
label: t('Chart Options'),
expanded: true,
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',
@ -40,10 +119,12 @@ const config: ControlPanelConfig = {
min: 10,
max: 100,
step: 1,
default: 70,
default: 80,
description: t('Outer edge of Pie chart'),
},
},
],
[
{
name: 'innerRadius',
config: {
@ -53,7 +134,7 @@ const config: ControlPanelConfig = {
min: 0,
max: 100,
step: 1,
default: 50,
default: 40,
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 thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: 'Pie chart using ECharts',
name: t('EchartsPie'),
thumbnail,
});
export default class EchartsPieChartPlugin extends ChartPlugin {
/**
* The constructor is used to pass relevant metadata and callbacks that get
@ -44,7 +38,11 @@ export default class EchartsPieChartPlugin extends ChartPlugin {
buildQuery,
controlPanel,
loadChart: () => import('./EchartsPie'),
metadata,
metadata: new ChartMetadata({
description: 'Pie chart using ECharts',
name: t('ECharts Pie'),
thumbnail,
}),
transformProps,
});
}

View File

@ -16,60 +16,126 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, DataRecord } from '@superset-ui/core';
import { EchartsPieProps } from './types';
import {
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 {
/*
TODO:
- add support for multiple groupby (requires post transform op)
- add support for ad-hoc metrics (currently only supports datasource metrics)
- add support for superset colors
- add support for control values in legacy pie chart
*/
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
export function formatPieLabel({
params,
pieLabelType,
numberFormatter,
}: {
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 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 name = extractGroupbyLabel(datum, groupby);
return {
value: datum[metrics[0]],
name: datum[groupby[0]],
value: datum[metricLabel],
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: {
confine: true,
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 10,
data: keys,
formatter: params => {
return formatPieLabel({
params: params as echarts.EChartOption.Tooltip.Format,
numberFormatter,
pieLabelType: 'key_value_percent',
});
},
},
legend: showLegend
? {
orient: 'horizontal',
left: 10,
data: keys,
}
: undefined,
series: [
{
type: 'pie',
radius: [`${innerRadius}%`, `${outerRadius}%`],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center',
},
radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
avoidLabelOverlap: true,
labelLine: labelsOutside ? { show: true } : { show: false },
label: labelsOutside
? {
formatter,
position: 'outer',
show: showLabels,
alignTo: 'none',
bleedMargin: 5,
}
: {
formatter,
position: 'inner',
show: showLabels,
},
emphasis: {
label: {
show: true,
fontSize: '30',
fontSize: 30,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
// @ts-ignore
data: transformedData,
},
],
@ -78,7 +144,6 @@ export default function transformProps(chartProps: ChartProps): EchartsPieProps
return {
width,
height,
// @ts-ignore
echartOptions,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,8 @@
* specific language governing permissions and limitations
* 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(
data: TimeseriesDataRecord[],
): echarts.EChartOption.Series[] {
@ -39,3 +38,8 @@ export function extractTimeseriesSeries(
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';
describe('EchartsTimeseries buildQuery', () => {
describe('Timeseries buildQuery', () => {
const formData = {
datasource: '5__table',
granularity_sqla: 'ds',
series: 'foo',
series: ['foo'],
metrics: ['bar', 'baz'],
viz_type: 'my_chart',
queryFields: { series: 'groupby' },
};
it('should build groupby with series in form data', () => {
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
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',
granularity_sqla: 'ds',
metric: 'sum__num',
series: 'name',
series: ['foo', 'bar'],
};
const chartProps = new ChartProps({
formData,
width: 800,
height: 600,
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,
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York'],
}),
series: expect.arrayContaining([
expect.objectContaining({
data: [[new Date(599616000000), 1]],
id: 'sum__num',
data: [
[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
* under the License.
*/
import { extractTimeseriesSeries } from '../../src/utils/series';
import { extractGroupbyLabel, extractTimeseriesSeries } from '../../src/utils/series';
describe('extractTimeseriesSeries', () => {
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');
});
});