645 lines
17 KiB
TypeScript
645 lines
17 KiB
TypeScript
/* 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 } from 'echarts/core';
|
|
import type { LegendComponentOption } from 'echarts/components';
|
|
import type { 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
|
|
);
|
|
}
|