Skip to content

Commit 1bfb430

Browse files
committed
feat(series): stack series in percentage mode (#250)
Add the `stackAsPercentage` prop to allow stacking bars and areas computing the percentage of each data point on each bucket/x value. fix #222
1 parent 3c291a2 commit 1bfb430

File tree

12 files changed

+640
-77
lines changed

12 files changed

+640
-77
lines changed

src/lib/series/domains/y_domain.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,4 +691,109 @@ describe('Y Domain', () => {
691691
const errorMessage = 'custom yDomain for a is invalid, computed min is greater than custom max';
692692
expect(attemptToMerge).toThrowError(errorMessage);
693693
});
694+
test('Should merge Y domain with stacked as percentage', () => {
695+
const dataSeries1: RawDataSeries[] = [
696+
{
697+
specId: getSpecId('a'),
698+
key: [''],
699+
seriesColorKey: '',
700+
data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
701+
},
702+
{
703+
specId: getSpecId('a'),
704+
key: [''],
705+
seriesColorKey: '',
706+
data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }],
707+
},
708+
];
709+
const dataSeries2: RawDataSeries[] = [
710+
{
711+
specId: getSpecId('a'),
712+
key: [''],
713+
seriesColorKey: '',
714+
data: [{ x: 1, y1: 10 }, { x: 2, y1: 10 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
715+
},
716+
];
717+
const specDataSeries = new Map();
718+
specDataSeries.set(getSpecId('a'), dataSeries1);
719+
specDataSeries.set(getSpecId('b'), dataSeries2);
720+
const mergedDomain = mergeYDomain(
721+
specDataSeries,
722+
[
723+
{
724+
seriesType: 'area',
725+
yScaleType: ScaleType.Linear,
726+
groupId: getGroupId('a'),
727+
id: getSpecId('a'),
728+
stackAccessors: ['a'],
729+
yScaleToDataExtent: true,
730+
stackAsPercentage: true,
731+
},
732+
{
733+
seriesType: 'area',
734+
yScaleType: ScaleType.Log,
735+
groupId: getGroupId('a'),
736+
id: getSpecId('b'),
737+
yScaleToDataExtent: true,
738+
},
739+
],
740+
new Map(),
741+
);
742+
expect(mergedDomain).toEqual([
743+
{
744+
groupId: 'a',
745+
domain: [0, 1],
746+
scaleType: ScaleType.Linear,
747+
isBandScale: false,
748+
type: 'yDomain',
749+
},
750+
]);
751+
});
752+
test('Should merge Y domain with as percentage regadless of custom domains', () => {
753+
const groupId = getGroupId('a');
754+
755+
const dataSeries: RawDataSeries[] = [
756+
{
757+
specId: getSpecId('a'),
758+
key: [''],
759+
seriesColorKey: '',
760+
data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
761+
},
762+
{
763+
specId: getSpecId('a'),
764+
key: [''],
765+
seriesColorKey: '',
766+
data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }],
767+
},
768+
];
769+
const specDataSeries = new Map();
770+
specDataSeries.set(getSpecId('a'), dataSeries);
771+
const domainsByGroupId = new Map<GroupId, DomainRange>();
772+
domainsByGroupId.set(groupId, { min: 2, max: 20 });
773+
774+
const mergedDomain = mergeYDomain(
775+
specDataSeries,
776+
[
777+
{
778+
seriesType: 'area',
779+
yScaleType: ScaleType.Linear,
780+
groupId,
781+
id: getSpecId('a'),
782+
stackAccessors: ['a'],
783+
yScaleToDataExtent: true,
784+
stackAsPercentage: true,
785+
},
786+
],
787+
domainsByGroupId,
788+
);
789+
expect(mergedDomain).toEqual([
790+
{
791+
type: 'yDomain',
792+
groupId,
793+
domain: [0, 1],
794+
scaleType: ScaleType.Linear,
795+
isBandScale: false,
796+
},
797+
]);
798+
});
694799
});

