fix: Total calculation in stacked Timeseries charts (#24477)

This commit is contained in:
Michael S. Molina 2023-06-23 11:57:48 -03:00 committed by GitHub
parent 51a34d7d58
commit c5b4ecdca5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 89 additions and 94 deletions

View File

@ -30,7 +30,12 @@ import {
FilterState,
JsonObject,
} from '../..';
import { HandlerFunction, PlainObject, SetDataMaskHook } from '../types/Base';
import {
HandlerFunction,
LegendState,
PlainObject,
SetDataMaskHook,
} from '../types/Base';
import { QueryData, DataRecordFilters } from '..';
import { SupersetTheme } from '../../style';
@ -54,6 +59,8 @@ type Hooks = {
onContextMenu?: HandlerFunction;
/** handle errors */
onError?: HandlerFunction;
/** handle legend state changes */
onLegendStateChanged?: HandlerFunction;
/** use the vis as control to update state */
setControlValue?: HandlerFunction;
/** handle external filters */
@ -88,6 +95,8 @@ export interface ChartPropsConfig {
ownState?: JsonObject;
/** Filter state that saved in dashboard */
filterState?: FilterState;
/** Legend state */
legendState?: LegendState;
/** Set of actual behaviors that this instance of chart should use */
behaviors?: Behavior[];
/** Chart display settings related to current view context */
@ -128,6 +137,8 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
filterState: FilterState;
legendState?: LegendState;
queriesData: QueryData[];
width: number;
@ -156,6 +167,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
hooks = {},
ownState = {},
filterState = {},
legendState,
initialValues = {},
queriesData = [],
behaviors = [],
@ -181,6 +193,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
this.queriesData = queriesData;
this.ownState = ownState;
this.filterState = filterState;
this.legendState = legendState;
this.behaviors = behaviors;
this.displaySettings = displaySettings;
this.appSection = appSection;
@ -205,6 +218,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
input => input.width,
input => input.ownState,
input => input.filterState,
input => input.legendState,
input => input.behaviors,
input => input.displaySettings,
input => input.appSection,
@ -224,6 +238,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
width,
ownState,
filterState,
legendState,
behaviors,
displaySettings,
appSection,
@ -243,6 +258,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
queriesData,
ownState,
filterState,
legendState,
width,
behaviors,
displaySettings,

View File

@ -100,4 +100,8 @@ export enum AxisType {
log = 'log',
}
export interface LegendState {
[key: string]: boolean;
}
export default {};

View File

@ -29,7 +29,7 @@ import {
import { EchartsMixedTimeseriesChartTransformedProps } from './types';
import Echart from '../components/Echart';
import { EventHandlers } from '../types';
import { currentSeries, formatSeriesName } from '../utils/series';
import { formatSeriesName } from '../utils/series';
export default function EchartsMixedTimeseries({
height,
@ -123,12 +123,6 @@ export default function EchartsMixedTimeseries({
const { seriesName, seriesIndex } = props;
handleChange(seriesName, seriesIndex);
},
mouseout: () => {
currentSeries.name = '';
},
mouseover: params => {
currentSeries.name = params.seriesName;
},
contextmenu: async eventParams => {
if (onContextMenu) {
eventParams.event.stop();

View File

@ -51,7 +51,6 @@ import {
import { parseYAxisBound } from '../utils/controls';
import {
getOverMaxHiddenFormatter,
currentSeries,
dedupSeries,
extractSeries,
getAxisType,
@ -481,11 +480,7 @@ export default function transformProps(
seriesName: key,
formatter: primarySeries.has(key) ? formatter : formatterSecondary,
});
if (currentSeries.name === key) {
rows.push(`<span style="font-weight: 700">${content}</span>`);
} else {
rows.push(`<span style="opacity: 0.7">${content}</span>`);
}
rows.push(`<span style="opacity: 0.7">${content}</span>`);
});
return rows.join('<br />');
},

View File

@ -24,6 +24,7 @@ import {
getTimeFormatter,
getColumnLabel,
getNumberFormatter,
LegendState,
} from '@superset-ui/core';
import { ViewRootGroup } from 'echarts/types/src/util/types';
import GlobalModel from 'echarts/types/src/model/Global';
@ -31,12 +32,11 @@ import ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
import { TimeseriesChartTransformedProps } from './types';
import { currentSeries, formatSeriesName } from '../utils/series';
import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
// @ts-ignore
export default function EchartsTimeseries({
formData,
height,
@ -49,6 +49,7 @@ export default function EchartsTimeseries({
setControlValue,
legendData = [],
onContextMenu,
onLegendStateChanged,
xValueFormatter,
xAxis,
refs,
@ -59,8 +60,6 @@ export default function EchartsTimeseries({
const echartRef = useRef<EchartsHandler | null>(null);
// eslint-disable-next-line no-param-reassign
refs.echartRef = echartRef;
const lastTimeRef = useRef(Date.now());
const lastSelectedLegend = useRef('');
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraControlHeight, setExtraControlHeight] = useState(0);
@ -69,34 +68,6 @@ export default function EchartsTimeseries({
setExtraControlHeight(updatedHeight);
}, [formData.showExtraControls]);
const handleDoubleClickChange = useCallback(
(name?: string) => {
const echartInstance = echartRef.current?.getEchartInstance();
if (!name) {
currentSeries.legend = '';
echartInstance?.dispatchAction({
type: 'legendAllSelect',
});
} else {
legendData.forEach(datum => {
if (datum === name) {
currentSeries.legend = datum;
echartInstance?.dispatchAction({
type: 'legendSelect',
name: datum,
});
} else {
echartInstance?.dispatchAction({
type: 'legendUnSelect',
name: datum,
});
}
});
}
},
[legendData],
);
const getModelInfo = (target: ViewRootGroup, globalModel: GlobalModel) => {
let el = target;
let model: ComponentModel | null = null;
@ -175,30 +146,14 @@ export default function EchartsTimeseries({
handleChange(name);
}, TIMER_DURATION);
},
mouseout: () => {
currentSeries.name = '';
},
mouseover: params => {
currentSeries.name = params.seriesName;
},
legendselectchanged: payload => {
const currentTime = Date.now();
// TIMER_DURATION is the interval between two legendselectchanged event
if (
currentTime - lastTimeRef.current < TIMER_DURATION &&
lastSelectedLegend.current === payload.name
) {
// execute dbclick
handleDoubleClickChange(payload.name);
} else {
lastTimeRef.current = currentTime;
// remember last selected legend
lastSelectedLegend.current = payload.name;
}
// if all legend is unselected, we keep all selected
if (Object.values(payload.selected).every(i => !i)) {
handleDoubleClickChange();
}
onLegendStateChanged?.(payload.selected);
},
legendselectall: payload => {
onLegendStateChanged?.(payload.selected);
},
legendinverseselect: payload => {
onLegendStateChanged?.(payload.selected);
},
contextmenu: async eventParams => {
if (onContextMenu) {
@ -272,15 +227,16 @@ export default function EchartsTimeseries({
// @ts-ignore
const globalModel = echartInstance.getModel();
const model = getModelInfo(params.target, globalModel);
const seriesCount = globalModel.getSeriesCount();
const currentSeriesIndices = globalModel.getCurrentSeriesIndices();
if (model) {
const { name } = model;
if (seriesCount !== currentSeriesIndices.length) {
handleDoubleClickChange();
} else {
handleDoubleClickChange(name);
}
const legendState: LegendState = legendData.reduce(
(previous, datum) => ({
...previous,
[datum]: datum === name,
}),
{},
);
onLegendStateChanged?.(legendState);
}
}
},
@ -292,6 +248,7 @@ export default function EchartsTimeseries({
<ExtraControls formData={formData} setControlValue={setControlValue} />
</div>
<Echart
ref={echartRef}
refs={refs}
height={height - extraControlHeight}
width={width}

View File

@ -54,7 +54,6 @@ import { ForecastSeriesEnum, ForecastValue, Refs } from '../types';
import { parseYAxisBound } from '../utils/controls';
import {
calculateLowerLogTick,
currentSeries,
dedupSeries,
extractDataTotalValues,
extractSeries,
@ -101,6 +100,7 @@ export default function transformProps(
width,
height,
filterState,
legendState,
formData,
hooks,
queriesData,
@ -192,6 +192,7 @@ export default function transformProps(
stack,
percentageThreshold,
xAxisCol: xAxisLabel,
legendState,
},
);
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map(
@ -221,6 +222,7 @@ export default function transformProps(
stack,
onlyTotal,
isHorizontal,
legendState,
});
const seriesContexts = extractForecastSeriesContexts(
Object.values(rawSeries).map(series => series.name as string),
@ -258,6 +260,7 @@ export default function transformProps(
markerSize,
areaOpacity: opacity,
seriesType,
legendState,
stack,
formatter,
showValue,
@ -379,6 +382,7 @@ export default function transformProps(
setDataMask = () => {},
setControlValue = () => {},
onContextMenu,
onLegendStateChanged,
} = hooks;
const addYAxisLabelOffset = !!yAxisTitle;
@ -486,7 +490,7 @@ export default function transformProps(
seriesName: key,
formatter,
});
if (currentSeries.name === key) {
if (!legendState || legendState[key]) {
rows.push(`<span style="font-weight: 700">${content}</span>`);
} else {
rows.push(`<span style="opacity: 0.7">${content}</span>`);
@ -506,6 +510,7 @@ export default function transformProps(
showLegend,
theme,
zoomable,
legendState,
),
data: legendData as string[],
},
@ -549,6 +554,7 @@ export default function transformProps(
width,
legendData,
onContextMenu,
onLegendStateChanged,
xValueFormatter: tooltipFormatter,
xAxis: {
label: xAxisLabel,

View File

@ -27,6 +27,7 @@ import {
getTimeFormatter,
IntervalAnnotationLayer,
isTimeseriesAnnotationResult,
LegendState,
NumberFormatter,
smartDateDetailedFormatter,
smartDateFormatter,
@ -65,7 +66,7 @@ import {
formatAnnotationLabel,
parseAnnotationOpacity,
} from '../utils/annotation';
import { currentSeries, getChartPadding } from '../utils/series';
import { getChartPadding } from '../utils/series';
import {
OpacityEnum,
StackControlsValue,
@ -156,6 +157,7 @@ export function transformSeries(
yAxisIndex?: number;
showValue?: boolean;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: NumberFormatter;
totalStackedValues?: number[];
showValueIndexes?: number[];
@ -182,6 +184,7 @@ export function transformSeries(
showValue,
onlyTotal,
formatter,
legendState,
totalStackedValues = [],
showValueIndexes = [],
thresholdValues = [],
@ -308,10 +311,14 @@ export function transformSeries(
formatter: (params: any) => {
const { value, dataIndex, seriesIndex, seriesName } = params;
const numericValue = isHorizontal ? value[0] : value[1];
const isSelectedLegend = currentSeries.legend === seriesName;
const isSelectedLegend = !legendState || legendState[seriesName];
const isAreaExpand = stack === StackControlsValue.Expand;
if (!formatter) return numericValue;
if (!stack || isSelectedLegend) return formatter(numericValue);
if (!formatter) {
return numericValue;
}
if (!stack && isSelectedLegend) {
return formatter(numericValue);
}
if (!onlyTotal) {
if (
numericValue >=

View File

@ -23,6 +23,7 @@ import {
ContextMenuFilters,
FilterState,
HandlerFunction,
LegendState,
PlainObject,
QueryFormColumn,
SetDataMaskHook,
@ -127,6 +128,7 @@ export interface BaseTransformedProps<F> {
filters?: ContextMenuFilters,
) => void;
setDataMask?: SetDataMaskHook;
onLegendStateChanged?: (state: LegendState) => void;
filterState?: FilterState;
refs: Refs;
width: number;

View File

@ -30,6 +30,7 @@ import {
TimeFormatter,
SupersetTheme,
normalizeTimestamp,
LegendState,
} from '@superset-ui/core';
import { SortSeriesType } from '@superset-ui/chart-controls';
import { format, LegendComponentOption, SeriesOption } from 'echarts';
@ -52,6 +53,7 @@ export function extractDataTotalValues(
stack: StackType;
percentageThreshold: number;
xAxisCol: string;
legendState?: LegendState;
},
): {
totalStackedValues: number[];
@ -59,13 +61,16 @@ export function extractDataTotalValues(
} {
const totalStackedValues: number[] = [];
const thresholdValues: number[] = [];
const { stack, percentageThreshold, xAxisCol } = opts;
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);
@ -85,23 +90,28 @@ export function extractShowValueIndexes(
stack: StackType;
onlyTotal?: boolean;
isHorizontal?: boolean;
legendState?: LegendState;
},
): number[] {
const showValueIndexes: number[] = [];
if (opts.stack) {
const { legendState, stack, isHorizontal, onlyTotal } = opts;
if (stack) {
series.forEach((entry, seriesIndex) => {
const { data = [] } = entry;
(data as [any, number][]).forEach((datum, dataIndex) => {
if (!opts.onlyTotal && datum[opts.isHorizontal ? 0 : 1] !== null) {
if (entry.id && legendState && !legendState[entry.id]) {
return;
}
if (!onlyTotal && datum[isHorizontal ? 0 : 1] !== null) {
showValueIndexes[dataIndex] = seriesIndex;
}
if (opts.onlyTotal) {
if (datum[opts.isHorizontal ? 0 : 1] > 0) {
if (onlyTotal) {
if (datum[isHorizontal ? 0 : 1] > 0) {
showValueIndexes[dataIndex] = seriesIndex;
}
if (
!showValueIndexes[dataIndex] &&
datum[opts.isHorizontal ? 0 : 1] !== null
datum[isHorizontal ? 0 : 1] !== null
) {
showValueIndexes[dataIndex] = seriesIndex;
}
@ -404,6 +414,7 @@ export function getLegendProps(
show: boolean,
theme: SupersetTheme,
zoomable = false,
legendState?: LegendState,
): LegendComponentOption | LegendComponentOption[] {
const legend: LegendComponentOption | LegendComponentOption[] = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
@ -413,6 +424,7 @@ export function getLegendProps(
: 'vertical',
show,
type,
selected: legendState,
selector: ['all', 'inverse'],
selectorLabel: {
fontFamily: theme.typography.families.sansSerif,
@ -495,12 +507,6 @@ export function sanitizeHtml(text: string): string {
return format.encodeHTML(text);
}
// TODO: Better use other method to maintain this state
export const currentSeries = {
name: '',
legend: '',
};
export function getAxisType(dataType?: GenericDataType): AxisType {
if (dataType === GenericDataType.TEMPORAL) {
return AxisType.time;

View File

@ -90,6 +90,7 @@ class ChartRenderer extends React.Component {
(isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) ||
isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)),
inContextMenu: false,
legendState: undefined,
};
this.hasQueryResponseChange = false;
@ -102,6 +103,7 @@ class ChartRenderer extends React.Component {
this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
this.hooks = {
@ -113,6 +115,7 @@ class ChartRenderer extends React.Component {
setControlValue: this.handleSetControlValue,
onFilterMenuOpen: this.props.onFilterMenuOpen,
onFilterMenuClose: this.props.onFilterMenuClose,
onLegendStateChanged: this.handleLegendStateChanged,
setDataMask: dataMask => {
this.props.actions?.updateDataMask(this.props.chartId, dataMask);
},
@ -226,6 +229,10 @@ class ChartRenderer extends React.Component {
this.setState({ inContextMenu: false });
}
handleLegendStateChanged(legendState) {
this.setState({ legendState });
}
// When viz plugins don't handle `contextmenu` event, fallback handler
// calls `handleOnContextMenu` with no `filters` param.
onContextMenuFallback(event) {
@ -354,6 +361,7 @@ class ChartRenderer extends React.Component {
noResults={noResultsComponent}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
legendState={this.state.legendState}
{...drillToDetailProps}
/>
</div>