superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
/* eslint-disable no-underscore-dangle */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
AxisType,
ChartDataResponseResult,
DataRecord,
DataRecordValue,
DTTM_ALIAS,
ensureIsArray,
GenericDataType,
LegendState,
normalizeTimestamp,
NumberFormats,
NumberFormatter,
SupersetTheme,
TimeFormatter,
ValueFormatter,
} from '@superset-ui/core';
import { SortSeriesType } from '@superset-ui/chart-controls';
import { format, LegendComponentOption, SeriesOption } from 'echarts';
import { isEmpty, maxBy, meanBy, minBy, orderBy, sumBy } from 'lodash';
import {
NULL_STRING,
StackControlsValue,
TIMESERIES_CONSTANTS,
} from '../constants';
import {
EchartsTimeseriesSeriesType,
LegendOrientation,
LegendType,
StackType,
} from '../types';
import { defaultLegendPadding } from '../defaults';
function isDefined<T>(value: T | undefined | null): boolean {
return value !== undefined && value !== null;
}
export function extractDataTotalValues(
data: DataRecord[],
opts: {
stack: StackType;
percentageThreshold: number;
xAxisCol: string;
legendState?: LegendState;
},
): {
totalStackedValues: number[];
thresholdValues: number[];
} {
const totalStackedValues: number[] = [];
const thresholdValues: number[] = [];
const { stack, percentageThreshold, xAxisCol, legendState } = opts;
if (stack) {
data.forEach(datum => {
const values = Object.keys(datum).reduce((prev, curr) => {
if (curr === xAxisCol) {
return prev;
}
if (legendState && !legendState[curr]) {
return prev;
}
const value = datum[curr] || 0;
return prev + (value as number);
}, 0);
totalStackedValues.push(values);
thresholdValues.push(((percentageThreshold || 0) / 100) * values);
});
}
return {
totalStackedValues,
thresholdValues,
};
}
export function extractShowValueIndexes(
series: SeriesOption[],
opts: {
stack: StackType;
onlyTotal?: boolean;
isHorizontal?: boolean;
legendState?: LegendState;
},
): number[] {
const showValueIndexes: number[] = [];
const { legendState, stack, isHorizontal, onlyTotal } = opts;
if (stack) {
series.forEach((entry, seriesIndex) => {
const { data = [] } = entry;
(data as [any, number][]).forEach((datum, dataIndex) => {
if (entry.id && legendState && !legendState[entry.id]) {
return;
}
if (!onlyTotal && datum[isHorizontal ? 0 : 1] !== null) {
showValueIndexes[dataIndex] = seriesIndex;
}
if (onlyTotal) {
if (datum[isHorizontal ? 0 : 1] > 0) {
showValueIndexes[dataIndex] = seriesIndex;
}
if (
!showValueIndexes[dataIndex] &&
datum[isHorizontal ? 0 : 1] !== null
) {
showValueIndexes[dataIndex] = seriesIndex;
}
}
});
});
}
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 sortRows(
rows: DataRecord[],
totalStackedValues: number[],
xAxis: string,
xAxisSortSeries: SortSeriesType,
xAxisSortSeriesAscending: boolean,
) {
const sortedRows = rows.map((row, idx) => {
let sortKey: DataRecordValue = '';
let aggregate: number | undefined;
let entries = 0;
Object.entries(row).forEach(([key, value]) => {
const isValueDefined = isDefined(value);
if (key === xAxis) {
sortKey = value;
}
if (
xAxisSortSeries === SortSeriesType.Name ||
typeof value !== 'number'
) {
return;
}
if (!(xAxisSortSeries === SortSeriesType.Avg && !isValueDefined)) {
entries += 1;
}
switch (xAxisSortSeries) {
case SortSeriesType.Avg:
case SortSeriesType.Sum:
if (aggregate === undefined) {
aggregate = value;
} else {
aggregate += value;
}
break;
case SortSeriesType.Min:
aggregate =
aggregate === undefined || (isValueDefined && value < aggregate)
? value
: aggregate;
break;
case SortSeriesType.Max:
aggregate =
aggregate === undefined || (isValueDefined && value > aggregate)
? value
: aggregate;
break;
default:
break;
}
});
if (
xAxisSortSeries === SortSeriesType.Avg &&
entries > 0 &&
aggregate !== undefined
) {
aggregate /= entries;
}
const value =
xAxisSortSeries === SortSeriesType.Name
? typeof sortKey === 'string'
? sortKey.toLowerCase()
: sortKey
: aggregate;
return {
key: sortKey,
value,
row,
totalStackedValue: totalStackedValues[idx],
};
});
return orderBy(
sortedRows,
['value'],
[xAxisSortSeriesAscending ? 'asc' : 'desc'],
).map(({ row, totalStackedValue }) => ({ row, totalStackedValue }));
}
export function extractSeries(
data: DataRecord[],
opts: {
fillNeighborValue?: number;
xAxis?: string;
extraMetricLabels?: string[];
removeNulls?: boolean;
stack?: StackType;
totalStackedValues?: number[];
isHorizontal?: boolean;
sortSeriesType?: SortSeriesType;
sortSeriesAscending?: boolean;
xAxisSortSeries?: SortSeriesType;
xAxisSortSeriesAscending?: boolean;
} = {},
): [SeriesOption[], number[], number | undefined] {
const {
fillNeighborValue,
xAxis = DTTM_ALIAS,
extraMetricLabels = [],
removeNulls = false,
stack = false,
totalStackedValues = [],
isHorizontal = false,
sortSeriesType,
sortSeriesAscending,
xAxisSortSeries,
xAxisSortSeriesAscending,
} = opts;
if (data.length === 0) return [[], [], undefined];
const rows: DataRecord[] = data.map(datum => ({
...datum,
[xAxis]: datum[xAxis],
}));
const sortedSeries = sortAndFilterSeries(
rows,
xAxis,
extraMetricLabels,
sortSeriesType,
sortSeriesAscending,
);
const sortedRows =
isDefined(xAxisSortSeries) && isDefined(xAxisSortSeriesAscending)
? sortRows(
rows,
totalStackedValues,
xAxis,
xAxisSortSeries!,
xAxisSortSeriesAscending!,
)
: rows.map((row, idx) => ({
row,
totalStackedValue: totalStackedValues[idx],
}));
let minPositiveValue: number | undefined;
const finalSeries = sortedSeries.map(name => ({
id: name,
name,
data: sortedRows
.map(({ row, totalStackedValue }, idx) => {
const currentValue = row[name];
if (
typeof currentValue === 'number' &&
currentValue > 0 &&
(minPositiveValue === undefined || minPositiveValue > currentValue)
) {
minPositiveValue = currentValue;
}
const isNextToDefinedValue =
isDefined(rows[idx - 1]?.[name]) || isDefined(rows[idx + 1]?.[name]);
const isFillNeighborValue =
!isDefined(currentValue) &&
isNextToDefinedValue &&
fillNeighborValue !== undefined;
let value: DataRecordValue | undefined = currentValue;
if (isFillNeighborValue) {
value = fillNeighborValue;
} else if (
stack === StackControlsValue.Expand &&
totalStackedValue !== undefined
) {
value = ((value || 0) as number) / totalStackedValue;
}
return [row[xAxis], value];
})
.filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null))
.map(obs => (isHorizontal ? [obs[1], obs[0]] : obs)),
}));
return [
finalSeries,
sortedRows.map(({ totalStackedValue }) => totalStackedValue),
minPositiveValue,
];
}
export function formatSeriesName(
name: DataRecordValue | undefined,
{
numberFormatter,
timeFormatter,
coltype,
}: {
numberFormatter?: ValueFormatter;
timeFormatter?: TimeFormatter;
coltype?: GenericDataType;
} = {},
): string {
if (name === undefined || name === null) {
return NULL_STRING;
}
if (typeof name === 'boolean') {
return name.toString();
}
if (name instanceof Date || coltype === GenericDataType.Temporal) {
const normalizedName =
typeof name === 'string' ? normalizeTimestamp(name) : name;
const d =
normalizedName instanceof Date
? normalizedName
: new Date(normalizedName);
return timeFormatter ? timeFormatter(d) : d.toISOString();
}
if (typeof name === 'number') {
return numberFormatter ? numberFormatter(name) : name.toString();
}
return name;
}
export const getColtypesMapping = ({
coltypes = [],
colnames = [],
}: Pick<ChartDataResponseResult, 'coltypes' | 'colnames'>): Record<
string,
GenericDataType
> =>
colnames.reduce(
(accumulator, item, index) => ({ ...accumulator, [item]: coltypes[index] }),
{},
);
export function extractGroupbyLabel({
datum = {},
groupby,
numberFormatter,
timeFormatter,
coltypeMapping = {},
}: {
datum?: DataRecord;
groupby?: string[] | null;
numberFormatter?: NumberFormatter;
timeFormatter?: TimeFormatter;
coltypeMapping?: Record<string, GenericDataType>;
}): string {
return ensureIsArray(groupby)
.map(val =>
formatSeriesName(datum[val], {
numberFormatter,
timeFormatter,
...(coltypeMapping[val] && { coltype: coltypeMapping[val] }),
}),
)
.join(', ');
}
export function getLegendProps(
type: LegendType,
orientation: LegendOrientation,
show: boolean,
theme: SupersetTheme,
zoomable = false,
legendState?: LegendState,
): LegendComponentOption | LegendComponentOption[] {
const legend: LegendComponentOption | LegendComponentOption[] = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
orientation,
)
? 'horizontal'
: 'vertical',
show,
type,
selected: legendState,
selector: ['all', 'inverse'],
selectorLabel: {
fontFamily: theme.typography.families.sansSerif,
fontSize: theme.typography.sizes.s,
color: theme.colors.grayscale.base,
borderColor: theme.colors.grayscale.base,
},
};
switch (orientation) {
case LegendOrientation.Left:
legend.left = 0;
break;
case LegendOrientation.Right:
legend.right = 0;
legend.top = zoomable ? TIMESERIES_CONSTANTS.legendRightTopOffset : 0;
break;
case LegendOrientation.Bottom:
legend.bottom = 0;
break;
case LegendOrientation.Top:
default:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
break;
}
return legend;
}
export function getChartPadding(
show: boolean,
orientation: LegendOrientation,
margin?: string | number | null,
padding?: { top?: number; bottom?: number; left?: number; right?: number },
isHorizontal?: boolean,
): {
bottom: number;
left: number;
right: number;
top: number;
} {
let legendMargin;
if (!show) {
legendMargin = 0;
} else if (
margin === null ||
margin === undefined ||
typeof margin === 'string'
) {
legendMargin = defaultLegendPadding[orientation];
} else {
legendMargin = margin;
}
const { bottom = 0, left = 0, right = 0, top = 0 } = padding || {};
if (isHorizontal) {
return {
left:
left + (orientation === LegendOrientation.Bottom ? legendMargin : 0),
right:
right + (orientation === LegendOrientation.Right ? legendMargin : 0),
top: top + (orientation === LegendOrientation.Top ? legendMargin : 0),
bottom:
bottom + (orientation === LegendOrientation.Left ? legendMargin : 0),
};
}
return {
left: left + (orientation === LegendOrientation.Left ? legendMargin : 0),
right: right + (orientation === LegendOrientation.Right ? legendMargin : 0),
top: top + (orientation === LegendOrientation.Top ? legendMargin : 0),
bottom:
bottom + (orientation === LegendOrientation.Bottom ? legendMargin : 0),
};
}
export function dedupSeries(series: SeriesOption[]): SeriesOption[] {
const counter = new Map<string, number>();
return series.map(row => {
let { id } = row;
if (id === undefined) return row;
id = String(id);
const count = counter.get(id) || 0;
const suffix = count > 0 ? ` (${count})` : '';
counter.set(id, count + 1);
return {
...row,
id: `${id}${suffix}`,
};
});
}
export function sanitizeHtml(text: string): string {
return format.encodeHTML(text);
}
export function getAxisType(
stack: StackType,
forceCategorical?: boolean,
dataType?: GenericDataType,
): AxisType {
if (forceCategorical) {
return AxisType.Category;
}
if (dataType === GenericDataType.Temporal) {
return AxisType.Time;
}
if (dataType === GenericDataType.Numeric && !stack) {
return AxisType.Value;
}
return AxisType.Category;
}
export function getOverMaxHiddenFormatter(
config: {
max?: number;
formatter?: ValueFormatter;
} = {},
) {
const { max, formatter } = config;
// Only apply this logic if there's a MAX set in the controls
const shouldHideIfOverMax = !!max || max === 0;
return new NumberFormatter({
formatFunc: value =>
`${
shouldHideIfOverMax && value > max
? ''
: formatter?.format(value) || value
}`,
id: NumberFormats.OVER_MAX_HIDDEN,
});
}
export function calculateLowerLogTick(minPositiveValue: number) {
const logBase10 = Math.floor(Math.log10(minPositiveValue));
return Math.pow(10, logBase10);
}
type BoundsType = {
min?: number | 'dataMin';
max?: number | 'dataMax';
scale?: true;
};
export function getMinAndMaxFromBounds(
axisType: AxisType,
truncateAxis: boolean,
min?: number,
max?: number,
seriesType?: EchartsTimeseriesSeriesType,
): BoundsType | {} {
if (axisType === AxisType.Value && truncateAxis) {
const ret: BoundsType = {};
if (seriesType === EchartsTimeseriesSeriesType.Bar) {
ret.scale = true;
}
if (min !== undefined) {
ret.min = min;
} else if (seriesType !== EchartsTimeseriesSeriesType.Bar) {
ret.min = 'dataMin';
}
if (max !== undefined) {
ret.max = max;
} else if (seriesType !== EchartsTimeseriesSeriesType.Bar) {
ret.max = 'dataMax';
}
return ret;
}
return {};
}
/**
* Returns the stackId used in stacked series.
* It will return the defaultId if the chart is not using time comparison.
* If time comparison is used, it will return the time comparison value as the stackId
* if the name includes the time comparison value.
*
* @param {string} defaultId The default stackId.
* @param {string[]} timeCompare The time comparison values.
* @param {string | number} name The name of the serie.
*
* @returns {string} The stackId.
*/
export function getTimeCompareStackId(
defaultId: string,
timeCompare: string[],
name?: string | number,
): string {
if (isEmpty(timeCompare)) {
return defaultId;
}
// Each timeCompare is its own stack so it doesn't stack on top of original ones
return (
timeCompare.find(value => {
if (typeof name === 'string') {
// offset is represented as <offset>, group by list
return (
name.includes(`${value},`) ||
// offset is represented as <metric>__<offset>
name.includes(`__${value}`)
);
}
return name?.toString().includes(value);
}) || defaultId
);
}