src/lib/series/domains/y_domain.ts

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type YDomain = BaseDomain & {
1717
export type YBasicSeriesSpec = Pick<
1818
BasicSeriesSpec,
1919
'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors'
20-
>;
20+
> & { stackAsPercentage?: boolean };
2121

2222
export function mergeYDomain(
2323
dataSeries: Map<SpecId, RawDataSeries[]>,
@@ -32,50 +32,54 @@ export function mergeYDomain(
3232
const yDomains = specsByGroupIdsEntries.map(
3333
([groupId, groupSpecs]): YDomain => {
3434
const groupYScaleType = coerceYScaleTypes([...groupSpecs.stacked, ...groupSpecs.nonStacked]);
35-
36-
// compute stacked domain
37-
const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => {
38-
return spec.yScaleToDataExtent;
39-
});
40-
const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked);
41-
const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent);
42-
43-
// compute non stacked domain
44-
const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => {
45-
return spec.yScaleToDataExtent;
46-
});
47-
const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked);
48-
const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent);
49-
50-
// merge stacked and non stacked domain together
51-
const groupDomain = computeContinuousDataDomain(
52-
[...stackedDomain, ...nonStackedDomain],
53-
identity,
54-
isStackedScaleToExtent || isNonStackedScaleToExtent,
55-
);
56-
57-
const [computedDomainMin, computedDomainMax] = groupDomain;
58-
let domain = groupDomain;
59-
60-
const customDomain = domainsByGroupId.get(groupId);
61-
62-
if (customDomain && isCompleteBound(customDomain)) {
63-
// Don't need to check min > max because this has been validated on axis domain merge
64-
domain = [customDomain.min, customDomain.max];
65-
} else if (customDomain && isLowerBound(customDomain)) {
66-
if (customDomain.min > computedDomainMax) {
67-
throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`);
68-
}
69-
70-
domain = [customDomain.min, computedDomainMax];
71-
} else if (customDomain && isUpperBound(customDomain)) {
72-
if (computedDomainMin > customDomain.max) {
73-
throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`);
35+
const { isPercentageStack } = groupSpecs;
36+
37+
let domain: number[];
38+
if (isPercentageStack) {
39+
domain = computeContinuousDataDomain([0, 1], identity);
40+
} else {
41+
// compute stacked domain
42+
const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => {
43+
return spec.yScaleToDataExtent;
44+
});
45+
const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked);
46+
const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent);
47+
48+
// compute non stacked domain
49+
const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => {
50+
return spec.yScaleToDataExtent;
51+
});
52+
const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked);
53+
const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent);
54+
55+
// merge stacked and non stacked domain together
56+
domain = computeContinuousDataDomain(
57+
[...stackedDomain, ...nonStackedDomain],
58+
identity,
59+
isStackedScaleToExtent || isNonStackedScaleToExtent,
60+
);
61+
62+
const [computedDomainMin, computedDomainMax] = domain;
63+
64+
const customDomain = domainsByGroupId.get(groupId);
65+
66+
if (customDomain && isCompleteBound(customDomain)) {
67+
// Don't need to check min > max because this has been validated on axis domain merge
68+
domain = [customDomain.min, customDomain.max];
69+
} else if (customDomain && isLowerBound(customDomain)) {
70+
if (customDomain.min > computedDomainMax) {
71+
throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`);
72+
}
73+
74+
domain = [customDomain.min, computedDomainMax];
75+
} else if (customDomain && isUpperBound(customDomain)) {
76+
if (computedDomainMin > customDomain.max) {
77+
throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`);
78+
}
79+
80+
domain = [computedDomainMin, customDomain.max];
7481
}
75-
76-
domain = [computedDomainMin, customDomain.max];
7782
}
78-
7983
return {
8084
type: 'yDomain',
8185
isBandScale: false,
@@ -139,10 +143,14 @@ function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: bo
139143
return computeContinuousDataDomain([...yValues.values()], identity, scaleToExtent);
140144
}
141145
export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) {
142-
const specsByGroupIds = new Map<GroupId, { stacked: YBasicSeriesSpec[]; nonStacked: YBasicSeriesSpec[] }>();
146+
const specsByGroupIds = new Map<
147+
GroupId,
148+
{ isPercentageStack: boolean; stacked: YBasicSeriesSpec[]; nonStacked: YBasicSeriesSpec[] }
149+
>();
143150
// split each specs by groupId and by stacked or not
144151
specs.forEach((spec) => {
145152
const group = specsByGroupIds.get(spec.groupId) || {
153+
isPercentageStack: false,
146154
stacked: [],
147155
nonStacked: [],
148156
};
@@ -151,6 +159,9 @@ export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) {
151159
} else {
152160
group.nonStacked.push(spec);
153161
}
162+
if (spec.stackAsPercentage === true) {
163+
group.isPercentageStack = true;
164+
}
154165
specsByGroupIds.set(spec.groupId, group);
155166
});
156167
return specsByGroupIds;

src/lib/series/legend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function computeLegend(
5656
isLegendItemVisible: !hideInLegend,
5757
displayValue: {
5858
raw: series.lastValue,
59-
formatted: formatter(series.lastValue),
59+
formatted: isSeriesVisible ? formatter(series.lastValue) : undefined,
6060
},
6161
});
6262
});

src/lib/series/series.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,18 @@ export function getFormattedDataseries(
219219
}[] = [];
220220

221221
specsByGroupIdsEntries.forEach(([groupId, groupSpecs]) => {
222+
const { isPercentageStack } = groupSpecs;
222223
// format stacked data series
223224
const stackedDataSeries = getRawDataSeries(groupSpecs.stacked, dataSeries);
225+
const stackedDataSeriesValues = formatStackedDataSeriesValues(
226+
stackedDataSeries.rawDataSeries,
227+
false,
228+
isPercentageStack,
229+
);
224230
stackedFormattedDataSeries.push({
225231
groupId,
226232
counts: stackedDataSeries.counts,
227-
dataSeries: formatStackedDataSeriesValues(stackedDataSeries.rawDataSeries, false),
233+
dataSeries: stackedDataSeriesValues,
228234
});
229235

230236
// format non stacked data series

src/lib/series/specs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ export type BarSeriesSpec = BasicSeriesSpec & {
136136
/** If true, will stack all BarSeries and align bars to ticks (instead of centered on ticks) */
137137
enableHistogramMode?: boolean;
138138
barSeriesStyle?: CustomBarSeriesStyle;
139+
/**
140+
* Stack each series in percentage for each point.
141+
*/
142+
stackAsPercentage?: boolean;
139143
};
140144

141145
/**
@@ -167,6 +171,10 @@ export type AreaSeriesSpec = BasicSeriesSpec &
167171
/** The type of interpolator to be used to interpolate values between points */
168172
curve?: CurveType;
169173
areaSeriesStyle?: AreaSeriesStyle;
174+
/**
175+
* Stack each series in percentage for each point.
176+
*/
177+
stackAsPercentage?: boolean;
170178
};
171179

172180
interface HistogramConfig {

0 commit comments

Comments
 (0)