mirror of
https://github.com/apache/superset.git
synced 2024-09-16 10:39:55 -04:00
feat(plugin-chart-echarts): add series sorting (#23392)
This commit is contained in:
parent
da3791ad3d
commit
0c454c6442
@ -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'],
|
||||
[
|
||||
{
|
||||
|
@ -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,
|
||||
[
|
||||
|
@ -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'],
|
||||
[
|
||||
{
|
||||
|
@ -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,
|
||||
[
|
||||
|
@ -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,
|
||||
[
|
||||
|
@ -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'],
|
||||
[
|
||||
{
|
||||
|
@ -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(
|
||||
|
@ -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 />');
|
||||
},
|
||||
},
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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],
|
||||
];
|
||||
|
@ -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';
|
||||
|
@ -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(
|
||||
|
@ -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 = [
|
||||
|
Loading…
Reference in New Issue
Block a user