feat(plugin-chart-echarts): support horizontal bar chart (#19918)

* feat(plugin-chart-echarts): support horizontal bar chart

* Update superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts

Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>

* Update superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts

Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>

* Update superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx

Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>

* improve controlpanel

* default value

* fix ut

Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
This commit is contained in:
Stephen Liu 2022-05-16 21:48:36 +08:00 committed by GitHub
parent d5802f7896
commit 9854d2d0e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 322 additions and 138 deletions

View File

@ -21,8 +21,10 @@ import { t } from '@superset-ui/core';
import { ControlPanelSectionConfig } from '../types'; import { ControlPanelSectionConfig } from '../types';
import { formatSelectOptions } from '../utils'; import { formatSelectOptions } from '../utils';
const TITLE_MARGIN_OPTIONS: number[] = [15, 30, 50, 75, 100, 125, 150, 200]; export const TITLE_MARGIN_OPTIONS: number[] = [
const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top']; 15, 30, 50, 75, 100, 125, 150, 200,
];
export const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top'];
export const titleControls: ControlPanelSectionConfig = { export const titleControls: ControlPanelSectionConfig = {
label: t('Chart Title'), label: t('Chart Title'),
tabOverride: 'customize', tabOverride: 'customize',

View File

@ -21,8 +21,11 @@ import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core';
import { import {
ControlPanelConfig, ControlPanelConfig,
ControlPanelsContainerProps, ControlPanelsContainerProps,
ControlSetRow,
ControlStateMapping,
D3_TIME_FORMAT_DOCS, D3_TIME_FORMAT_DOCS,
emitFilterControl, emitFilterControl,
formatSelectOptions,
sections, sections,
sharedControls, sharedControls,
} from '@superset-ui/chart-controls'; } from '@superset-ui/chart-controls';
@ -30,6 +33,7 @@ import {
import { import {
DEFAULT_FORM_DATA, DEFAULT_FORM_DATA,
EchartsTimeseriesContributionType, EchartsTimeseriesContributionType,
OrientationType,
} from '../../types'; } from '../../types';
import { import {
legendSection, legendSection,
@ -49,7 +53,217 @@ const {
yAxisBounds, yAxisBounds,
zoomable, zoomable,
xAxisLabelRotation, xAxisLabelRotation,
orientation,
} = DEFAULT_FORM_DATA; } = DEFAULT_FORM_DATA;
function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
const isXAxis = axis === 'x';
const isVertical = (controls: ControlStateMapping) =>
Boolean(controls?.orientation.value === OrientationType.vertical);
const isHorizental = (controls: ControlStateMapping) =>
Boolean(controls?.orientation.value === OrientationType.horizontal);
return [
[
{
name: 'x_axis_title',
config: {
type: 'TextControl',
label: t('Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizental(controls),
},
},
],
[
{
name: 'x_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizental(controls),
},
},
],
[
{
name: 'y_axis_title',
config: {
type: 'TextControl',
label: t('Axis Title'),
renderTrigger: true,
default: '',
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'y_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'y_axis_title_position',
config: {
type: 'SelectControl',
freeForm: true,
clearable: false,
label: t('AXIS TITLE POSITION'),
renderTrigger: true,
default: sections.TITLE_POSITION_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_POSITION_OPTIONS),
description: t('Changing this control takes effect instantly'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
];
}
function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] {
const isXAxis = axis === 'x';
const isVertical = (controls: ControlStateMapping) =>
Boolean(controls?.orientation.value === OrientationType.vertical);
const isHorizental = (controls: ControlStateMapping) =>
Boolean(controls?.orientation.value === OrientationType.horizontal);
return [
[
{
name: 'x_axis_time_format',
config: {
...sharedControls.x_axis_time_format,
default: 'smart_date',
description: `${D3_TIME_FORMAT_DOCS}. ${t(
'When using other than adaptive formatting, labels may overlap.',
)}`,
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizental(controls),
},
},
],
[
{
name: 'xAxisLabelRotation',
config: {
type: 'SelectControl',
freeForm: true,
clearable: false,
label: t('Rotate axis label'),
choices: [
[0, '0°'],
[45, '45°'],
],
default: xAxisLabelRotation,
renderTrigger: true,
description: t(
'Input field supports custom rotation. e.g. 30 for 30°',
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isVertical(controls) : isHorizental(controls),
},
},
],
[
{
name: 'y_axis_format',
config: {
...sharedControls.y_axis_format,
label: t('Axis Format'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'logAxis',
config: {
type: 'CheckboxControl',
label: t('Logarithmic axis'),
renderTrigger: true,
default: logAxis,
description: t('Logarithmic axis'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'minorSplitLine',
config: {
type: 'CheckboxControl',
label: t('Minor Split Line'),
renderTrigger: true,
default: minorSplitLine,
description: t('Draw split lines for minor axis ticks'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'truncateYAxis',
config: {
type: 'CheckboxControl',
label: t('Truncate Axis'),
default: truncateYAxis,
renderTrigger: true,
description: t('Its not recommended to truncate axis in Bar chart.'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isXAxis ? isHorizental(controls) : isVertical(controls),
},
},
],
[
{
name: 'y_axis_bounds',
config: {
type: 'BoundsControl',
label: t('Axis Bounds'),
renderTrigger: true,
default: yAxisBounds,
description: t(
'Bounds for the axis. When left empty, the bounds are ' +
'dynamically defined based on the min/max of the data. Note that ' +
"this feature will only expand the axis range. It won't " +
"narrow the data's extent.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.truncateYAxis?.value) &&
(isXAxis ? isHorizental(controls) : isVertical(controls)),
},
},
],
];
}
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
controlPanelSections: [ controlPanelSections: [
sections.legacyTimeseriesTime, sections.legacyTimeseriesTime,
@ -87,7 +301,39 @@ const config: ControlPanelConfig = {
sections.advancedAnalyticsControls, sections.advancedAnalyticsControls,
sections.annotationsAndLayersControls, sections.annotationsAndLayersControls,
sections.forecastIntervalControls, sections.forecastIntervalControls,
sections.titleControls, {
label: t('Chart Orientation'),
expanded: true,
controlSetRows: [
[
{
name: 'orientation',
config: {
type: 'RadioButtonControl',
renderTrigger: true,
label: t('Bar orientation'),
default: orientation,
options: [
[OrientationType.vertical, t('Vertical')],
[OrientationType.horizontal, t('Horizontal')],
],
description: t('Orientation of bar chart'),
},
},
],
],
},
{
label: t('Chart Title'),
tabOverride: 'customize',
expanded: true,
controlSetRows: [
[<div className="section-header">{t('X Axis')}</div>],
...createAxisTitleControl('x'),
[<div className="section-header">{t('Y Axis')}</div>],
...createAxisTitleControl('y'),
],
},
{ {
label: t('Chart Options'), label: t('Chart Options'),
expanded: true, expanded: true,
@ -140,101 +386,10 @@ const config: ControlPanelConfig = {
], ],
...legendSection, ...legendSection,
[<div className="section-header">{t('X Axis')}</div>], [<div className="section-header">{t('X Axis')}</div>],
[ ...createAxisControl('x'),
{
name: 'x_axis_time_format',
config: {
...sharedControls.x_axis_time_format,
default: 'smart_date',
description: `${D3_TIME_FORMAT_DOCS}. ${t(
'When using other than adaptive formatting, labels may overlap.',
)}`,
},
},
],
[
{
name: 'xAxisLabelRotation',
config: {
type: 'SelectControl',
freeForm: true,
clearable: false,
label: t('Rotate x axis label'),
choices: [
[0, '0°'],
[45, '45°'],
],
default: xAxisLabelRotation,
renderTrigger: true,
description: t(
'Input field supports custom rotation. e.g. 30 for 30°',
),
},
},
],
// eslint-disable-next-line react/jsx-key
...richTooltipSection, ...richTooltipSection,
// eslint-disable-next-line react/jsx-key
[<div className="section-header">{t('Y Axis')}</div>], [<div className="section-header">{t('Y Axis')}</div>],
...createAxisControl('y'),
['y_axis_format'],
[
{
name: 'logAxis',
config: {
type: 'CheckboxControl',
label: t('Logarithmic y-axis'),
renderTrigger: true,
default: logAxis,
description: t('Logarithmic y-axis'),
},
},
],
[
{
name: 'minorSplitLine',
config: {
type: 'CheckboxControl',
label: t('Minor Split Line'),
renderTrigger: true,
default: minorSplitLine,
description: t('Draw split lines for minor y-axis ticks'),
},
},
],
[
{
name: 'truncateYAxis',
config: {
type: 'CheckboxControl',
label: t('Truncate Y Axis'),
default: truncateYAxis,
renderTrigger: true,
description: t(
'Its not recommended to truncate y-axis in Bar chart.',
),
},
},
],
[
{
name: 'y_axis_bounds',
config: {
type: 'BoundsControl',
label: t('Y Axis Bounds'),
renderTrigger: true,
default: yAxisBounds,
description: t(
'Bounds for the Y-axis. When left empty, the bounds are ' +
'dynamically defined based on the min/max of the data. Note that ' +
"this feature will only expand the axis range. It won't " +
"narrow the data's extent.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.truncateYAxis?.value),
},
},
],
], ],
}, },
], ],

View File

@ -39,6 +39,7 @@ import {
EchartsTimeseriesFormData, EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType, EchartsTimeseriesSeriesType,
TimeseriesChartTransformedProps, TimeseriesChartTransformedProps,
OrientationType,
} from './types'; } from './types';
import { ForecastSeriesEnum, ForecastValue } from '../types'; import { ForecastSeriesEnum, ForecastValue } from '../types';
import { parseYAxisBound } from '../utils/controls'; import { parseYAxisBound } from '../utils/controls';
@ -138,16 +139,19 @@ export default function transformProps(
yAxisTitlePosition, yAxisTitlePosition,
sliceId, sliceId,
timeGrainSqla, timeGrainSqla,
orientation,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseForecastDatum(data, verboseMap); const rebasedData = rebaseForecastDatum(data, verboseMap);
const xAxisCol = const xAxisCol =
verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS); verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS);
const isHorizontal = orientation === OrientationType.horizontal;
const rawSeries = extractSeries(rebasedData, { const rawSeries = extractSeries(rebasedData, {
fillNeighborValue: stack && !forecastEnabled ? 0 : undefined, fillNeighborValue: stack && !forecastEnabled ? 0 : undefined,
xAxis: xAxisCol, xAxis: xAxisCol,
removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter, removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter,
isHorizontal,
}); });
const seriesContexts = extractForecastSeriesContexts( const seriesContexts = extractForecastSeriesContexts(
Object.values(rawSeries).map(series => series.name as string), Object.values(rawSeries).map(series => series.name as string),
@ -213,6 +217,7 @@ export default function transformProps(
thresholdValues, thresholdValues,
richTooltip, richTooltip,
sliceId, sliceId,
isHorizontal,
}); });
if (transformedSeries) series.push(transformedSeries); if (transformedSeries) series.push(transformedSeries);
}); });
@ -325,57 +330,66 @@ export default function transformProps(
.map(entry => entry.name || '') .map(entry => entry.name || '')
.concat(extractAnnotationLabels(annotationLayers, annotationData)); .concat(extractAnnotationLabels(annotationLayers, annotationData));
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
hideOverlap: true,
formatter: xAxisFormatter,
rotate: xAxisLabelRotation,
},
minInterval:
xAxisType === 'time' && timeGrainSqla
? TimeGrainToTimestamp[timeGrainSqla]
: 0,
};
let yAxis: any = {
...defaultYAxis,
type: logAxis ? 'log' : 'value',
min,
max,
minorTick: { show: true },
minorSplitLine: { show: minorSplitLine },
axisLabel: { formatter },
scale: truncateYAxis,
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
};
if (isHorizontal) {
[xAxis, yAxis] = [yAxis, xAxis];
[padding.bottom, padding.left] = [padding.left, padding.bottom];
}
const echartOptions: EChartsCoreOption = { const echartOptions: EChartsCoreOption = {
useUTC: true, useUTC: true,
grid: { grid: {
...defaultGrid, ...defaultGrid,
...padding, ...padding,
}, },
xAxis: { xAxis,
type: xAxisType, yAxis,
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
hideOverlap: true,
formatter: xAxisFormatter,
rotate: xAxisLabelRotation,
},
minInterval:
xAxisType === 'time' && timeGrainSqla
? TimeGrainToTimestamp[timeGrainSqla]
: 0,
},
yAxis: {
...defaultYAxis,
type: logAxis ? 'log' : 'value',
min,
max,
minorTick: { show: true },
minorSplitLine: { show: minorSplitLine },
axisLabel: { formatter },
scale: truncateYAxis,
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
},
tooltip: { tooltip: {
...defaultTooltip, ...defaultTooltip,
appendToBody: true, appendToBody: true,
trigger: richTooltip ? 'axis' : 'item', trigger: richTooltip ? 'axis' : 'item',
formatter: (params: any) => { formatter: (params: any) => {
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
const xValue: number = richTooltip const xValue: number = richTooltip
? params[0].value[0] ? params[0].value[xIndex]
: params.value[0]; : params.value[xIndex];
const forecastValue: any[] = richTooltip ? params : [params]; const forecastValue: any[] = richTooltip ? params : [params];
if (richTooltip && tooltipSortByMetric) { if (richTooltip && tooltipSortByMetric) {
forecastValue.sort((a, b) => b.data[1] - a.data[1]); forecastValue.sort((a, b) => b.data[yIndex] - a.data[yIndex]);
} }
const rows: Array<string> = [`${tooltipFormatter(xValue)}`]; const rows: Array<string> = [`${tooltipFormatter(xValue)}`];
const forecastValues: Record<string, ForecastValue> = const forecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(forecastValue); extractForecastValuesFromTooltipParams(forecastValue, isHorizontal);
Object.keys(forecastValues).forEach(key => { Object.keys(forecastValues).forEach(key => {
const value = forecastValues[key]; const value = forecastValues[key];

View File

@ -86,6 +86,7 @@ export function transformSeries(
richTooltip?: boolean; richTooltip?: boolean;
seriesKey?: OptionName; seriesKey?: OptionName;
sliceId?: number; sliceId?: number;
isHorizontal?: boolean;
}, },
): SeriesOption | undefined { ): SeriesOption | undefined {
const { name } = series; const { name } = series;
@ -108,6 +109,7 @@ export function transformSeries(
richTooltip, richTooltip,
seriesKey, seriesKey,
sliceId, sliceId,
isHorizontal = false,
} = opts; } = opts;
const contexts = seriesContexts[name || ''] || []; const contexts = seriesContexts[name || ''] || [];
const hasForecast = const hasForecast =
@ -217,14 +219,10 @@ export function transformSeries(
symbolSize: markerSize, symbolSize: markerSize,
label: { label: {
show: !!showValue, show: !!showValue,
position: 'top', position: isHorizontal ? 'right' : 'top',
formatter: (params: any) => { formatter: (params: any) => {
const { const { value, dataIndex, seriesIndex, seriesName } = params;
value: [, numericValue], const numericValue = isHorizontal ? value[0] : value[1];
dataIndex,
seriesIndex,
seriesName,
} = params;
const isSelectedLegend = currentSeries.legend === seriesName; const isSelectedLegend = currentSeries.legend === seriesName;
if (!formatter) return numericValue; if (!formatter) return numericValue;
if (!stack || isSelectedLegend) return formatter(numericValue); if (!stack || isSelectedLegend) return formatter(numericValue);

View File

@ -38,6 +38,11 @@ export enum EchartsTimeseriesContributionType {
Column = 'column', Column = 'column',
} }
export enum OrientationType {
vertical = 'vertical',
horizontal = 'horizontal',
}
export enum EchartsTimeseriesSeriesType { export enum EchartsTimeseriesSeriesType {
Line = 'line', Line = 'line',
Scatter = 'scatter', Scatter = 'scatter',
@ -82,6 +87,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
showValue: boolean; showValue: boolean;
onlyTotal: boolean; onlyTotal: boolean;
percentageThreshold: number; percentageThreshold: number;
orientation?: OrientationType;
} & EchartsLegendFormData & } & EchartsLegendFormData &
EchartsTitleFormData; EchartsTitleFormData;
@ -119,6 +125,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
showValue: false, showValue: false,
onlyTotal: false, onlyTotal: false,
percentageThreshold: 0, percentageThreshold: 0,
orientation: OrientationType.vertical,
...DEFAULT_TITLE_FORM_DATA, ...DEFAULT_TITLE_FORM_DATA,
}; };

View File

@ -53,12 +53,13 @@ export const extractForecastSeriesContexts = (
export const extractForecastValuesFromTooltipParams = ( export const extractForecastValuesFromTooltipParams = (
params: any[], params: any[],
isHorizontal = false,
): Record<string, ForecastValue> => { ): Record<string, ForecastValue> => {
const values: Record<string, ForecastValue> = {}; const values: Record<string, ForecastValue> = {};
params.forEach(param => { params.forEach(param => {
const { marker, seriesId, value } = param; const { marker, seriesId, value } = param;
const context = extractForecastSeriesContext(seriesId); const context = extractForecastSeriesContext(seriesId);
const numericValue = (value as [Date, number])[1]; const numericValue = isHorizontal ? value[0] : value[1];
if (numericValue) { if (numericValue) {
if (!(context.name in values)) if (!(context.name in values))
values[context.name] = { values[context.name] = {

View File

@ -42,9 +42,15 @@ export function extractSeries(
fillNeighborValue?: number; fillNeighborValue?: number;
xAxis?: string; xAxis?: string;
removeNulls?: boolean; removeNulls?: boolean;
isHorizontal?: boolean;
} = {}, } = {},
): SeriesOption[] { ): SeriesOption[] {
const { fillNeighborValue, xAxis = DTTM_ALIAS, removeNulls = false } = opts; const {
fillNeighborValue,
xAxis = DTTM_ALIAS,
removeNulls = false,
isHorizontal = false,
} = opts;
if (data.length === 0) return []; if (data.length === 0) return [];
const rows: DataRecord[] = data.map(datum => ({ const rows: DataRecord[] = data.map(datum => ({
...datum, ...datum,
@ -69,7 +75,8 @@ export function extractSeries(
: row[key], : row[key],
]; ];
}) })
.filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null)), .filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null))
.map(obs => (isHorizontal ? [obs[1], obs[0]] : obs)),
})); }));
} }