Skip to content

Commit

Permalink
feat(echarts): Implement stream graph for Echarts Timeseries (#23410)
Browse files Browse the repository at this point in the history
  • Loading branch information
kgabryje authored Mar 20, 2023
1 parent a5c31b2 commit b0d83e8
Show file tree
Hide file tree
Showing 11 changed files with 345 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ import {
showValueControl,
richTooltipSection,
seriesOrderSection,
percentageThresholdControl,
} from '../../controls';
import { AreaChartExtraControlsOptions } from '../../constants';
import { AreaChartStackControlOptions } from '../../constants';

const {
logAxis,
Expand Down Expand Up @@ -109,13 +110,14 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Stacked Style'),
renderTrigger: true,
choices: AreaChartExtraControlsOptions,
choices: AreaChartStackControlOptions,
default: null,
description: t('Stack series on top of each other'),
},
},
],
[onlyTotalControl],
[percentageThresholdControl],
[
{
name: 'show_extra_controls',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,19 @@ import {
sections,
sharedControls,
} from '@superset-ui/chart-controls';

import { OrientationType } from '../../types';
import {
DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT,
} from '../../constants';
import {
legendSection,
richTooltipSection,
seriesOrderSection,
showValueSection,
} from '../../../controls';

import { OrientationType } from '../../types';
import {
DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT,
} from '../../constants';

const {
logAxis,
minorSplitLine,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import { ZRLineType } from 'echarts/types/src/util/types';
import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
TimeseriesChartTransformedProps,
OrientationType,
} from './types';
Expand Down Expand Up @@ -74,6 +73,7 @@ import {
import { convertInteger } from '../utils/convertInteger';
import { defaultGrid, defaultYAxis } from '../defaults';
import {
getBaselineSeriesForStream,
getPadding,
getTooltipTimeFormatter,
getXAxisFormatter,
Expand All @@ -84,7 +84,7 @@ import {
transformTimeseriesAnnotation,
} from './transformers';
import {
AreaChartExtraControlsValue,
StackControlsValue,
TIMESERIES_CONSTANTS,
TIMEGRAIN_TO_TIMESTAMP,
} from '../constants';
Expand Down Expand Up @@ -195,7 +195,6 @@ export default function transformProps(
fillNeighborValue: stack && !forecastEnabled ? 0 : undefined,
xAxis: xAxisLabel,
extraMetricLabels,
removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter,
stack,
totalStackedValues,
isHorizontal,
Expand All @@ -210,7 +209,7 @@ export default function transformProps(
const seriesContexts = extractForecastSeriesContexts(
Object.values(rawSeries).map(series => series.name as string),
);
const isAreaExpand = stack === AreaChartExtraControlsValue.Expand;
const isAreaExpand = stack === StackControlsValue.Expand;
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];

const xAxisType = getAxisType(xAxisDataType);
Expand Down Expand Up @@ -243,9 +242,29 @@ export default function transformProps(
isHorizontal,
lineStyle,
});
if (transformedSeries) series.push(transformedSeries);
if (transformedSeries) {
if (stack === StackControlsValue.Stream) {
// bug in Echarts - `stackStrategy: 'all'` doesn't work with nulls, so we cast them to 0
series.push({
...transformedSeries,
data: (transformedSeries.data as any).map(
(row: [string | number, number]) => [row[0], row[1] ?? 0],
),
});
} else {
series.push(transformedSeries);
}
}
});

if (stack === StackControlsValue.Stream) {
const baselineSeries = getBaselineSeriesForStream(
series.map(entry => entry.data) as [string | number, number][][],
seriesType,
);

series.unshift(baselineSeries);
}
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<string, number>, selectedValue: string) => {
const index = series.findIndex(({ name }) => name === selectedValue);
Expand Down Expand Up @@ -428,6 +447,9 @@ export default function transformProps(

Object.keys(forecastValues).forEach(key => {
const value = forecastValues[key];
if (value.observation === 0 && stack) {
return;
}
const content = formatForecastTooltipSeries({
...value,
seriesName: key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import {
AnnotationData,
AnnotationOpacity,
AxisType,
CategoricalColorScale,
EventAnnotationLayer,
FilterState,
Expand All @@ -33,7 +34,6 @@ import {
TimeFormatter,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
AxisType,
} from '@superset-ui/core';
import { SeriesOption } from 'echarts';
import {
Expand All @@ -53,8 +53,12 @@ import {
import { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';

import { extractForecastSeriesContext } from '../utils/forecast';
import { ForecastSeriesEnum, LegendOrientation, StackType } from '../types';
import { EchartsTimeseriesSeriesType } from './types';
import {
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
LegendOrientation,
StackType,
} from '../types';

import {
evalFormula,
Expand All @@ -64,11 +68,79 @@ import {
} from '../utils/annotation';
import { currentSeries, getChartPadding } from '../utils/series';
import {
AreaChartExtraControlsValue,
OpacityEnum,
StackControlsValue,
TIMESERIES_CONSTANTS,
} from '../constants';

// based on weighted wiggle algorithm
// source: https://ieeexplore.ieee.org/document/4658136
export const getBaselineSeriesForStream = (
series: [string | number, number][][],
seriesType: EchartsTimeseriesSeriesType,
) => {
const seriesLength = series[0].length;
const baselineSeriesDelta = new Array(seriesLength).fill([0, 0]);
const getVal = (value: number | null) => value ?? 0;
for (let i = 0; i < seriesLength; i += 1) {
let seriesSum = 0;
let weightedSeriesSum = 0;
for (let j = 0; j < series.length; j += 1) {
const delta =
i > 0
? getVal(series[j][i][1]) - getVal(series[j][i - 1][1])
: getVal(series[j][i][1]);
let deltaPrev = 0;
for (let k = 1; k < j - 1; k += 1) {
deltaPrev +=
i > 0
? getVal(series[k][i][1]) - getVal(series[k][i - 1][1])
: getVal(series[k][i][1]);
}
weightedSeriesSum += (0.5 * delta + deltaPrev) * getVal(series[j][i][1]);
seriesSum += getVal(series[j][i][1]);
}
baselineSeriesDelta[i] = [series[0][i][0], -weightedSeriesSum / seriesSum];
}
const baselineSeries = baselineSeriesDelta.reduce((acc, curr, i) => {
if (i === 0) {
acc.push(curr);
} else {
acc.push([curr[0], acc[i - 1][1] + curr[1]]);
}
return acc;
}, []);
return {
data: baselineSeries,
name: 'baseline',
stack: 'obs',
stackStrategy: 'all' as const,
type: 'line' as const,
lineStyle: {
opacity: 0,
},
tooltip: {
show: false,
},
silent: true,
showSymbol: false,
areaStyle: {
opacity: 0,
},
step: [
EchartsTimeseriesSeriesType.Start,
EchartsTimeseriesSeriesType.Middle,
EchartsTimeseriesSeriesType.End,
].includes(seriesType)
? (seriesType as
| EchartsTimeseriesSeriesType.Start
| EchartsTimeseriesSeriesType.Middle
| EchartsTimeseriesSeriesType.End)
: undefined,
smooth: seriesType === EchartsTimeseriesSeriesType.Smooth,
};
};

export function transformSeries(
series: SeriesOption,
colorScale: CategoricalColorScale,
Expand Down Expand Up @@ -190,9 +262,10 @@ export function transformSeries(
showSymbol = true;
}
}
const lineStyle = isConfidenceBand
? { ...opts.lineStyle, opacity: OpacityEnum.Transparent }
: { ...opts.lineStyle, opacity };
const lineStyle =
isConfidenceBand || (stack === StackControlsValue.Stream && area)
? { ...opts.lineStyle, opacity: OpacityEnum.Transparent }
: { ...opts.lineStyle, opacity };
return {
...series,
queryIndex,
Expand All @@ -208,7 +281,10 @@ export function transformSeries(
? seriesType
: undefined,
stack: stackId,
stackStrategy: isConfidenceBand ? 'all' : 'samesign',
stackStrategy:
isConfidenceBand || stack === StackControlsValue.Stream
? 'all'
: 'samesign',
lineStyle,
areaStyle:
area || forecastSeries.type === ForecastSeriesEnum.ForecastUpper
Expand All @@ -234,7 +310,7 @@ export function transformSeries(
const { value, dataIndex, seriesIndex, seriesName } = params;
const numericValue = isHorizontal ? value[0] : value[1];
const isSelectedLegend = currentSeries.legend === seriesName;
const isAreaExpand = stack === AreaChartExtraControlsValue.Expand;
const isAreaExpand = stack === StackControlsValue.Expand;
if (!formatter) return numericValue;
if (!stack || isSelectedLegend) return formatter(numericValue);
if (!onlyTotal) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
RadioButtonOption,
sharedControlComponents,
} from '@superset-ui/chart-controls';
import { AreaChartExtraControlsOptions } from '../constants';
import { AreaChartStackControlOptions } from '../constants';

const { RadioButtonControl } = sharedControlComponents;

Expand Down Expand Up @@ -53,7 +53,7 @@ export function useExtraControl<

const extraControlsOptions = useMemo(() => {
if (area) {
return AreaChartExtraControlsOptions;
return AreaChartStackControlOptions;
}
return [];
}, [area]);
Expand Down
14 changes: 10 additions & 4 deletions superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,26 @@ export enum OpacityEnum {
NonTransparent = 1,
}

export enum AreaChartExtraControlsValue {
export enum StackControlsValue {
Stack = 'Stack',
Stream = 'Stream',
Expand = 'Expand',
}

export const AreaChartExtraControlsOptions: [
export const StackControlOptions: [
JsonValue,
Exclude<ReactNode, null | undefined | boolean>,
][] = [
[null, t('None')],
[AreaChartExtraControlsValue.Stack, t('Stack')],
[AreaChartExtraControlsValue.Expand, t('Expand')],
[StackControlsValue.Stack, t('Stack')],
[StackControlsValue.Stream, t('Stream')],
];

export const AreaChartStackControlOptions: [
JsonValue,
Exclude<ReactNode, null | undefined | boolean>,
][] = [...StackControlOptions, [StackControlsValue.Expand, t('Expand')]];

export const TIMEGRAIN_TO_TIMESTAMP = {
[TimeGranularity.HOUR]: 3600 * 1000,
[TimeGranularity.DAY]: 3600 * 1000 * 24,
Expand Down
10 changes: 6 additions & 4 deletions superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import {
DEFAULT_LEGEND_FORM_DATA,
DEFAULT_SORT_SERIES_DATA,
StackControlOptions,
} from './constants';
import { DEFAULT_FORM_DATA } from './Timeseries/constants';
import { SortSeriesType } from './types';
Expand Down Expand Up @@ -119,10 +120,11 @@ export const showValueControl: ControlSetItem = {
export const stackControl: ControlSetItem = {
name: 'stack',
config: {
type: 'CheckboxControl',
label: t('Stack series'),
type: 'SelectControl',
label: t('Stacked Style'),
renderTrigger: true,
default: false,
choices: StackControlOptions,
default: null,
description: t('Stack series on top of each other'),
},
};
Expand All @@ -142,7 +144,7 @@ export const onlyTotalControl: ControlSetItem = {
},
};

const percentageThresholdControl: ControlSetItem = {
export const percentageThresholdControl: ControlSetItem = {
name: 'percentage_threshold',
config: {
type: 'TextControl',
Expand Down
4 changes: 2 additions & 2 deletions superset-frontend/plugins/plugin-chart-echarts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
} from '@superset-ui/core';
import { EChartsCoreOption, ECharts } from 'echarts';
import { TooltipMarker } from 'echarts/types/src/util/format';
import { AreaChartExtraControlsValue } from './constants';
import { StackControlsValue } from './constants';

export type EchartsStylesProps = {
height: number;
Expand Down Expand Up @@ -159,7 +159,7 @@ export interface TitleFormData {
yAxisTitlePosition: string;
}

export type StackType = boolean | null | Partial<AreaChartExtraControlsValue>;
export type StackType = boolean | null | Partial<StackControlsValue>;

export interface TreePathInfo {
name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { format, LegendComponentOption, SeriesOption } from 'echarts';
import { sumBy, meanBy, minBy, maxBy, orderBy } from 'lodash';
import {
AreaChartExtraControlsValue,
StackControlsValue,
NULL_STRING,
TIMESERIES_CONSTANTS,
} from '../constants';
Expand Down Expand Up @@ -207,7 +207,7 @@ export function extractSeries(
if (isFillNeighborValue) {
value = fillNeighborValue;
} else if (
stack === AreaChartExtraControlsValue.Expand &&
stack === StackControlsValue.Expand &&
totalStackedValues.length > 0
) {
value = ((value || 0) as number) / totalStackedValues[idx];
Expand Down
Loading

0 comments on commit b0d83e8

Please sign in to comment.