Skip to content

Commit

Permalink
feat(domain): scale data to a specific domain via axis spec (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmacunningham authored Mar 19, 2019
1 parent 3e06276 commit b039ebf
Show file tree
Hide file tree
Showing 15 changed files with 592 additions and 97 deletions.
124 changes: 111 additions & 13 deletions src/lib/axes/axis_utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { XDomain } from '../series/domains/x_domain';
import { YDomain } from '../series/domains/y_domain';
import { Position } from '../series/specs';
import { AxisSpec, DomainRange, Position } from '../series/specs';
import { LIGHT_THEME } from '../themes/light_theme';
import { getAxisId, getGroupId } from '../utils/ids';
import { getAxisId, getGroupId, GroupId } from '../utils/ids';
import { ScaleType } from '../utils/scales/scales';
import {
centerRotationOrigin,
Expand All @@ -14,15 +14,17 @@ import {
getAxisTicksPositions,
getHorizontalAxisGridLineProps,
getHorizontalAxisTickLineProps,
getHorizontalDomain,
getMaxBboxDimensions,
getMinMaxRange,
getScaleForAxisSpec,
getTickLabelProps,
getVerticalAxisGridLineProps,
getVerticalAxisTickLineProps,
getVerticalDomain,
getVisibleTicks,
isHorizontal,
isVertical,
isYDomain,
mergeDomainsByGroupId,
} from './axis_utils';
import { CanvasTextBBoxCalculator } from './canvas_text_bbox_calculator';
import { SvgTextBBoxCalculator } from './svg_text_bbox_calculator';
Expand Down Expand Up @@ -71,7 +73,7 @@ describe('Axis computational utils', () => {
maxLabelTextWidth: 10,
maxLabelTextHeight: 10,
};
const verticalAxisSpec = {
const verticalAxisSpec: AxisSpec = {
id: getAxisId('axis_1'),
groupId: getGroupId('group_1'),
hide: false,
Expand All @@ -86,7 +88,7 @@ describe('Axis computational utils', () => {
showGridLines: true,
};

const horizontalAxisSpec = {
const horizontalAxisSpec: AxisSpec = {
id: getAxisId('axis_2'),
groupId: getGroupId('group_1'),
hide: false,
Expand Down Expand Up @@ -149,6 +151,21 @@ describe('Axis computational utils', () => {
bboxCalculator.destroy();
});

test('should not compute axis dimensions when spec is configured to hide', () => {
const bboxCalculator = new CanvasTextBBoxCalculator();
verticalAxisSpec.hide = true;
const axisDimensions = computeAxisTicksDimensions(
verticalAxisSpec,
xDomain,
[yDomain],
1,
bboxCalculator,
0,
axes,
);
expect(axisDimensions).toBe(null);
});

test('should compute dimensions for the bounding box containing a rotated label', () => {
expect(computeRotatedLabelDimensions({ width: 1, height: 2 }, 0)).toEqual({
width: 1,
Expand Down Expand Up @@ -914,13 +931,94 @@ describe('Axis computational utils', () => {
expect(horizontalAxisGridLines).toEqual([25, 0, 25, 100]);
});

test('should return correct domain based on rotation', () => {
const chartRotation = 180;
expect(getHorizontalDomain(xDomain, [yDomain], chartRotation)).toEqual(xDomain);
expect(getVerticalDomain(xDomain, [yDomain], chartRotation)).toEqual([yDomain]);
test('should determine orientation of axis position', () => {
expect(isVertical(Position.Left)).toBe(true);
expect(isVertical(Position.Right)).toBe(true);
expect(isVertical(Position.Top)).toBe(false);
expect(isVertical(Position.Bottom)).toBe(false);

expect(isHorizontal(Position.Left)).toBe(false);
expect(isHorizontal(Position.Right)).toBe(false);
expect(isHorizontal(Position.Top)).toBe(true);
expect(isHorizontal(Position.Bottom)).toBe(true);
});

test('should determine if axis belongs to yDomain', () => {
const verticalY = isYDomain(Position.Left, 0);
expect(verticalY).toBe(true);

const verticalX = isYDomain(Position.Left, 90);
expect(verticalX).toBe(false);

const horizontalX = isYDomain(Position.Top, 0);
expect(horizontalX).toBe(false);

const horizontalY = isYDomain(Position.Top, 90);
expect(horizontalY).toBe(true);
});

test('should merge axis domains by group id', () => {
const groupId = getGroupId('group_1');
const domainRange1 = {
min: 2,
max: 9,
};

verticalAxisSpec.domain = domainRange1;

const axesSpecs = new Map();
axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec);

// Base case
const expectedSimpleMap = new Map<GroupId, DomainRange>();
expectedSimpleMap.set(groupId, { min: 2, max: 9 });

const simpleDomainsByGroupId = mergeDomainsByGroupId(axesSpecs, 0);
expect(simpleDomainsByGroupId).toEqual(expectedSimpleMap);

// Multiple definitions for the same group
const domainRange2 = {
min: 0,
max: 7,
};

const altVerticalAxisSpec = { ...verticalAxisSpec, id: getAxisId('axis2') };

altVerticalAxisSpec.domain = domainRange2;
axesSpecs.set(altVerticalAxisSpec.id, altVerticalAxisSpec);

const expectedMergedMap = new Map<GroupId, DomainRange>();
expectedMergedMap.set(groupId, { min: 0, max: 9 });

const mergedDomainsByGroupId = mergeDomainsByGroupId(axesSpecs, 0);
expect(mergedDomainsByGroupId).toEqual(expectedMergedMap);

// xDomain limit (bad config)
horizontalAxisSpec.domain = {
min: 5,
max: 15,
};
axesSpecs.set(horizontalAxisSpec.id, horizontalAxisSpec);

const attemptToMerge = () => { mergeDomainsByGroupId(axesSpecs, 0); };

expect(attemptToMerge).toThrowError('[Axis axis_2]: custom domain for xDomain should be defined in Settings');
});

test('should throw on invalid domain', () => {
const domainRange1 = {
min: 9,
max: 2,
};

verticalAxisSpec.domain = domainRange1;

const axesSpecs = new Map();
axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec);

const attemptToMerge = () => { mergeDomainsByGroupId(axesSpecs, 0); };
const expectedError = '[Axis axis_1]: custom domain is invalid, min is greater than max';

const skewChartRotation = 45;
expect(getHorizontalDomain(xDomain, [yDomain], skewChartRotation)).toEqual([yDomain]);
expect(getVerticalDomain(xDomain, [yDomain], skewChartRotation)).toEqual(xDomain);
expect(attemptToMerge).toThrowError(expectedError);
});
});
103 changes: 61 additions & 42 deletions src/lib/axes/axis_utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { XDomain } from '../series/domains/x_domain';
import { YDomain } from '../series/domains/y_domain';
import { computeXScale, computeYScales } from '../series/scales';
import { AxisSpec, Position, Rotation, TickFormatter } from '../series/specs';
import { AxisSpec, DomainRange, Position, Rotation, TickFormatter } from '../series/specs';
import { AxisConfig, Theme } from '../themes/theme';
import { Dimensions, Margins } from '../utils/dimensions';
import { Domain } from '../utils/domain';
import { AxisId } from '../utils/ids';
import { AxisId, GroupId } from '../utils/ids';
import { Scale, ScaleType } from '../utils/scales/scales';
import { BBox, BBoxCalculator } from './bbox_calculator';

Expand Down Expand Up @@ -54,6 +54,10 @@ export function computeAxisTicksDimensions(
chartRotation: Rotation,
axisConfig: AxisConfig,
): AxisTicksDimensions | null {
if (axisSpec.hide) {
return null;
}

const scale = getScaleForAxisSpec(
axisSpec,
xDomain,
Expand All @@ -80,6 +84,16 @@ export function computeAxisTicksDimensions(
...dimensions,
};
}

export function isYDomain(position: Position, chartRotation: Rotation): boolean {
const isStraightRotation = chartRotation === 0 || chartRotation === 180;
if (isVertical(position)) {
return isStraightRotation;
}

return !isStraightRotation;
}

export function getScaleForAxisSpec(
axisSpec: AxisSpec,
xDomain: XDomain,
Expand All @@ -89,9 +103,9 @@ export function getScaleForAxisSpec(
minRange: number,
maxRange: number,
): Scale | null {
const axisDomain = getAxisDomain(axisSpec.position, xDomain, yDomain, chartRotation);
// If axisDomain is an array of values, this is an array of YDomains
if (Array.isArray(axisDomain)) {
const axisIsYDomain = isYDomain(axisSpec.position, chartRotation);

if (axisIsYDomain) {
const yScales = computeYScales(yDomain, minRange, maxRange);
if (yScales.has(axisSpec.groupId)) {
return yScales.get(axisSpec.groupId)!;
Expand Down Expand Up @@ -598,47 +612,52 @@ export function computeAxisGridLinePositions(
return positions;
}

export function getVerticalDomain(
xDomain: XDomain,
yDomain: YDomain[],
chartRotation: number,
): XDomain | YDomain[] {
if (chartRotation === 0 || chartRotation === 180) {
return yDomain;
} else {
return xDomain;
}
}

export function getHorizontalDomain(
xDomain: XDomain,
yDomain: YDomain[],
chartRotation: number,
): XDomain | YDomain[] {
if (chartRotation === 0 || chartRotation === 180) {
return xDomain;
} else {
return yDomain;
}
}

export function getAxisDomain(
position: Position,
xDomain: XDomain,
yDomain: YDomain[],
chartRotation: number,
): XDomain | YDomain[] {
if (!isHorizontal(position)) {
return getVerticalDomain(xDomain, yDomain, chartRotation);
} else {
return getHorizontalDomain(xDomain, yDomain, chartRotation);
}
}

export function isVertical(position: Position) {
return position === Position.Left || position === Position.Right;
}

export function isHorizontal(position: Position) {
return !isVertical(position);
}

export function mergeDomainsByGroupId(
axesSpecs: Map<AxisId, AxisSpec>,
chartRotation: Rotation,
): Map<GroupId, DomainRange> {
const domainsByGroupId = new Map<GroupId, DomainRange>();

axesSpecs.forEach((spec: AxisSpec, id: AxisId) => {
const { groupId, domain } = spec;

if (!domain) {
return;
}

const isAxisYDomain = isYDomain(spec.position, chartRotation);

if (!isAxisYDomain) {
const errorMessage = `[Axis ${id}]: custom domain for xDomain should be defined in Settings`;
throw new Error(errorMessage);
}

if (domain.min > domain.max) {
const errorMessage = `[Axis ${id}]: custom domain is invalid, min is greater than max`;
throw new Error(errorMessage);
}

const prevGroupDomain = domainsByGroupId.get(groupId);

if (prevGroupDomain) {
const mergedDomain = {
min: Math.min(domain.min, prevGroupDomain.min),
max: Math.max(domain.max, prevGroupDomain.max),
};

domainsByGroupId.set(groupId, mergedDomain);
} else {
domainsByGroupId.set(groupId, domain);
}
});

return domainsByGroupId;
}
34 changes: 33 additions & 1 deletion src/lib/series/domains/x_domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,12 +590,12 @@ describe('X Domain', () => {
specDataSeries.set(ds1.id, ds1);
specDataSeries.set(ds2.id, ds2);
const { xValues } = getSplittedSeries(specDataSeries);

const mergedDomain = mergeXDomain(
[
{
seriesType: 'area',
xScaleType: ScaleType.Linear,
xDomain: [0, 10],
},
{
seriesType: 'line',
Expand Down Expand Up @@ -630,4 +630,36 @@ describe('X Domain', () => {
const minInterval = findMinInterval([100]);
expect(minInterval).toBe(1);
});
test('should account for custom domain when merging a linear domain', () => {
const xValues = new Set([1, 2, 3, 4, 5]);
const xDomain = { min: 0, max: 3 };
const specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> =
[{ seriesType: 'line', xScaleType: ScaleType.Linear }];

const basicMergedDomain = mergeXDomain(specs, xValues, xDomain);
expect(basicMergedDomain.domain).toEqual([0, 3]);

const arrayXDomain = [1, 2];
const attemptToMergeArrayDomain = () => { mergeXDomain(specs, xValues, arrayXDomain); };
const errorMessage = 'xDomain for continuous scale should be a DomainRange object, not an array';
expect(attemptToMergeArrayDomain).toThrowError(errorMessage);

const invalidXDomain = { min: 10, max: 0 };
const attemptToMerge = () => { mergeXDomain(specs, xValues, invalidXDomain); };
expect(attemptToMerge).toThrowError('custom xDomain is invalid, min is greater than max');
});

test('should account for custom domain when merging an ordinal domain', () => {
const xValues = new Set(['a', 'b', 'c', 'd']);
const xDomain = ['a', 'b', 'c'];
const specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> =
[{ seriesType: 'bar', xScaleType: ScaleType.Ordinal }];
const basicMergedDomain = mergeXDomain(specs, xValues, xDomain);
expect(basicMergedDomain.domain).toEqual(['a', 'b', 'c']);

const objectXDomain = { max: 10, min: 0 };
const attemptToMerge = () => { mergeXDomain(specs, xValues, objectXDomain); };
const errorMessage = 'xDomain for ordinal scale should be an array of values, not a DomainRange object';
expect(attemptToMerge).toThrowError(errorMessage);
});
});
Loading

0 comments on commit b039ebf

Please sign in to comment.