Skip to content

Commit

Permalink
feat(series): stack series in percentage mode (#250)
Browse files Browse the repository at this point in the history
Add the `stackAsPercentage` prop to allow stacking bars and areas computing the percentage of each data point on each bucket/x value.

fix #222
  • Loading branch information
markov00 committed Jul 5, 2019
1 parent 3c291a2 commit 1bfb430
Show file tree
Hide file tree
Showing 12 changed files with 640 additions and 77 deletions.
105 changes: 105 additions & 0 deletions src/lib/series/domains/y_domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,4 +691,109 @@ describe('Y Domain', () => {
const errorMessage = 'custom yDomain for a is invalid, computed min is greater than custom max';
expect(attemptToMerge).toThrowError(errorMessage);
});
test('Should merge Y domain with stacked as percentage', () => {
const dataSeries1: RawDataSeries[] = [
{
specId: getSpecId('a'),
key: [''],
seriesColorKey: '',
data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
},
{
specId: getSpecId('a'),
key: [''],
seriesColorKey: '',
data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }],
},
];
const dataSeries2: RawDataSeries[] = [
{
specId: getSpecId('a'),
key: [''],
seriesColorKey: '',
data: [{ x: 1, y1: 10 }, { x: 2, y1: 10 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
},
];
const specDataSeries = new Map();
specDataSeries.set(getSpecId('a'), dataSeries1);
specDataSeries.set(getSpecId('b'), dataSeries2);
const mergedDomain = mergeYDomain(
specDataSeries,
[
{
seriesType: 'area',
yScaleType: ScaleType.Linear,
groupId: getGroupId('a'),
id: getSpecId('a'),
stackAccessors: ['a'],
yScaleToDataExtent: true,
stackAsPercentage: true,
},
{
seriesType: 'area',
yScaleType: ScaleType.Log,
groupId: getGroupId('a'),
id: getSpecId('b'),
yScaleToDataExtent: true,
},
],
new Map(),
);
expect(mergedDomain).toEqual([
{
groupId: 'a',
domain: [0, 1],
scaleType: ScaleType.Linear,
isBandScale: false,
type: 'yDomain',
},
]);
});
test('Should merge Y domain with as percentage regadless of custom domains', () => {
const groupId = getGroupId('a');

const dataSeries: RawDataSeries[] = [
{
specId: getSpecId('a'),
key: [''],
seriesColorKey: '',
data: [{ x: 1, y1: 2 }, { x: 2, y1: 2 }, { x: 3, y1: 2 }, { x: 4, y1: 5 }],
},
{
specId: getSpecId('a'),
key: [''],
seriesColorKey: '',
data: [{ x: 1, y1: 2 }, { x: 4, y1: 7 }],
},
];
const specDataSeries = new Map();
specDataSeries.set(getSpecId('a'), dataSeries);
const domainsByGroupId = new Map<GroupId, DomainRange>();
domainsByGroupId.set(groupId, { min: 2, max: 20 });

const mergedDomain = mergeYDomain(
specDataSeries,
[
{
seriesType: 'area',
yScaleType: ScaleType.Linear,
groupId,
id: getSpecId('a'),
stackAccessors: ['a'],
yScaleToDataExtent: true,
stackAsPercentage: true,
},
],
domainsByGroupId,
);
expect(mergedDomain).toEqual([
{
type: 'yDomain',
groupId,
domain: [0, 1],
scaleType: ScaleType.Linear,
isBandScale: false,
},
]);
});
});
99 changes: 55 additions & 44 deletions src/lib/series/domains/y_domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type YDomain = BaseDomain & {
export type YBasicSeriesSpec = Pick<
BasicSeriesSpec,
'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors'
>;
> & { stackAsPercentage?: boolean };

export function mergeYDomain(
dataSeries: Map<SpecId, RawDataSeries[]>,
Expand All @@ -32,50 +32,54 @@ export function mergeYDomain(
const yDomains = specsByGroupIdsEntries.map(
([groupId, groupSpecs]): YDomain => {
const groupYScaleType = coerceYScaleTypes([...groupSpecs.stacked, ...groupSpecs.nonStacked]);

// compute stacked domain
const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => {
return spec.yScaleToDataExtent;
});
const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked);
const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent);

// compute non stacked domain
const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => {
return spec.yScaleToDataExtent;
});
const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked);
const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent);

// merge stacked and non stacked domain together
const groupDomain = computeContinuousDataDomain(
[...stackedDomain, ...nonStackedDomain],
identity,
isStackedScaleToExtent || isNonStackedScaleToExtent,
);

const [computedDomainMin, computedDomainMax] = groupDomain;
let domain = groupDomain;

const customDomain = domainsByGroupId.get(groupId);

