diff --git a/src/component/tooltip/seriesFormatTooltip.ts b/src/component/tooltip/seriesFormatTooltip.ts index 714cac2dfd..9e6c1fba29 100644 --- a/src/component/tooltip/seriesFormatTooltip.ts +++ b/src/component/tooltip/seriesFormatTooltip.ts @@ -65,6 +65,14 @@ export function defaultSeriesFormatTooltip(opt: { const dimInfo = data.getDimensionInfo(tooltipDims[0]); sortParam = inlineValue = retrieveRawValue(data, dataIndex, tooltipDims[0]); inlineValueType = dimInfo.type; + const isPercentStackEnabled = data.getCalculationInfo('isPercentStackEnabled'); + if (isPercentStackEnabled) { + // Append the normalized value (as a percent of the total stack) when stackPercent is true. + const params = series.getDataParams(dataIndex); + if (params.percent != null) { + inlineValue = `${inlineValue} (${params.percent}%)`; + } + } } else { sortParam = inlineValue = isValueArr ? value[0] : value; diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index 9a824c05a4..f453427aab 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -135,6 +135,7 @@ export interface DataCalculationInfo { stackedOverDimension: DimensionName; stackResultDimension: DimensionName; stackedOnSeries?: SERIES_MODEL; + isPercentStackEnabled?: boolean; } // ----------------------------- diff --git a/src/data/helper/dataStackHelper.ts b/src/data/helper/dataStackHelper.ts index 549820c912..b726ea6cb5 100644 --- a/src/data/helper/dataStackHelper.ts +++ b/src/data/helper/dataStackHelper.ts @@ -69,6 +69,7 @@ export function enableDataStack( | 'isStackedByIndex' | 'stackedOverDimension' | 'stackResultDimension' + | 'isPercentStackEnabled' > { opt = opt || {}; let byIndex = opt.byIndex; @@ -192,7 +193,8 @@ export function enableDataStack( stackedByDimension: stackedByDimInfo && stackedByDimInfo.name, isStackedByIndex: byIndex, stackedOverDimension: stackedOverDimension, - stackResultDimension: stackResultDimension + stackResultDimension: stackResultDimension, + isPercentStackEnabled: seriesModel.get('stackPercent'), }; } diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index cb979216fd..395bce10fd 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -485,14 +485,19 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const baseDimIdx = data.getDimensionIndex(data.mapDimension(baseAxis.dim)); const drawBackground = seriesModel.get('showBackground', true); const valueDim = data.mapDimension(valueAxis.dim); - const stackResultDim = data.getCalculationInfo('stackResultDimension'); - const stacked = isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries'); const isValueAxisH = valueAxis.isHorizontal(); const valueAxisStart = getValueAxisStart(baseAxis, valueAxis); const isLarge = isInLargeMode(seriesModel); const barMinHeight = seriesModel.get('barMinHeight') || 0; + // Determine stacked dimensions. + const stackResultDim = data.getCalculationInfo('stackResultDimension'); const stackedDimIdx = stackResultDim && data.getDimensionIndex(stackResultDim); + const stackedOverDim = data.getCalculationInfo('stackedOverDimension'); + const stackedOverDimIdx = stackedOverDim && data.getDimensionIndex(stackedOverDim); + const isPercentStackEnabled = seriesModel.get('stackPercent'); + const stacked = isPercentStackEnabled + || (isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries')); // Layout info. const columnWidth = data.getLayout('size'); @@ -521,7 +526,16 @@ export function createProgressiveLayout(seriesType: string): StageHandler { // Because of the barMinHeight, we can not use the value in // stackResultDimension directly. if (stacked) { - stackStartValue = +value - (store.get(valueDimIdx, dataIndex) as number); + if (isPercentStackEnabled) { + // When percentStack is true, use the normalized bottom edge (stackedOverDimension) + // as the start value of the bar segment. + stackStartValue = store.get(stackedOverDimIdx, dataIndex); + } + else { + // For standard (non-percent) stack, subtract the original value from the + // stacked total to compute the bar segment's start value. + stackStartValue = +value - (store.get(valueDimIdx, dataIndex) as number); + } } let x; diff --git a/src/model/mixin/dataFormat.ts b/src/model/mixin/dataFormat.ts index 2c39816235..9c209f23fd 100644 --- a/src/model/mixin/dataFormat.ts +++ b/src/model/mixin/dataFormat.ts @@ -36,6 +36,7 @@ import { import GlobalModel from '../Global'; import { TooltipMarkupBlockFragment } from '../../component/tooltip/tooltipMarkup'; import { error, makePrintable } from '../../util/log'; +import { round } from '../../util/number'; const DIMENSION_LABEL_REG = /\{@(.+?)\}/g; @@ -72,7 +73,7 @@ export class DataFormatMixin { const isSeries = mainType === 'series'; const userOutput = data.userOutput && data.userOutput.get(); - return { + const params: CallbackDataParams = { componentType: mainType, componentSubType: this.subType, componentIndex: this.componentIndex, @@ -93,6 +94,20 @@ export class DataFormatMixin { // Param name list for mapping `a`, `b`, `c`, `d`, `e` $vars: ['seriesName', 'name', 'value'] }; + + const isPercentStackEnabled = data.getCalculationInfo('isPercentStackEnabled'); + if (isPercentStackEnabled) { + // Include the normalized value when stackPercent is true. + const stackResultDim = data.getCalculationInfo('stackResultDimension'); + const stackedOverDim = data.getCalculationInfo('stackedOverDimension'); + const stackTop = data.get(stackResultDim, dataIndex) as number; + const stackBottom = data.get(stackedOverDim, dataIndex) as number; + if (!isNaN(stackTop) && !isNaN(stackBottom)) { + const normalizedValue = stackTop - stackBottom; + params.percent = round(normalizedValue, 2); + } + } + return params; } /** diff --git a/src/processor/dataStack.ts b/src/processor/dataStack.ts index f83c7f90f6..44e5552784 100644 --- a/src/processor/dataStack.ts +++ b/src/processor/dataStack.ts @@ -20,21 +20,9 @@ import {createHashMap, each} from 'zrender/src/core/util'; import GlobalModel from '../model/Global'; import SeriesModel from '../model/Series'; -import { SeriesOption, SeriesStackOptionMixin } from '../util/types'; -import SeriesData, { DataCalculationInfo } from '../data/SeriesData'; +import { SeriesOption, SeriesStackOptionMixin, StackInfo } from '../util/types'; import { addSafe } from '../util/number'; - -type StackInfo = Pick< - DataCalculationInfo, - 'stackedDimension' - | 'isStackedByIndex' - | 'stackedByDimension' - | 'stackResultDimension' - | 'stackedOverDimension' -> & { - data: SeriesData - seriesModel: SeriesModel -}; +import { calculatePercentStack } from '../util/stack'; // (1) [Caution]: the logic is correct based on the premises: // data processing stage is blocked in stream. @@ -95,11 +83,17 @@ export default function dataStack(ecModel: GlobalModel) { }); // Calculate stack values - calculateStack(stackInfoList); + const isPercentStackEnabled = stackInfoList.some((info) => info.seriesModel.get('stackPercent')); + if (isPercentStackEnabled) { + calculatePercentStack(stackInfoList); + } + else { + calculateStandardStack(stackInfoList); + } }); } -function calculateStack(stackInfoList: StackInfo[]) { +function calculateStandardStack(stackInfoList: StackInfo[]) { each(stackInfoList, function (targetStackInfo, idxInStack) { const resultVal: number[] = []; const resultNaN = [NaN, NaN]; diff --git a/src/util/stack.ts b/src/util/stack.ts new file mode 100644 index 0000000000..5ca76093e2 --- /dev/null +++ b/src/util/stack.ts @@ -0,0 +1,94 @@ +/* +* 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 { each } from 'zrender/src/core/util'; +import { addSafe } from './number'; +import { StackInfo } from './types'; + +/** + * Percent stackStrategy logic to normalize each value as a percentage of the total per index. + */ +export function calculatePercentStack(stackInfoList: StackInfo[]) { + const dataLength = stackInfoList[0].data.count(); + if (dataLength === 0) { + return; + } + + // Calculate totals per data index across all series in the stack group. + const totals = calculateStackTotals(stackInfoList, dataLength); + + // Used to track running total of percent values at each index. + const cumulativePercents = new Float64Array(dataLength); + + const resultNaN = [NaN, NaN]; + + each(stackInfoList, function (targetStackInfo) { + const resultVal: number[] = []; + const dims: [string, string] = [targetStackInfo.stackResultDimension, targetStackInfo.stackedOverDimension]; + const targetData = targetStackInfo.data; + const stackedDim = targetStackInfo.stackedDimension; + + // Should not write on raw data, because stack series model list changes + // depending on legend selection. + targetData.modify(dims, function (v0, v1, dataIndex) { + const rawValue = targetData.get(stackedDim, dataIndex) as number; + + // Consider `connectNulls` of line area, if value is NaN, stackedOver + // should also be NaN, to draw a appropriate belt area. + if (isNaN(rawValue)) { + return resultNaN; + } + + // Pre-calculated total for this specific data index. + const total = totals[dataIndex]; + + // Percentage contribution of this segment. + const percent = total === 0 ? 0 : (rawValue / total) * 100; + + // Bottom edge of this segment (cumulative % before this series). + const stackedOver = cumulativePercents[dataIndex]; + + // Update the cumulative percentage for the next series at this index to use. + cumulativePercents[dataIndex] = addSafe(stackedOver, percent); + + // Result: [Top edge %, Bottom edge %] + resultVal[0] = cumulativePercents[dataIndex]; + resultVal[1] = stackedOver; + return resultVal; + }); + }); +} + +/** +* Helper to calculate the total value across all series for each data index. +*/ +function calculateStackTotals(stackInfoList: StackInfo[], dataLength: number): number[] { + const totals = Array(dataLength).fill(0); + each(stackInfoList, (stackInfo) => { + const data = stackInfo.data; + const dim = stackInfo.stackedDimension; + for (let i = 0; i < dataLength; i++) { + const val = data.get(dim, i) as number; + if (!isNaN(val)) { + totals[i] = addSafe(totals[i], val); + } + } + }); + return totals; +} diff --git a/src/util/types.ts b/src/util/types.ts index 2a37e39a0f..96903b6e6b 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -33,7 +33,7 @@ import ExtensionAPI from '../core/ExtensionAPI'; import SeriesModel from '../model/Series'; import { createHashMap, HashMap } from 'zrender/src/core/util'; import { TaskPlanCallbackReturn, TaskProgressParams } from '../core/task'; -import SeriesData from '../data/SeriesData'; +import SeriesData, { DataCalculationInfo } from '../data/SeriesData'; import { Dictionary, ElementEventName, ImageLike, TextAlign, TextVerticalAlign } from 'zrender/src/core/types'; import { PatternObject } from 'zrender/src/graphic/Pattern'; import { TooltipMarker } from './format'; @@ -880,7 +880,7 @@ export interface CallbackDataParams { marker?: TooltipMarker; status?: DisplayState; dimensionIndex?: number; - percent?: number; // Only for chart like 'pie' + percent?: number; // Only for chart like 'pie' or when 'stackPercent' is used. // Param name list for mapping `a`, `b`, `c`, `d`, `e` $vars: string[]; @@ -1983,7 +1983,21 @@ export interface SeriesStackOptionMixin { stack?: string stackStrategy?: 'samesign' | 'all' | 'positive' | 'negative'; stackOrder?: 'seriesAsc' | 'seriesDesc'; // default: seriesAsc -} + stackPercent?: boolean; +} + +export type StackInfo = Pick< + DataCalculationInfo, + 'stackedDimension' + | 'isStackedByIndex' + | 'stackedByDimension' + | 'stackResultDimension' + | 'stackedOverDimension' + | 'isPercentStackEnabled' +> & { + data: SeriesData + seriesModel: SeriesModel +}; type SamplingFunc = (frame: ArrayLike) => number; diff --git a/test/percent-stack.html b/test/percent-stack.html new file mode 100644 index 0000000000..9737a8681d --- /dev/null +++ b/test/percent-stack.html @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/ut/spec/util/stack.test.ts b/test/ut/spec/util/stack.test.ts new file mode 100644 index 0000000000..724547e1d3 --- /dev/null +++ b/test/ut/spec/util/stack.test.ts @@ -0,0 +1,182 @@ + +/* +* 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 { calculatePercentStack } from '@/src/util/stack'; +import { StackInfo, SOURCE_FORMAT_ARRAY_ROWS } from '@/src/util/types'; +import Model from '@/src/model/Model'; +import SeriesModel from '@/src/model/Series'; +import SeriesData from '@/src/data/SeriesData'; +import { createSourceFromSeriesDataOption } from '@/src/data/Source'; +import prepareSeriesDataSchema from '@/src/data/helper/createDimensions'; +import SeriesDimensionDefine from '@/src/data/SeriesDimensionDefine'; + +function createMockSeriesDataFromSchema(dimensions: string[], rows: (number | null)[][]): SeriesData { + const hostModel = new Model(); + const source = createSourceFromSeriesDataOption({ + sourceFormat: SOURCE_FORMAT_ARRAY_ROWS, + dimensions, + data: rows as unknown as any[], + }); + const schema = prepareSeriesDataSchema(source, { + coordDimensions: dimensions.map((name) => ({ name })), + }); + const storeDimStart = schema.dimensions.length; + schema.dimensions.push(new SeriesDimensionDefine({ + name: '__stack_result__', + type: 'float', + isCalculationCoord: true, + storeDimIndex: storeDimStart + })); + schema.dimensions.push(new SeriesDimensionDefine({ + name: '__stacked_over__', + type: 'float', + isCalculationCoord: true, + storeDimIndex: storeDimStart + 1 + })); + const paddedRows = rows.map((row) => { + const full = [...row]; + while (full.length < schema.dimensions.length) { + full.push(NaN); + } + return full; + }); + const seriesData = new SeriesData(schema, hostModel); + seriesData.initData(paddedRows); + return seriesData; +} + +function createMockStackInfo(name: string, values: number[][]): StackInfo { + const data = createMockSeriesDataFromSchema(['x', 'y'], values); + const yInfo = data.getDimensionInfo('y'); + if (yInfo) { + yInfo.isCalculationCoord = true; + } + data.setCalculationInfo({ + stackedByDimension: 'x', + stackedDimension: 'y', + stackResultDimension: '__stack_result__', + stackedOverDimension: '__stacked_over__', + isStackedByIndex: true, + }); + const seriesModel = { + name, + option: { + type: 'bar', + stack: 'total', + stackStrategy: 'percent', + data: values[0], + }, + } as unknown as SeriesModel; + return { + data, + seriesModel, + stackedDimension: 'y', + stackedByDimension: '__stack_by__', + stackResultDimension: '__stack_result__', + stackedOverDimension: '__stacked_over__', + isStackedByIndex: true, + }; +} + +describe('util/stack', function () { + describe('calculatePercentStack', function () { + it('should compute percent-stacked values for two series', function () { + const stackInfoList: StackInfo[] = [ + createMockStackInfo('a', [ + [0, 10], + [1, 20], + [2, 30], + ]), + createMockStackInfo('b', [ + [0, 40], + [1, 20], + [2, 10], + ]), + ]; + calculatePercentStack(stackInfoList); + const firstSeriesData = stackInfoList[0].data; + expect(firstSeriesData.mapArray('__stack_result__', Number)).toEqual([20, 50, 75]); + expect(firstSeriesData.mapArray('__stack_result__', Number)).toEqual([20, 50, 75]); + expect(firstSeriesData.mapArray('__stacked_over__', Number)).toEqual([0, 0, 0]); + const secondSeriesData = stackInfoList[1].data; + expect(secondSeriesData.mapArray('__stack_result__', Number)).toEqual([100, 100, 100]); + expect(secondSeriesData.mapArray('__stacked_over__', Number)).toEqual([20, 50, 75]); + }); + + it('should compute percent-stacked values for two series with varying magnitudes', function () { + const stackInfoList: StackInfo[] = [ + createMockStackInfo('a', [ + [0, 50], + [1, 2], + [2, 1000], + [3, 0], + ]), + createMockStackInfo('b', [ + [0, 50], + [1, 8], + [2, 1000], + [3, 1], + ]), + ]; + calculatePercentStack(stackInfoList); + const firstSeriesData = stackInfoList[0].data; + expect(firstSeriesData.mapArray('__stack_result__', Number)).toEqual( + [50, 20, 50, 0] + ); + expect(firstSeriesData.mapArray('__stacked_over__', Number)).toEqual( + [0, 0, 0, 0] + ); + const secondSeriesData = stackInfoList[1].data; + expect(secondSeriesData.mapArray('__stack_result__', Number)).toEqual( + [100, 100, 100, 100] + ); + expect(secondSeriesData.mapArray('__stacked_over__', Number)).toEqual( + [50, 20, 50, 0] + ); + }); + + it('should compute percent-stacked values for three series', function () { + const stackInfoList: StackInfo[] = [ + createMockStackInfo('a', [ + [0, 1000], + [1, 1000], + ]), + createMockStackInfo('b', [ + [0, 2000], + [1, 3000], + ]), + createMockStackInfo('c', [ + [0, 7000], + [1, 6000], + ]), + ]; + calculatePercentStack(stackInfoList); + const [a, b, c] = stackInfoList.map((s) => s.data); + expect(a.mapArray('__stack_result__', Number)).toEqual([10, 10]); + expect(a.mapArray('__stacked_over__', Number)).toEqual([0, 0]); + + expect(b.mapArray('__stack_result__', Number)).toEqual([30, 40]); + expect(b.mapArray('__stacked_over__', Number)).toEqual([10, 10]); + + expect(c.mapArray('__stack_result__', Number)).toEqual([100, 100]); + expect(c.mapArray('__stacked_over__', Number)).toEqual([30, 40]); + }); + }); +});