feat(plugin-chart-echarts): add series sorting (#23392)

This commit is contained in:
Ville Brofeldt 2023-03-16 16:26:01 +02:00 committed by GitHub
parent da3791ad3d
commit 0c454c6442
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 221 additions and 37 deletions

View File

@ -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'],
[
{

View File

@ -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,
[

View File

@ -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'],
[
{

View File

@ -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,
[

View File

@ -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,
[

View File

@ -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'],
[
{

View File

@ -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(

View File

@ -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<string> = [`${tooltipFormatter(xValue)}`];
const rows: string[] = [];
const forecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(forecastValue, isHorizontal);
@ -435,6 +439,10 @@ export default function transformProps(
rows.push(`<span style="opacity: 0.7">${content}</span>`);
}
});
if (stack) {
rows.reverse();
}
rows.unshift(`${tooltipFormatter(xValue)}`);
return rows.join('<br />');
},
},

View File

@ -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,
};

View File

@ -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[] = [
[<div className="section-header">{t('Series Order')}</div>],
[sortSeriesType],
[sortSeriesAscending],
];

View File

@ -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';

View File

@ -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<T>(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(

View File

@ -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 = [