if (customDomain && isCompleteBound(customDomain)) {
// Don't need to check min > max because this has been validated on axis domain merge
domain = [customDomain.min, customDomain.max];
} else if (customDomain && isLowerBound(customDomain)) {
if (customDomain.min > computedDomainMax) {
throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`);
}

domain = [customDomain.min, computedDomainMax];
} else if (customDomain && isUpperBound(customDomain)) {
if (computedDomainMin > customDomain.max) {
throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`);
const { isPercentageStack } = groupSpecs;

let domain: number[];
if (isPercentageStack) {
domain = computeContinuousDataDomain([0, 1], identity);
} else {
// compute stacked domain
const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => {
return spec.yScaleToDataExtent;
});
const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked);
const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent);

// compute non stacked domain
const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => {
return spec.yScaleToDataExtent;
});
const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked);
const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent);

// merge stacked and non stacked domain together
domain = computeContinuousDataDomain(
[...stackedDomain, ...nonStackedDomain],
identity,
isStackedScaleToExtent || isNonStackedScaleToExtent,
);

const [computedDomainMin, computedDomainMax] = domain;

const customDomain = domainsByGroupId.get(groupId);

if (customDomain && isCompleteBound(customDomain)) {
// Don't need to check min > max because this has been validated on axis domain merge
domain = [customDomain.min, customDomain.max];
} else if (customDomain && isLowerBound(customDomain)) {
if (customDomain.min > computedDomainMax) {
throw new Error(`custom yDomain for ${groupId} is invalid, custom min is greater than computed max`);
}

domain = [customDomain.min, computedDomainMax];
} else if (customDomain && isUpperBound(customDomain)) {
if (computedDomainMin > customDomain.max) {
throw new Error(`custom yDomain for ${groupId} is invalid, computed min is greater than custom max`);
}

domain = [computedDomainMin, customDomain.max];
}

domain = [computedDomainMin, customDomain.max];
}

return {
type: 'yDomain',
isBandScale: false,
Expand Down Expand Up @@ -139,10 +143,14 @@ function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: bo
return computeContinuousDataDomain([...yValues.values()], identity, scaleToExtent);
}
export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) {
const specsByGroupIds = new Map<GroupId, { stacked: YBasicSeriesSpec[]; nonStacked: YBasicSeriesSpec[] }>();
const specsByGroupIds = new Map<
GroupId,
{ isPercentageStack: boolean; stacked: YBasicSeriesSpec[]; nonStacked: YBasicSeriesSpec[] }
>();
// split each specs by groupId and by stacked or not
specs.forEach((spec) => {
const group = specsByGroupIds.get(spec.groupId) || {
isPercentageStack: false,
stacked: [],
nonStacked: [],
};
Expand All @@ -151,6 +159,9 @@ export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) {
} else {
group.nonStacked.push(spec);
}
if (spec.stackAsPercentage === true) {
group.isPercentageStack = true;
}
specsByGroupIds.set(spec.groupId, group);
});
return specsByGroupIds;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/series/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function computeLegend(
isLegendItemVisible: !hideInLegend,
displayValue: {
raw: series.lastValue,
formatted: formatter(series.lastValue),
formatted: isSeriesVisible ? formatter(series.lastValue) : undefined,
},
});
});
Expand Down
8 changes: 7 additions & 1 deletion src/lib/series/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,18 @@ export function getFormattedDataseries(
}[] = [];

specsByGroupIdsEntries.forEach(([groupId, groupSpecs]) => {
const { isPercentageStack } = groupSpecs;
// format stacked data series
const stackedDataSeries = getRawDataSeries(groupSpecs.stacked, dataSeries);
const stackedDataSeriesValues = formatStackedDataSeriesValues(
stackedDataSeries.rawDataSeries,
false,
isPercentageStack,
);
stackedFormattedDataSeries.push({
groupId,
counts: stackedDataSeries.counts,
dataSeries: formatStackedDataSeriesValues(stackedDataSeries.rawDataSeries, false),
dataSeries: stackedDataSeriesValues,
});

// format non stacked data series
Expand Down
8 changes: 8 additions & 0 deletions src/lib/series/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ export type BarSeriesSpec = BasicSeriesSpec & {
/** If true, will stack all BarSeries and align bars to ticks (instead of centered on ticks) */
enableHistogramMode?: boolean;
barSeriesStyle?: CustomBarSeriesStyle;
/**
* Stack each series in percentage for each point.
*/
stackAsPercentage?: boolean;
};

/**
Expand Down Expand Up @@ -167,6 +171,10 @@ export type AreaSeriesSpec = BasicSeriesSpec &
/** The type of interpolator to be used to interpolate values between points */
curve?: CurveType;
areaSeriesStyle?: AreaSeriesStyle;
/**
* Stack each series in percentage for each point.
*/
stackAsPercentage?: boolean;
};

interface HistogramConfig {
Expand Down
Loading

0 comments on commit 1bfb430

Please sign in to comment.