blob: b25245fd9b86a322c3d51a3c9810713d373229b9 [file] [log] [blame]
/**
* 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.
*/
/* eslint-disable camelcase */
import { invert } from 'lodash';
import {
AnnotationLayer,
AxisType,
buildCustomFormatters,
CategoricalColorNamespace,
CurrencyFormatter,
ensureIsArray,
getCustomFormatter,
getNumberFormatter,
getXAxisLabel,
isDefined,
isEventAnnotationLayer,
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isPhysicalColumn,
isTimeseriesAnnotationLayer,
QueryFormData,
QueryFormMetric,
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
tooltipHtml,
ValueFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { getOriginalSeries } from '@superset-ui/chart-controls';
import type { EChartsCoreOption } from 'echarts/core';
import type { SeriesOption } from 'echarts';
import {
DEFAULT_FORM_DATA,
EchartsMixedTimeseriesChartTransformedProps,
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps,
} from './types';
import {
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
Refs,
} from '../types';
import { parseAxisBound } from '../utils/controls';
import {
dedupSeries,
extractDataTotalValues,
extractSeries,
extractShowValueIndexes,
extractTooltipKeys,
getAxisType,
getColtypesMapping,
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
} from '../utils/series';
import {
extractAnnotationLabels,
getAnnotationData,
} from '../utils/annotation';
import {
extractForecastSeriesContext,
extractForecastValuesFromTooltipParams,
formatForecastTooltipSeries,
rebaseForecastDatum,
reorderForecastSeries,
} from '../utils/forecast';
import { convertInteger } from '../utils/convertInteger';
import { defaultGrid, defaultYAxis } from '../defaults';
import {
getPadding,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
transformSeries,
transformTimeseriesAnnotation,
} from '../Timeseries/transformers';
import { TIMEGRAIN_TO_TIMESTAMP, TIMESERIES_CONSTANTS } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
import { getMetricDisplayName } from '../utils/metricDisplayName';
const getFormatter = (
customFormatters: Record<string, ValueFormatter>,
defaultFormatter: ValueFormatter,
metrics: QueryFormMetric[],
formatterKey: string,
forcePercentFormat: boolean,
) => {
if (forcePercentFormat) {
return getNumberFormatter(',.0%');
}
return (
getCustomFormatter(customFormatters, metrics, formatterKey) ??
defaultFormatter
);
};
export default function transformProps(
chartProps: EchartsMixedTimeseriesProps,
): EchartsMixedTimeseriesChartTransformedProps {
const {
width,
height,
formData,
queriesData,
hooks,
filterState,
datasource,
theme,
inContextMenu,
emitCrossFilters,
legendState,
} = chartProps;
let focusedSeries: string | null = null;
const {
verboseMap = {},
currencyFormats = {},
columnFormats = {},
} = datasource;
const { label_map: labelMap } =
queriesData[0] as TimeseriesChartDataResponseResult;
const { label_map: labelMapB } =
queriesData[1] as TimeseriesChartDataResponseResult;
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
const annotationData = getAnnotationData(chartProps);
const coltypeMapping = {
...getColtypesMapping(queriesData[0]),
...getColtypesMapping(queriesData[1]),
};
const {
area,
areaB,
annotationLayers,
colorScheme,
timeShiftColor,
contributionMode,
legendOrientation,
legendMargin,
legendType,
legendSort,
logAxis,
logAxisSecondary,
markerEnabled,
markerEnabledB,
markerSize,
markerSizeB,
opacity,
opacityB,
minorSplitLine,
minorTicks,
seriesType,
seriesTypeB,
showLegend,
showValue,
showValueB,
onlyTotal,
onlyTotalB,
stack,
stackB,
truncateXAxis,
truncateYAxis,
tooltipTimeFormat,
yAxisFormat,
currencyFormat,
yAxisFormatSecondary,
currencyFormatSecondary,
xAxisTimeFormat,
yAxisBounds,
yAxisBoundsSecondary,
yAxisIndex,
yAxisIndexB,
yAxisTitleSecondary,
zoomable,
richTooltip,
tooltipSortByMetric,
xAxisBounds,
xAxisLabelRotation,
xAxisLabelInterval,
groupby,
groupbyB,
xAxis: xAxisOrig,
xAxisForceCategorical,
xAxisTitle,
yAxisTitle,
xAxisTitleMargin,
yAxisTitleMargin,
yAxisTitlePosition,
sliceId,
sortSeriesType,
sortSeriesTypeB,
sortSeriesAscending,
sortSeriesAscendingB,
timeGrainSqla,
forceMaxInterval,
percentageThreshold,
showQueryIdentifiers = false,
metrics = [],
metricsB = [],
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {};
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
let xAxisLabel = getXAxisLabel(
chartProps.rawFormData as QueryFormData,
) as string;
if (
isPhysicalColumn(chartProps.rawFormData?.x_axis) &&
isDefined(verboseMap[xAxisLabel])
) {
xAxisLabel = verboseMap[xAxisLabel];
}
const rebasedDataA = rebaseForecastDatum(data1, verboseMap);
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedDataA,
{
stack,
percentageThreshold,
xAxisCol: xAxisLabel,
},
);
const MetricDisplayNameA = getMetricDisplayName(metrics[0], verboseMap);
const MetricDisplayNameB = getMetricDisplayName(metricsB[0], verboseMap);
const dataTypes = getColtypesMapping(queriesData[0]);
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const [rawSeriesA, sortedTotalValuesA] = extractSeries(rebasedDataA, {
fillNeighborValue: stack ? 0 : undefined,
xAxis: xAxisLabel,
sortSeriesType,
sortSeriesAscending,
stack,
totalStackedValues,
xAxisType,
});
const rebasedDataB = rebaseForecastDatum(data2, verboseMap);
const {
totalStackedValues: totalStackedValuesB,
thresholdValues: thresholdValuesB,
} = extractDataTotalValues(rebasedDataB, {
stack: Boolean(stackB),
percentageThreshold,
xAxisCol: xAxisLabel,
});
const [rawSeriesB, sortedTotalValuesB] = extractSeries(rebasedDataB, {
fillNeighborValue: stackB ? 0 : undefined,
xAxis: xAxisLabel,
sortSeriesType: sortSeriesTypeB,
sortSeriesAscending: sortSeriesAscendingB,
stack: Boolean(stackB),
totalStackedValues: totalStackedValuesB,
xAxisType,
});
const series: SeriesOption[] = [];
const formatter = contributionMode
? getNumberFormatter(',.0%')
: currencyFormat?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormat,
currency: currencyFormat,
})
: getNumberFormatter(yAxisFormat);
const formatterSecondary = contributionMode
? getNumberFormatter(',.0%')
: currencyFormatSecondary?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormatSecondary,
currency: currencyFormatSecondary,
})
: getNumberFormatter(yAxisFormatSecondary);
const customFormatters = buildCustomFormatters(
[...ensureIsArray(metrics), ...ensureIsArray(metricsB)],
currencyFormats,
columnFormats,
yAxisFormat,
currencyFormat,
);
const customFormattersSecondary = buildCustomFormatters(
[...ensureIsArray(metrics), ...ensureIsArray(metricsB)],
currencyFormats,
columnFormats,
yAxisFormatSecondary,
currencyFormatSecondary,
);
const primarySeries = new Set<string>();
const secondarySeries = new Set<string>();
const mapSeriesIdToAxis = (
seriesOption: SeriesOption,
index?: number,
): void => {
if (index === 1) {
secondarySeries.add(seriesOption.id as string);
} else {
primarySeries.add(seriesOption.id as string);
}
};
const showValueIndexesA = extractShowValueIndexes(rawSeriesA, {
stack,
onlyTotal,
});
const showValueIndexesB = extractShowValueIndexes(rawSeriesB, {
stack,
onlyTotal,
});
annotationLayers
.filter((layer: AnnotationLayer) => layer.show)
.forEach((layer: AnnotationLayer) => {
if (isFormulaAnnotationLayer(layer))
series.push(
transformFormulaAnnotation(
layer,
data1,
xAxisLabel,
xAxisType,
colorScale,
sliceId,
),
);
else if (isIntervalAnnotationLayer(layer)) {
series.push(
...transformIntervalAnnotation(
layer,
data1,
annotationData,
colorScale,
theme,
sliceId,
),
);
} else if (isEventAnnotationLayer(layer)) {
series.push(
...transformEventAnnotation(
layer,
data1,
annotationData,
colorScale,
theme,
sliceId,
),
);
} else if (isTimeseriesAnnotationLayer(layer)) {
series.push(
...transformTimeseriesAnnotation(
layer,
markerSize,
data1,
annotationData,
colorScale,
sliceId,
),
);
}
});
// yAxisBounds need to be parsed to replace incompatible values with undefined
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);
let [minSecondary, maxSecondary] = (yAxisBoundsSecondary || []).map(
parseAxisBound,
);
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
rawSeriesA.forEach(entry => {
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let displayName: string;
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const seriesFormatter = getFormatter(
customFormatters,
formatter,
metrics,
labelMap?.[seriesName]?.[0],
!!contributionMode,
);
const transformedSeries = transformSeries(
{
...entry,
id: `${displayName || ''}`,
name: `${displayName || ''}`,
},
colorScale,
colorScaleKey,
{
area,
markerEnabled,
markerSize,
areaOpacity: opacity,
seriesType,
showValue,
onlyTotal,
stack: Boolean(stack),
stackIdSuffix: '\na',
yAxisIndex,
filterState,
seriesKey: entry.name,
sliceId,
queryIndex: 0,
formatter:
seriesType === EchartsTimeseriesSeriesType.Bar
? getOverMaxHiddenFormatter({
max: yAxisMax,
formatter: seriesFormatter,
})
: seriesFormatter,
totalStackedValues: sortedTotalValuesA,
showValueIndexes: showValueIndexesA,
thresholdValues,
timeShiftColor,
theme,
},
);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndex);
}
});
rawSeriesB.forEach(entry => {
const entryName = String(entry.name || '');
const seriesEntry = inverted[entryName] || entryName;
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName: string;
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const seriesFormatter = getFormatter(
customFormattersSecondary,
formatterSecondary,
metricsB,
labelMapB?.[seriesName]?.[0],
!!contributionMode,
);
const transformedSeries = transformSeries(
{
...entry,
id: `${displayName || ''}`,
name: `${displayName || ''}`,
},
colorScale,
colorScaleKey,
{
area: areaB,
markerEnabled: markerEnabledB,
markerSize: markerSizeB,
areaOpacity: opacityB,
seriesType: seriesTypeB,
showValue: showValueB,
onlyTotal: onlyTotalB,
stack: Boolean(stackB),
stackIdSuffix: '\nb',
yAxisIndex: yAxisIndexB,
filterState,
seriesKey: entry.name,
sliceId,
queryIndex: 1,
formatter:
seriesTypeB === EchartsTimeseriesSeriesType.Bar
? getOverMaxHiddenFormatter({
max: maxSecondary,
formatter: seriesFormatter,
})
: seriesFormatter,
totalStackedValues: sortedTotalValuesB,
showValueIndexes: showValueIndexesB,
thresholdValues: thresholdValuesB,
timeShiftColor,
theme,
},
);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndexB);
}
});
// default to 0-100% range when doing row-level contribution chart
if (contributionMode === 'row' && stack) {
if (yAxisMin === undefined) yAxisMin = 0;
if (yAxisMax === undefined) yAxisMax = 1;
if (minSecondary === undefined) minSecondary = 0;
if (maxSecondary === undefined) maxSecondary = 1;
}
const tooltipFormatter =
xAxisDataType === GenericDataType.Temporal
? getTooltipTimeFormatter(tooltipTimeFormat)
: String;
const xAxisFormatter =
xAxisDataType === GenericDataType.Temporal
? getXAxisFormatter(xAxisTimeFormat)
: String;
const addYAxisTitleOffset = !!(yAxisTitle || yAxisTitleSecondary);
const addXAxisTitleOffset = !!xAxisTitle;
const chartPadding = getPadding(
showLegend,
legendOrientation,
addYAxisTitleOffset,
zoomable,
legendMargin,
addXAxisTitleOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
);
const { setDataMask = () => {}, onContextMenu } = hooks;
const alignTicks = yAxisIndex !== yAxisIndexB;
const echartOptions: EChartsCoreOption = {
useUTC: true,
grid: {
...defaultGrid,
...chartPadding,
},
xAxis: {
type: xAxisType,
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
formatter: xAxisFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
},
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && timeGrainSqla && !forceMaxInterval
? TIMEGRAIN_TO_TIMESTAMP[
timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP
]
: 0,
maxInterval:
xAxisType === AxisType.Time && timeGrainSqla && forceMaxInterval
? TIMEGRAIN_TO_TIMESTAMP[
timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP
]
: undefined,
...getMinAndMaxFromBounds(
xAxisType,
truncateXAxis,
xAxisMin,
xAxisMax,
seriesType === EchartsTimeseriesSeriesType.Bar ||
seriesTypeB === EchartsTimeseriesSeriesType.Bar
? EchartsTimeseriesSeriesType.Bar
: undefined,
),
},
yAxis: [
{
...defaultYAxis,
type: logAxis ? 'log' : 'value',
min: yAxisMin,
max: yAxisMax,
minorTick: { show: minorTicks },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
formatter: getYAxisFormatter(
metrics,
!!contributionMode,
customFormatters,
formatter,
yAxisFormat,
),
},
scale: truncateYAxis,
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
alignTicks,
},
{
...defaultYAxis,
type: logAxisSecondary ? 'log' : 'value',
min: minSecondary,
max: maxSecondary,
minorTick: { show: minorTicks },
splitLine: { show: false },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
formatter: getYAxisFormatter(
metricsB,
!!contributionMode,
customFormattersSecondary,
formatterSecondary,
yAxisFormatSecondary,
),
},
scale: truncateYAxis,
name: yAxisTitleSecondary,
alignTicks,
},
],
tooltip: {
...getDefaultTooltip(refs),
show: !inContextMenu,
trigger: richTooltip ? 'axis' : 'item',
formatter: (params: any) => {
const xValue: number = richTooltip
? params[0].value[0]
: params.value[0];
const forecastValue: any[] = richTooltip ? params : [params];
const sortedKeys = extractTooltipKeys(
forecastValue,
// horizontal mode is not supported in mixed series chart
1,
richTooltip,
tooltipSortByMetric,
);
const rows: string[][] = [];
const forecastValues =
extractForecastValuesFromTooltipParams(forecastValue);
const keys = Object.keys(forecastValues);
let focusedRow;
sortedKeys
.filter(key => keys.includes(key))
.forEach(key => {
const value = forecastValues[key];
// if there are no dimensions, key is a verbose name of a metric,
// otherwise it is a comma separated string where the first part is metric name
let formatterKey;
if (primarySeries.has(key)) {
formatterKey =
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
} else {
formatterKey =
groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0];
}
const tooltipFormatter = getFormatter(
customFormatters,
formatter,
metrics,
formatterKey,
!!contributionMode,
);
const tooltipFormatterSecondary = getFormatter(
customFormattersSecondary,
formatterSecondary,
metricsB,
formatterKey,
!!contributionMode,
);
const row = formatForecastTooltipSeries({
...value,
seriesName: key,
formatter: primarySeries.has(key)
? tooltipFormatter
: tooltipFormatterSecondary,
});
rows.push(row);
if (key === focusedSeries) {
focusedRow = rows.length - 1;
}
});
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
},
},
legend: {
...getLegendProps(
legendType,
legendOrientation,
showLegend,
theme,
zoomable,
legendState,
chartPadding,
),
// @ts-ignore
data: series
.filter(
entry =>
extractForecastSeriesContext((entry.name || '') as string).type ===
ForecastSeriesEnum.Observation,
)
.map(entry => entry.id || entry.name || '')
.concat(extractAnnotationLabels(annotationLayers))
.sort((a: string, b: string) => {
if (!legendSort) return 0;
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
}),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
right: TIMESERIES_CONSTANTS.toolboxRight,
feature: {
dataZoom: {
yAxisIndex: false,
title: {
zoom: 'zoom area',
back: 'restore zoom',
},
},
},
},
dataZoom: zoomable
? [
{
type: 'slider',
start: TIMESERIES_CONSTANTS.dataZoomStart,
end: TIMESERIES_CONSTANTS.dataZoomEnd,
bottom: TIMESERIES_CONSTANTS.zoomBottom,
},
]
: [],
};
const onFocusedSeries = (seriesName: string | null) => {
focusedSeries = seriesName;
};
return {
formData,
width,
height,
echartOptions,
setDataMask,
emitCrossFilters,
labelMap,
labelMapB,
groupby,
groupbyB,
seriesBreakdown: rawSeriesA.length,
selectedValues: filterState.selectedValues || [],
onContextMenu,
onFocusedSeries,
xValueFormatter: tooltipFormatter,
xAxis: {
label: xAxisLabel,
type: xAxisType,
},
refs,
coltypeMapping,
};
}