Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(series): stack series in percentage mode #250

Merged
merged 9 commits into from
Jul 5, 2019
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,
},
]);
});
});
106 changes: 62 additions & 44 deletions src/lib/series/domains/y_domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ export type YDomain = BaseDomain & {
};
export type YBasicSeriesSpec = Pick<
BasicSeriesSpec,
'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors'
| 'id'
| 'seriesType'
| 'yScaleType'
| 'groupId'
| 'stackAccessors'
| 'yScaleToDataExtent'
| 'colorAccessors'
| 'stackAsPercentage'
>;

export function mergeYDomain(
Expand All @@ -32,50 +39,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[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the Domain type here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and no. On the Y Axis we, currently can handle only numeric values, so the domain here is number[], or better: it should be [number, number]. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahhh yes, of course. Yes I agree [number, number]; maybe in the future we can consider how to tighten the Domain type so that we can between distinguish between types of domains so it is clearer.

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 +150,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 +166,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
4 changes: 4 additions & 0 deletions src/lib/series/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export interface SeriesAccessors {
splitSeriesAccessors?: Accessor[];
/** An array of fields thats indicates the stack membership */
stackAccessors?: Accessor[];
/**
* Stack each series in percentage for each point.
*/
stackAsPercentage?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to define a series with empty/undefined stackAccessors and stackAsPercentage=true. While functionally it ends up rendering ok, it seems we may want to consider how to constrain these "impossible states" in a way that makes it easy for the user to understand what options are available to them given certain other prop configurations.

I don't think this needs to be addressed in this PR because we also have a few other "impossible states" that can be configured currently, but wanted to start the discussion in case that is something we want to explore. This is a talk about how to achieve such a semantics in Elm, but certainly not unique to Elm and something that we could do using Typescript/React.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that should be addressed here: #85

/** An optional array of field name thats indicates the stack membership */
colorAccessors?: Accessor[];
}
Expand Down
Loading