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 { formatSelectOptions } from '../utils';
const TITLE_MARGIN_OPTIONS: number[] = [15, 30, 50, 75, 100, 125, 150, 200];
const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top'];
export const TITLE_MARGIN_OPTIONS: number[] = [
15, 30, 50, 75, 100, 125, 150, 200,
];
export const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top'];
export const titleControls: ControlPanelSectionConfig = {
label: t('Chart Title'),
tabOverride: 'customize',

View File

@ -21,8 +21,11 @@ import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlPanelsContainerProps,
ControlSetRow,
ControlStateMapping,
D3_TIME_FORMAT_DOCS,
emitFilterControl,
formatSelectOptions,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
@ -30,6 +33,7 @@ import {
import {
DEFAULT_FORM_DATA,
EchartsTimeseriesContributionType,
OrientationType,
} from '../../types';
import {
legendSection,
@ -49,7 +53,217 @@ const {
yAxisBounds,
zoomable,
xAxisLabelRotation,
orientation,
} = 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 = {
controlPanelSections: [
sections.legacyTimeseriesTime,
@ -87,7 +301,39 @@ const config: ControlPanelConfig = {
sections.advancedAnalyticsControls,
sections.annotationsAndLayersControls,
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'),
expanded: true,
@ -140,101 +386,10 @@ const config: ControlPanelConfig = {
],
...legendSection,
[<div className="section-header">{t('X Axis')}</div>],
[
{
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
...createAxisControl('x'),
...richTooltipSection,
// eslint-disable-next-line react/jsx-key
[<div className="section-header">{t('Y Axis')}</div>],
['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),
},
},
],
...createAxisControl('y'),
],
},
],

View File

@ -39,6 +39,7 @@ import {
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
TimeseriesChartTransformedProps,
OrientationType,
} from './types';
import { ForecastSeriesEnum, ForecastValue } from '../types';
import { parseYAxisBound } from '../utils/controls';
@ -138,16 +139,19 @@ export default function transformProps(
yAxisTitlePosition,
sliceId,
timeGrainSqla,
orientation,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseForecastDatum(data, verboseMap);
const xAxisCol =
verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS);
const isHorizontal = orientation === OrientationType.horizontal;
const rawSeries = extractSeries(rebasedData, {
fillNeighborValue: stack && !forecastEnabled ? 0 : undefined,
xAxis: xAxisCol,
removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter,
isHorizontal,
});
const seriesContexts = extractForecastSeriesContexts(
Object.values(rawSeries).map(series => series.name as string),
@ -213,6 +217,7 @@ export default function transformProps(
thresholdValues,
richTooltip,
sliceId,
isHorizontal,
});
if (transformedSeries) series.push(transformedSeries);
});
@ -325,57 +330,66 @@ export default function transformProps(
.map(entry => entry.name || '')
.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 = {
useUTC: true,
grid: {
...defaultGrid,
...padding,
},
xAxis: {
type: xAxisType,
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',
},
xAxis,
yAxis,
tooltip: {
...defaultTooltip,
appendToBody: true,
trigger: richTooltip ? 'axis' : 'item',
formatter: (params: any) => {
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
const xValue: number = richTooltip
? params[0].value[0]
: params.value[0];
? params[0].value[xIndex]
: params.value[xIndex];
const forecastValue: any[] = richTooltip ? params : [params];
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 forecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(forecastValue);
extractForecastValuesFromTooltipParams(forecastValue, isHorizontal);
Object.keys(forecastValues).forEach(key => {
const value = forecastValues[key];

View File

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

View File

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

View File

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

View File

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