Skip to content

Commit cc245c5

Browse files
committed
feat: support stackPercent for normalized stacking
1 parent d0d53db commit cc245c5

File tree

10 files changed

+809
-24
lines changed

10 files changed

+809
-24
lines changed

src/component/tooltip/seriesFormatTooltip.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ export function defaultSeriesFormatTooltip(opt: {
6565
const dimInfo = data.getDimensionInfo(tooltipDims[0]);
6666
sortParam = inlineValue = retrieveRawValue(data, dataIndex, tooltipDims[0]);
6767
inlineValueType = dimInfo.type;
68+
const isPercentStackEnabled = data.getCalculationInfo('isPercentStackEnabled');
69+
if (isPercentStackEnabled) {
70+
// Append the normalized value (as a percent of the total stack) when stackPercent is true.
71+
const params = series.getDataParams(dataIndex);
72+
if (params.percent != null) {
73+
inlineValue = `${inlineValue} (${params.percent}%)`;
74+
}
75+
}
6876
}
6977
else {
7078
sortParam = inlineValue = isValueArr ? value[0] : value;

src/data/SeriesData.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export interface DataCalculationInfo<SERIES_MODEL> {
135135
stackedOverDimension: DimensionName;
136136
stackResultDimension: DimensionName;
137137
stackedOnSeries?: SERIES_MODEL;
138+
isPercentStackEnabled?: boolean;
138139
}
139140

140141
// -----------------------------

src/data/helper/dataStackHelper.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function enableDataStack(
6969
| 'isStackedByIndex'
7070
| 'stackedOverDimension'
7171
| 'stackResultDimension'
72+
| 'isPercentStackEnabled'
7273
> {
7374
opt = opt || {};
7475
let byIndex = opt.byIndex;
@@ -192,7 +193,8 @@ export function enableDataStack(
192193
stackedByDimension: stackedByDimInfo && stackedByDimInfo.name,
193194
isStackedByIndex: byIndex,
194195
stackedOverDimension: stackedOverDimension,
195-
stackResultDimension: stackResultDimension
196+
stackResultDimension: stackResultDimension,
197+
isPercentStackEnabled: seriesModel.get('stackPercent'),
196198
};
197199
}
198200

src/layout/barGrid.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,14 +485,19 @@ export function createProgressiveLayout(seriesType: string): StageHandler {
485485
const baseDimIdx = data.getDimensionIndex(data.mapDimension(baseAxis.dim));
486486
const drawBackground = seriesModel.get('showBackground', true);
487487
const valueDim = data.mapDimension(valueAxis.dim);
488-
const stackResultDim = data.getCalculationInfo('stackResultDimension');
489-
const stacked = isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries');
490488
const isValueAxisH = valueAxis.isHorizontal();
491489
const valueAxisStart = getValueAxisStart(baseAxis, valueAxis);
492490
const isLarge = isInLargeMode(seriesModel);
493491
const barMinHeight = seriesModel.get('barMinHeight') || 0;
494492

493+
// Determine stacked dimensions.
494+
const stackResultDim = data.getCalculationInfo('stackResultDimension');
495495
const stackedDimIdx = stackResultDim && data.getDimensionIndex(stackResultDim);
496+
const stackedOverDim = data.getCalculationInfo('stackedOverDimension');
497+
const stackedOverDimIdx = stackedOverDim && data.getDimensionIndex(stackedOverDim);
498+
const isPercentStackEnabled = seriesModel.get('stackPercent');
499+
const stacked = isPercentStackEnabled
500+
|| (isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries'));
496501

497502
// Layout info.
498503
const columnWidth = data.getLayout('size');
@@ -521,7 +526,16 @@ export function createProgressiveLayout(seriesType: string): StageHandler {
521526
// Because of the barMinHeight, we can not use the value in
522527
// stackResultDimension directly.
523528
if (stacked) {
524-
stackStartValue = +value - (store.get(valueDimIdx, dataIndex) as number);
529+
if (isPercentStackEnabled) {
530+
// When percentStack is true, use the normalized bottom edge (stackedOverDimension)
531+
// as the start value of the bar segment.
532+
stackStartValue = store.get(stackedOverDimIdx, dataIndex);
533+
}
534+
else {
535+
// For standard (non-percent) stack, subtract the original value from the
536+
// stacked total to compute the bar segment's start value.
537+
stackStartValue = +value - (store.get(valueDimIdx, dataIndex) as number);
538+
}
525539
}
526540

527541
let x;

src/model/mixin/dataFormat.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import GlobalModel from '../Global';
3737
import { TooltipMarkupBlockFragment } from '../../component/tooltip/tooltipMarkup';
3838
import { error, makePrintable } from '../../util/log';
39+
import { round } from '../../util/number';
3940

4041
const DIMENSION_LABEL_REG = /\{@(.+?)\}/g;
4142

@@ -72,7 +73,7 @@ export class DataFormatMixin {
7273
const isSeries = mainType === 'series';
7374
const userOutput = data.userOutput && data.userOutput.get();
7475

75-
return {
76+
const params: CallbackDataParams = {
7677
componentType: mainType,
7778
componentSubType: this.subType,
7879
componentIndex: this.componentIndex,
@@ -93,6 +94,20 @@ export class DataFormatMixin {
9394
// Param name list for mapping `a`, `b`, `c`, `d`, `e`
9495
$vars: ['seriesName', 'name', 'value']
9596
};
97+
98+
const isPercentStackEnabled = data.getCalculationInfo('isPercentStackEnabled');
99+
if (isPercentStackEnabled) {
100+
// Include the normalized value when stackPercent is true.
101+
const stackResultDim = data.getCalculationInfo('stackResultDimension');
102+
const stackedOverDim = data.getCalculationInfo('stackedOverDimension');
103+
const stackTop = data.get(stackResultDim, dataIndex) as number;
104+
const stackBottom = data.get(stackedOverDim, dataIndex) as number;
105+
if (!isNaN(stackTop) && !isNaN(stackBottom)) {
106+
const normalizedValue = stackTop - stackBottom;
107+
params.percent = round(normalizedValue, 2);
108+
}
109+
}
110+
return params;
96111
}
97112

98113
/**

src/processor/dataStack.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,9 @@
2020
import {createHashMap, each} from 'zrender/src/core/util';
2121
import GlobalModel from '../model/Global';
2222
import SeriesModel from '../model/Series';
23-
import { SeriesOption, SeriesStackOptionMixin } from '../util/types';
24-
import SeriesData, { DataCalculationInfo } from '../data/SeriesData';
23+
import { SeriesOption, SeriesStackOptionMixin, StackInfo } from '../util/types';
2524
import { addSafe } from '../util/number';
26-
27-
type StackInfo = Pick<
28-
DataCalculationInfo<SeriesOption & SeriesStackOptionMixin>,
29-
'stackedDimension'
30-
| 'isStackedByIndex'
31-
| 'stackedByDimension'
32-
| 'stackResultDimension'
33-
| 'stackedOverDimension'
34-
> & {
35-
data: SeriesData
36-
seriesModel: SeriesModel<SeriesOption & SeriesStackOptionMixin>
37-
};
25+
import { calculatePercentStack } from '../util/stack';
3826

3927
// (1) [Caution]: the logic is correct based on the premises:
4028
// data processing stage is blocked in stream.
@@ -95,11 +83,17 @@ export default function dataStack(ecModel: GlobalModel) {
9583
});
9684

9785
// Calculate stack values
98-
calculateStack(stackInfoList);
86+
const isPercentStackEnabled = stackInfoList.some((info) => info.seriesModel.get('stackPercent'));
87+
if (isPercentStackEnabled) {
88+
calculatePercentStack(stackInfoList);
89+
}
90+
else {
91+
calculateStandardStack(stackInfoList);
92+
}
9993
});
10094
}
10195

102-
function calculateStack(stackInfoList: StackInfo[]) {
96+
function calculateStandardStack(stackInfoList: StackInfo[]) {
10397
each(stackInfoList, function (targetStackInfo, idxInStack) {
10498
const resultVal: number[] = [];
10599
const resultNaN = [NaN, NaN];

src/util/stack.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { each } from 'zrender/src/core/util';
21+
import { addSafe } from './number';
22+
import { StackInfo } from './types';
23+
24+
/**
25+
* Percent stackStrategy logic to normalize each value as a percentage of the total per index.
26+
*/
27+
export function calculatePercentStack(stackInfoList: StackInfo[]) {
28+
const dataLength = stackInfoList[0].data.count();
29+
if (dataLength === 0) {
30+
return;
31+
}
32+
33+
// Calculate totals per data index across all series in the stack group.
34+
const totals = calculateStackTotals(stackInfoList, dataLength);
35+
36+
// Used to track running total of percent values at each index.
37+
const cumulativePercents = new Float64Array(dataLength);
38+
39+
const resultNaN = [NaN, NaN];
40+
41+
each(stackInfoList, function (targetStackInfo) {
42+
const resultVal: number[] = [];
43+
const dims: [string, string] = [targetStackInfo.stackResultDimension, targetStackInfo.stackedOverDimension];
44+
const targetData = targetStackInfo.data;
45+
const stackedDim = targetStackInfo.stackedDimension;
46+
47+
// Should not write on raw data, because stack series model list changes
48+
// depending on legend selection.
49+
targetData.modify(dims, function (v0, v1, dataIndex) {
50+
const rawValue = targetData.get(stackedDim, dataIndex) as number;
51+
52+
// Consider `connectNulls` of line area, if value is NaN, stackedOver
53+
// should also be NaN, to draw a appropriate belt area.
54+
if (isNaN(rawValue)) {
55+
return resultNaN;
56+
}
57+
58+
// Pre-calculated total for this specific data index.
59+
const total = totals[dataIndex];
60+
61+
// Percentage contribution of this segment.
62+
const percent = total === 0 ? 0 : (rawValue / total) * 100;
63+
64+
// Bottom edge of this segment (cumulative % before this series).
65+
const stackedOver = cumulativePercents[dataIndex];
66+
67+
// Update the cumulative percentage for the next series at this index to use.
68+
cumulativePercents[dataIndex] = addSafe(stackedOver, percent);
69+
70+
// Result: [Top edge %, Bottom edge %]
71+
resultVal[0] = cumulativePercents[dataIndex];
72+
resultVal[1] = stackedOver;
73+
return resultVal;
74+
});
75+
});
76+
}
77+
78+
/**
79+
* Helper to calculate the total value across all series for each data index.
80+
*/
81+
function calculateStackTotals(stackInfoList: StackInfo[], dataLength: number): number[] {
82+
const totals = Array(dataLength).fill(0);
83+
each(stackInfoList, (stackInfo) => {
84+
const data = stackInfo.data;
85+
const dim = stackInfo.stackedDimension;
86+
for (let i = 0; i < dataLength; i++) {
87+
const val = data.get(dim, i) as number;
88+
if (!isNaN(val)) {
89+
totals[i] = addSafe(totals[i], val);
90+
}
91+
}
92+
});
93+
return totals;
94+
}

src/util/types.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import ExtensionAPI from '../core/ExtensionAPI';
3333
import SeriesModel from '../model/Series';
3434
import { createHashMap, HashMap } from 'zrender/src/core/util';
3535
import { TaskPlanCallbackReturn, TaskProgressParams } from '../core/task';
36-
import SeriesData from '../data/SeriesData';
36+
import SeriesData, { DataCalculationInfo } from '../data/SeriesData';
3737
import { Dictionary, ElementEventName, ImageLike, TextAlign, TextVerticalAlign } from 'zrender/src/core/types';
3838
import { PatternObject } from 'zrender/src/graphic/Pattern';
3939
import { TooltipMarker } from './format';
@@ -880,7 +880,7 @@ export interface CallbackDataParams {
880880
marker?: TooltipMarker;
881881
status?: DisplayState;
882882
dimensionIndex?: number;
883-
percent?: number; // Only for chart like 'pie'
883+
percent?: number; // Only for chart like 'pie' or when 'stackPercent' is used.
884884

885885
// Param name list for mapping `a`, `b`, `c`, `d`, `e`
886886
$vars: string[];
@@ -1983,7 +1983,21 @@ export interface SeriesStackOptionMixin {
19831983
stack?: string
19841984
stackStrategy?: 'samesign' | 'all' | 'positive' | 'negative';
19851985
stackOrder?: 'seriesAsc' | 'seriesDesc'; // default: seriesAsc
1986-
}
1986+
stackPercent?: boolean;
1987+
}
1988+
1989+
export type StackInfo = Pick<
1990+
DataCalculationInfo<SeriesOption & SeriesStackOptionMixin>,
1991+
'stackedDimension'
1992+
| 'isStackedByIndex'
1993+
| 'stackedByDimension'
1994+
| 'stackResultDimension'
1995+
| 'stackedOverDimension'
1996+
| 'isPercentStackEnabled'
1997+
> & {
1998+
data: SeriesData
1999+
seriesModel: SeriesModel<SeriesOption & SeriesStackOptionMixin>
2000+
};
19872001

19882002
type SamplingFunc = (frame: ArrayLike<number>) => number;
19892003

0 commit comments

Comments
 (0)