Files
superset2/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts

1085 lines
28 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,
LegendState,
normalizeTimestamp,
NumberFormats,
NumberFormatter,
TimeFormatter,
ValueFormatter,
} from '@superset-ui/core';
import { SupersetTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { SortSeriesType, LegendPaddingType } 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-es';
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;
}
const DEFAULT_LEGEND_ITEM_GAP = 10;
const DEFAULT_LEGEND_ICON_WIDTH = 25;
const LEGEND_ICON_LABEL_GAP = 5;
const LEGEND_HORIZONTAL_SIDE_GUTTER = 16;
const LEGEND_HORIZONTAL_ROW_HEIGHT = 24;
const LEGEND_HORIZONTAL_MAX_ROWS = 2;
const LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO = 0.25;
const LEGEND_VERTICAL_SIDE_GUTTER = 16;
const LEGEND_VERTICAL_ROW_HEIGHT = 24;
const LEGEND_VERTICAL_MAX_WIDTH_RATIO = 0.4;
const LEGEND_SELECTOR_GAP = 10;
const LEGEND_MARGIN_GUTTER = 45;
// ECharts does not expose pre-render measurements for plain legends, so these
// values intentionally overestimate selector space to avoid clipping.
const ESTIMATED_LEGEND_SELECTOR_WIDTH = 112;
const LEGEND_TEXT_WIDTH_CACHE = new Map<string, number>();
type LegendDataItem =
| string
| number
| null
| undefined
| { name?: string | number | null };
export type LegendLayoutResult = {
effectiveMargin?: number;
effectiveType: LegendType;
};
const SCROLL_LEGEND_LAYOUT: LegendLayoutResult = {
effectiveType: LegendType.Scroll,
};
function getLegendLabel(item: LegendDataItem): string {
if (typeof item === 'string' || typeof item === 'number') {
return String(item);
}
if (item?.name === undefined || item.name === null) {
return '';
}
return String(item.name);
}
function measureLegendTextWidth(text: string, theme: SupersetTheme): number {
const cacheKey = `${theme.fontFamily}:${theme.fontSizeSM}:${text}`;
const cachedWidth = LEGEND_TEXT_WIDTH_CACHE.get(cacheKey);
if (cachedWidth !== undefined) {
return cachedWidth;
}
let width = text.length * theme.fontSizeSM * 0.62;
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = `${theme.fontSizeSM}px ${theme.fontFamily}`;
({ width } = context.measureText(text));
}
}
LEGEND_TEXT_WIDTH_CACHE.set(cacheKey, width);
return width;
}
function hasLegendLabel(item: LegendDataItem): boolean {
if (item === null || item === undefined) {
return false;
}
if (typeof item === 'object') {
return item.name !== null && item.name !== undefined;
}
return true;
}
function getLegendLabels(items: LegendDataItem[]): string[] {
return items.filter(hasLegendLabel).map(getLegendLabel);
}
function getLegendItemWidths(labels: string[], theme: SupersetTheme): number[] {
return labels.map(
label =>
DEFAULT_LEGEND_ICON_WIDTH +
LEGEND_ICON_LABEL_GAP +
measureLegendTextWidth(label, theme),
);
}
function estimateHorizontalLegendRows(
labels: string[],
chartWidth: number,
showSelectors: boolean,
theme: SupersetTheme,
): number {
const availableWidth = Math.max(
chartWidth - LEGEND_HORIZONTAL_SIDE_GUTTER,
0,
);
if (availableWidth === 0) {
return Infinity;
}
const legendItemWidths = getLegendItemWidths(labels, theme);
if (legendItemWidths.length === 0) {
if (showSelectors && ESTIMATED_LEGEND_SELECTOR_WIDTH > availableWidth) {
return Infinity;
}
return 1;
}
let rows = 1;
let rowWidth = 0;
for (const itemWidth of legendItemWidths) {
if (itemWidth > availableWidth) {
return Infinity;
}
const nextWidth =
rowWidth === 0
? itemWidth
: rowWidth + DEFAULT_LEGEND_ITEM_GAP + itemWidth;
if (rowWidth > 0 && nextWidth > availableWidth) {
rows += 1;
rowWidth = itemWidth;
} else {
rowWidth = nextWidth;
}
}
if (showSelectors) {
if (ESTIMATED_LEGEND_SELECTOR_WIDTH > availableWidth) {
return Infinity;
}
const selectorWidth =
rowWidth === 0
? ESTIMATED_LEGEND_SELECTOR_WIDTH
: rowWidth + LEGEND_SELECTOR_GAP + ESTIMATED_LEGEND_SELECTOR_WIDTH;
if (selectorWidth > availableWidth) {
rows += 1;
}
}
return rows;
}
export function getHorizontalLegendAvailableWidth({
chartWidth,
orientation,
padding,
zoomable = false,
}: {
chartWidth: number;
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
padding?: LegendPaddingType;
zoomable?: boolean;
}): number {
let availableWidth = chartWidth - (padding?.left ?? 0);
if (orientation === LegendOrientation.Top && zoomable) {
availableWidth -= TIMESERIES_CONSTANTS.legendTopRightOffset;
}
return Math.max(availableWidth, 0);
}
function getLongestLegendLabelWidth(
labels: string[],
theme: SupersetTheme,
): number {
return labels.reduce(
(maxWidth, label) =>
Math.max(maxWidth, measureLegendTextWidth(label, theme)),
0,
);
}
function isHorizontalLegendOrientation(
orientation: LegendOrientation,
): orientation is LegendOrientation.Top | LegendOrientation.Bottom {
return (
orientation === LegendOrientation.Top ||
orientation === LegendOrientation.Bottom
);
}
function getHorizontalPlainLegendLayout({
availableHeight,
availableWidth,
currentMargin,
legendLabels,
orientation,
showSelectors,
theme,
}: {
availableHeight: number;
availableWidth: number;
currentMargin: number;
legendLabels: string[];
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
showSelectors: boolean;
theme: SupersetTheme;
}): LegendLayoutResult {
const rowCount = estimateHorizontalLegendRows(
legendLabels,
availableWidth,
showSelectors,
theme,
);
const requiredMargin =
defaultLegendPadding[orientation] +
Math.max(0, rowCount - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
const maxLegendHeight =
availableHeight > 0
? availableHeight * LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO
: Infinity;
if (
!Number.isFinite(rowCount) ||
rowCount > LEGEND_HORIZONTAL_MAX_ROWS ||
requiredMargin > maxLegendHeight
) {
return SCROLL_LEGEND_LAYOUT;
}
return {
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveType: LegendType.Plain,
};
}
function getVerticalPlainLegendLayout({
availableHeight,
availableWidth,
currentMargin,
legendLabels,
showSelectors,
theme,
}: {
availableHeight: number;
availableWidth: number;
currentMargin: number;
legendLabels: string[];
showSelectors: boolean;
theme: SupersetTheme;
}): LegendLayoutResult {
if (legendLabels.length === 0) {
return {
effectiveMargin: currentMargin,
effectiveType: LegendType.Plain,
};
}
const selectorHeight = showSelectors
? LEGEND_VERTICAL_ROW_HEIGHT + LEGEND_SELECTOR_GAP
: 0;
const effectiveAvailableHeight = Math.max(
availableHeight - LEGEND_VERTICAL_SIDE_GUTTER - selectorHeight,
0,
);
const rowsPerColumn = Math.floor(
(effectiveAvailableHeight + DEFAULT_LEGEND_ITEM_GAP) /
(LEGEND_VERTICAL_ROW_HEIGHT + DEFAULT_LEGEND_ITEM_GAP),
);
const requiredSelectorMargin = showSelectors
? ESTIMATED_LEGEND_SELECTOR_WIDTH + LEGEND_VERTICAL_SIDE_GUTTER
: 0;
const requiredMargin = Math.ceil(
Math.max(
getLongestLegendLabelWidth(legendLabels, theme) + LEGEND_MARGIN_GUTTER,
requiredSelectorMargin,
),
);
const maxLegendWidth =
availableWidth > 0
? availableWidth * LEGEND_VERTICAL_MAX_WIDTH_RATIO
: Infinity;
if (
rowsPerColumn <= 0 ||
legendLabels.length > rowsPerColumn ||
requiredMargin > maxLegendWidth
) {
return SCROLL_LEGEND_LAYOUT;
}
return {
effectiveMargin: Math.max(currentMargin, requiredMargin),
effectiveType: LegendType.Plain,
};
}
export function getLegendLayoutResult({
availableHeight,
availableWidth,
chartHeight,
chartWidth,
legendItems = [],
legendMargin,
orientation,
show,
showSelectors = true,
theme,
type,
}: {
// Raw chart dimensions. Use availableWidth/availableHeight when other chart
// UI elements reserve legend space before ECharts lays out the legend.
availableHeight?: number;
availableWidth?: number;
chartHeight: number;
chartWidth: number;
legendItems?: LegendDataItem[];
legendMargin?: string | number | null;
orientation: LegendOrientation;
show: boolean;
showSelectors?: boolean;
theme: SupersetTheme;
type: LegendType;
}): LegendLayoutResult {
if (!show || type !== LegendType.Plain) {
return { effectiveType: type };
}
const resolvedLegendMargin =
typeof legendMargin === 'number'
? legendMargin
: defaultLegendPadding[orientation];
const legendLabels = getLegendLabels(legendItems);
const resolvedAvailableWidth = availableWidth ?? chartWidth;
const resolvedAvailableHeight = availableHeight ?? chartHeight;
if (isHorizontalLegendOrientation(orientation)) {
return getHorizontalPlainLegendLayout({
availableHeight: resolvedAvailableHeight,
availableWidth: resolvedAvailableWidth,
currentMargin: resolvedLegendMargin,
legendLabels,
orientation,
showSelectors,
theme,
});
}
return getVerticalPlainLegendLayout({
availableHeight: resolvedAvailableHeight,
availableWidth: resolvedAvailableWidth,
currentMargin: resolvedLegendMargin,
legendLabels,
showSelectors,
theme,
});
}
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: {
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
return seriesNames.sort((a, b) =>
sortSeriesAscending ? collator.compare(a, b) : collator.compare(b, a),
);
}
}
const sortedValues = seriesNames.map(aggregator);
return orderBy(
sortedValues,
['value', 'name'],
[sortSeriesAscending ? 'asc' : 'desc', 'asc'],
).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;
xAxisType?: AxisType;
} = {},
): [SeriesOption[], number[], number | undefined] {
const {
fillNeighborValue,
xAxis = DTTM_ALIAS,
extraMetricLabels = [],
removeNulls = false,
stack = false,
totalStackedValues = [],
isHorizontal = false,
sortSeriesType,
sortSeriesAscending,
xAxisSortSeries,
xAxisSortSeriesAscending,
xAxisType,
} = opts;
if (data.length === 0) return [[], [], undefined];
const rows: DataRecord[] = data.map(datum => ({
...datum,
[xAxis]:
datum[xAxis] === null && xAxisType === AxisType.Category
? NULL_STRING
: 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' || typeof name === 'bigint') {
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,
padding?: LegendPaddingType,
): LegendComponentOption {
const legend: LegendComponentOption = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
orientation,
)
? 'horizontal'
: 'vertical',
show,
type,
selected: legendState ?? {},
selector: ['all', 'inverse'],
selectorLabel: {
fontFamily: theme.fontFamily,
fontSize: theme.fontSizeSM,
color: theme.colorText,
borderColor: theme.colorBorder,
},
};
const MIN_LEGEND_WIDTH = 0;
const MARGIN_GUTTER = 45;
const getLegendWidth = (paddingWidth: number) =>
Math.max(paddingWidth - MARGIN_GUTTER, MIN_LEGEND_WIDTH);
switch (orientation) {
case LegendOrientation.Left:
legend.left = 0;
if (padding?.left) {
legend.textStyle = {
overflow: 'truncate',
width: getLegendWidth(padding.left),
};
}
break;
case LegendOrientation.Right:
legend.right = 0;
legend.top = zoomable ? TIMESERIES_CONSTANTS.legendRightTopOffset : 0;
if (padding?.right) {
legend.textStyle = {
overflow: 'truncate',
width: getLegendWidth(padding.right),
};
}
break;
case LegendOrientation.Bottom:
legend.bottom = 0;
if (padding?.left) {
legend.left = padding.left;
}
if (type === LegendType.Plain) {
legend.right = 0;
}
break;
case LegendOrientation.Top:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
if (type === LegendType.Plain && padding?.left) {
legend.left = padding.left;
}
break;
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?: LegendPaddingType,
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,
seriesType?: EchartsTimeseriesSeriesType,
): AxisType {
if (forceCategorical) {
return AxisType.Category;
}
if (dataType === GenericDataType.Temporal) {
return AxisType.Time;
}
if (
dataType === GenericDataType.Numeric &&
!stack &&
seriesType !== EchartsTimeseriesSeriesType.Bar
) {
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
);
}
const TOOLTIP_SERIES_KEY = 'seriesId';
export function extractTooltipKeys(
forecastValue: any[],
yIndex: number,
richTooltip?: boolean,
tooltipSortByMetric?: boolean,
): string[] {
if (richTooltip && tooltipSortByMetric) {
return forecastValue
.slice()
.sort((a, b) => b.data[yIndex] - a.data[yIndex])
.map(value => value[TOOLTIP_SERIES_KEY]);
}
if (richTooltip) {
return forecastValue.map(s => s[TOOLTIP_SERIES_KEY]);
}
return [forecastValue[0][TOOLTIP_SERIES_KEY]];
}
export function groupData(data: DataRecord[], by?: string | null) {
const seriesMap: Map<DataRecordValue | undefined, DataRecord[]> = new Map();
if (by) {
data.forEach(datum => {
const value = seriesMap.get(datum[by]);
if (value) {
value.push(datum);
} else {
seriesMap.set(datum[by], [datum]);
}
});
} else {
seriesMap.set(undefined, data);
}
return seriesMap;
}