diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index 82ca0b585d..a1e877cf1c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -34,6 +34,7 @@ import { onlyTotalControl, showValueControl, richTooltipSection, + seriesOrderSection, } from '../../controls'; import { AreaChartExtraControlsOptions } from '../../constants'; @@ -62,6 +63,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index 509dc6c815..f69099866c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -38,6 +38,7 @@ import { import { legendSection, richTooltipSection, + seriesOrderSection, showValueSection, } from '../../../controls'; @@ -301,6 +302,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], ...showValueSection, [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx index 0ceb518b88..a2c9648ea0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx @@ -35,6 +35,7 @@ import { import { legendSection, richTooltipSection, + seriesOrderSection, showValueSection, } from '../../../controls'; @@ -64,6 +65,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index 9e36db0d3b..d8ad857129 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -34,6 +34,7 @@ import { import { legendSection, richTooltipSection, + seriesOrderSection, showValueSection, } from '../../../controls'; @@ -60,6 +61,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], ...showValueSection, [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx index bfb7671ddb..a45a790883 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx @@ -34,6 +34,7 @@ import { import { legendSection, richTooltipSection, + seriesOrderSection, showValueSectionWithoutStack, } from '../../../controls'; @@ -60,6 +61,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], ...showValueSectionWithoutStack, [ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx index 6a8e6eef17..2f65c4d151 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/controlPanel.tsx @@ -32,6 +32,7 @@ import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT } from '../constants'; import { legendSection, richTooltipSection, + seriesOrderSection, showValueSection, } from '../../controls'; @@ -60,6 +61,7 @@ const config: ControlPanelConfig = { label: t('Chart Options'), expanded: true, controlSetRows: [ + ...seriesOrderSection, ['color_scheme'], [ { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts index 1d7b871944..e0b41f9f68 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts @@ -25,6 +25,7 @@ import { } from './types'; import { DEFAULT_LEGEND_FORM_DATA, + DEFAULT_SORT_SERIES_DATA, DEFAULT_TITLE_FORM_DATA, } from '../constants'; @@ -32,6 +33,7 @@ import { export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_TITLE_FORM_DATA, + ...DEFAULT_SORT_SERIES_DATA, annotationLayers: sections.annotationLayers, area: false, forecastEnabled: sections.FORECAST_DEFAULT_DATA.forecastEnabled, @@ -63,6 +65,8 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { onlyTotal: false, percentageThreshold: 0, orientation: OrientationType.vertical, + sort_series_type: 'sum', + sort_series_ascending: false, }; export const TIME_SERIES_DESCRIPTION_TEXT: string = t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index a853c4b869..1342e860ba 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -136,6 +136,8 @@ export default function transformProps( showLegend, showValue, sliceId, + sortSeriesType, + sortSeriesAscending, timeGrainSqla, timeCompare, stack, @@ -197,6 +199,8 @@ export default function transformProps( stack, totalStackedValues, isHorizontal, + sortSeriesType, + sortSeriesAscending, }); const showValueIndexes = extractShowValueIndexes(rawSeries, { stack, @@ -418,7 +422,7 @@ export default function transformProps( forecastValue.sort((a, b) => b.data[yIndex] - a.data[yIndex]); } - const rows: Array = [`${tooltipFormatter(xValue)}`]; + const rows: string[] = []; const forecastValues: Record = extractForecastValuesFromTooltipParams(forecastValue, isHorizontal); @@ -435,6 +439,10 @@ export default function transformProps( rows.push(`${content}`); } }); + if (stack) { + rows.reverse(); + } + rows.unshift(`${tooltipFormatter(xValue)}`); return rows.join('
'); }, }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index 1c20128b67..3fc3fc999b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -20,11 +20,13 @@ import { JsonValue, t, TimeGranularity } from '@superset-ui/core'; import { ReactNode } from 'react'; import { - LegendFormData, - TitleFormData, LabelPositionEnum, + LegendFormData, LegendOrientation, LegendType, + SortSeriesData, + SortSeriesType, + TitleFormData, } from './types'; // eslint-disable-next-line import/prefer-default-export @@ -114,3 +116,8 @@ export const TOOLTIP_POINTER_MARGIN = 10; // If no satisfactory position can be found, how far away // from the edge of the window should the tooltip be kept export const TOOLTIP_OVERFLOW_MARGIN = 5; + +export const DEFAULT_SORT_SERIES_DATA: SortSeriesData = { + sort_series_type: SortSeriesType.Sum, + sort_series_ascending: false, +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index ff74cd171d..0733721091 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -24,8 +24,12 @@ import { ControlSetRow, sharedControls, } from '@superset-ui/chart-controls'; -import { DEFAULT_LEGEND_FORM_DATA } from './constants'; +import { + DEFAULT_LEGEND_FORM_DATA, + DEFAULT_SORT_SERIES_DATA, +} from './constants'; import { DEFAULT_FORM_DATA } from './Timeseries/constants'; +import { SortSeriesType } from './types'; const { legendMargin, legendOrientation, legendType, showLegend } = DEFAULT_LEGEND_FORM_DATA; @@ -212,3 +216,41 @@ export const richTooltipSection: ControlSetRow[] = [ [tooltipSortByMetricControl], [tooltipTimeFormatControl], ]; + +const sortSeriesType: ControlSetItem = { + name: 'sort_series_type', + config: { + type: 'SelectControl', + freeForm: false, + label: t('Sort Series By'), + choices: [ + [SortSeriesType.Name, t('Category name')], + [SortSeriesType.Sum, t('Total value')], + [SortSeriesType.Min, t('Minimum value')], + [SortSeriesType.Max, t('Maximum value')], + [SortSeriesType.Avg, t('Average value')], + ], + default: DEFAULT_SORT_SERIES_DATA.sort_series_type, + renderTrigger: true, + description: t( + 'Based on what should series be ordered on the chart and legend', + ), + }, +}; + +const sortSeriesAscending: ControlSetItem = { + name: 'sort_series_ascending', + config: { + type: 'CheckboxControl', + label: t('Sort Series Ascending'), + default: DEFAULT_SORT_SERIES_DATA.sort_series_ascending, + renderTrigger: true, + description: t('Sort series in ascending order'), + }, +}; + +export const seriesOrderSection: ControlSetRow[] = [ + [
{t('Series Order')}
], + [sortSeriesType], + [sortSeriesAscending], +]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index d51102439f..7408d0a112 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -167,4 +167,17 @@ export interface TreePathInfo { value: number | number[]; } +export enum SortSeriesType { + Name = 'name', + Max = 'max', + Min = 'min', + Sum = 'sum', + Avg = 'avg', +} + +export type SortSeriesData = { + sort_series_type: SortSeriesType; + sort_series_ascending: boolean; +}; + export * from './Timeseries/types'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 649dedd680..6d1396afc2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -30,12 +30,18 @@ import { AxisType, } from '@superset-ui/core'; import { format, LegendComponentOption, SeriesOption } from 'echarts'; +import { sumBy, meanBy, minBy, maxBy, orderBy } from 'lodash'; import { AreaChartExtraControlsValue, NULL_STRING, TIMESERIES_CONSTANTS, } from '../constants'; -import { LegendOrientation, LegendType, StackType } from '../types'; +import { + LegendOrientation, + LegendType, + SortSeriesType, + StackType, +} from '../types'; import { defaultLegendPadding } from '../defaults'; function isDefined(value: T | undefined | null): boolean { @@ -108,6 +114,46 @@ export function extractShowValueIndexes( return showValueIndexes; } +export function sortAndFilterSeries( + rows: DataRecord[], + xAxis: string, + extraMetricLabels: any[], + sortSeriesType?: SortSeriesType, + sortSeriesAscending?: boolean, +): string[] { + const seriesNames = Object.keys(rows[0]) + .filter(key => key !== xAxis) + .filter(key => !extraMetricLabels.includes(key)); + + let aggregator: (name: string) => { name: string; value: any }; + + switch (sortSeriesType) { + case SortSeriesType.Sum: + aggregator = name => ({ name, value: sumBy(rows, name) }); + break; + case SortSeriesType.Min: + aggregator = name => ({ name, value: minBy(rows, name)?.[name] }); + break; + case SortSeriesType.Max: + aggregator = name => ({ name, value: maxBy(rows, name)?.[name] }); + break; + case SortSeriesType.Avg: + aggregator = name => ({ name, value: meanBy(rows, name) }); + break; + default: + aggregator = name => ({ name, value: name.toLowerCase() }); + break; + } + + const sortedValues = seriesNames.map(aggregator); + + return orderBy( + sortedValues, + ['value'], + [sortSeriesAscending ? 'asc' : 'desc'], + ).map(({ name }) => name); +} + export function extractSeries( data: DataRecord[], opts: { @@ -118,6 +164,8 @@ export function extractSeries( stack?: StackType; totalStackedValues?: number[]; isHorizontal?: boolean; + sortSeriesType?: SortSeriesType; + sortSeriesAscending?: boolean; } = {}, ): SeriesOption[] { const { @@ -128,41 +176,47 @@ export function extractSeries( stack = false, totalStackedValues = [], isHorizontal = false, + sortSeriesType, + sortSeriesAscending, } = opts; if (data.length === 0) return []; const rows: DataRecord[] = data.map(datum => ({ ...datum, [xAxis]: datum[xAxis], })); + const series = sortAndFilterSeries( + rows, + xAxis, + extraMetricLabels, + sortSeriesType, + sortSeriesAscending, + ); - return Object.keys(rows[0]) - .filter(key => key !== xAxis && key !== DTTM_ALIAS) - .filter(key => !extraMetricLabels.includes(key)) - .map(key => ({ - id: key, - name: key, - data: rows - .map((row, idx) => { - const isNextToDefinedValue = - isDefined(rows[idx - 1]?.[key]) || isDefined(rows[idx + 1]?.[key]); - const isFillNeighborValue = - !isDefined(row[key]) && - isNextToDefinedValue && - fillNeighborValue !== undefined; - let value: DataRecordValue | undefined = row[key]; - if (isFillNeighborValue) { - value = fillNeighborValue; - } else if ( - stack === AreaChartExtraControlsValue.Expand && - totalStackedValues.length > 0 - ) { - value = ((value || 0) as number) / totalStackedValues[idx]; - } - return [row[xAxis], value]; - }) - .filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null)) - .map(obs => (isHorizontal ? [obs[1], obs[0]] : obs)), - })); + return series.map(name => ({ + id: name, + name, + data: rows + .map((row, idx) => { + const isNextToDefinedValue = + isDefined(rows[idx - 1]?.[name]) || isDefined(rows[idx + 1]?.[name]); + const isFillNeighborValue = + !isDefined(row[name]) && + isNextToDefinedValue && + fillNeighborValue !== undefined; + let value: DataRecordValue | undefined = row[name]; + if (isFillNeighborValue) { + value = fillNeighborValue; + } else if ( + stack === AreaChartExtraControlsValue.Expand && + totalStackedValues.length > 0 + ) { + value = ((value || 0) as number) / totalStackedValues[idx]; + } + return [row[xAxis], value]; + }) + .filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null)) + .map(obs => (isHorizontal ? [obs[1], obs[0]] : obs)), + })); } export function formatSeriesName( diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 3bd949d8ad..51b8b72f06 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -16,22 +16,66 @@ * specific language governing permissions and limitations * under the License. */ -import { getNumberFormatter, getTimeFormatter } from '@superset-ui/core'; +import { + DataRecord, + getNumberFormatter, + getTimeFormatter, +} from '@superset-ui/core'; import { dedupSeries, extractGroupbyLabel, extractSeries, + extractShowValueIndexes, formatSeriesName, getChartPadding, getLegendProps, - sanitizeHtml, - extractShowValueIndexes, getOverMaxHiddenFormatter, + sanitizeHtml, + sortAndFilterSeries, } from '../../src/utils/series'; -import { LegendOrientation, LegendType } from '../../src/types'; +import { LegendOrientation, LegendType, SortSeriesType } from '../../src/types'; import { defaultLegendPadding } from '../../src/defaults'; import { NULL_STRING } from '../../src/constants'; +test('sortAndFilterSeries', () => { + const data: DataRecord[] = [ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + ]; + + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Min, true), + ).toEqual(['y', 'x', 'z']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Min, false), + ).toEqual(['z', 'x', 'y']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Max, true), + ).toEqual(['x', 'z', 'y']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Max, false), + ).toEqual(['y', 'z', 'x']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Avg, true), + ).toEqual(['x', 'y', 'z']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Avg, false), + ).toEqual(['z', 'y', 'x']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Sum, true), + ).toEqual(['x', 'y', 'z']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Sum, false), + ).toEqual(['z', 'y', 'x']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Name, true), + ).toEqual(['x', 'y', 'z']); + expect( + sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Name, false), + ).toEqual(['z', 'y', 'x']); +}); + describe('extractSeries', () => { it('should generate a valid ECharts timeseries series object', () => { const data = [