Skip to content

Commit b039ebf

Browse files
feat(domain): scale data to a specific domain via axis spec (#98)
1 parent 3e06276 commit b039ebf

File tree

15 files changed

+592
-97
lines changed

15 files changed

+592
-97
lines changed

src/lib/axes/axis_utils.test.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { XDomain } from '../series/domains/x_domain';
22
import { YDomain } from '../series/domains/y_domain';
3-
import { Position } from '../series/specs';
3+
import { AxisSpec, DomainRange, Position } from '../series/specs';
44
import { LIGHT_THEME } from '../themes/light_theme';
5-
import { getAxisId, getGroupId } from '../utils/ids';
5+
import { getAxisId, getGroupId, GroupId } from '../utils/ids';
66
import { ScaleType } from '../utils/scales/scales';
77
import {
88
centerRotationOrigin,
@@ -14,15 +14,17 @@ import {
1414
getAxisTicksPositions,
1515
getHorizontalAxisGridLineProps,
1616
getHorizontalAxisTickLineProps,
17-
getHorizontalDomain,
1817
getMaxBboxDimensions,
1918
getMinMaxRange,
2019
getScaleForAxisSpec,
2120
getTickLabelProps,
2221
getVerticalAxisGridLineProps,
2322
getVerticalAxisTickLineProps,
24-
getVerticalDomain,
2523
getVisibleTicks,
24+
isHorizontal,
25+
isVertical,
26+
isYDomain,
27+
mergeDomainsByGroupId,
2628
} from './axis_utils';
2729
import { CanvasTextBBoxCalculator } from './canvas_text_bbox_calculator';
2830
import { SvgTextBBoxCalculator } from './svg_text_bbox_calculator';
@@ -71,7 +73,7 @@ describe('Axis computational utils', () => {
7173
maxLabelTextWidth: 10,
7274
maxLabelTextHeight: 10,
7375
};
74-
const verticalAxisSpec = {
76+
const verticalAxisSpec: AxisSpec = {
7577
id: getAxisId('axis_1'),
7678
groupId: getGroupId('group_1'),
7779
hide: false,
@@ -86,7 +88,7 @@ describe('Axis computational utils', () => {
8688
showGridLines: true,
8789
};
8890

89-
const horizontalAxisSpec = {
91+
const horizontalAxisSpec: AxisSpec = {
9092
id: getAxisId('axis_2'),
9193
groupId: getGroupId('group_1'),
9294
hide: false,
@@ -149,6 +151,21 @@ describe('Axis computational utils', () => {
149151
bboxCalculator.destroy();
150152
});
151153

154+
test('should not compute axis dimensions when spec is configured to hide', () => {
155+
const bboxCalculator = new CanvasTextBBoxCalculator();
156+
verticalAxisSpec.hide = true;
157+
const axisDimensions = computeAxisTicksDimensions(
158+
verticalAxisSpec,
159+
xDomain,
160+
[yDomain],
161+
1,
162+
bboxCalculator,
163+
0,
164+
axes,
165+
);
166+
expect(axisDimensions).toBe(null);
167+
});
168+
152169
test('should compute dimensions for the bounding box containing a rotated label', () => {
153170
expect(computeRotatedLabelDimensions({ width: 1, height: 2 }, 0)).toEqual({
154171
width: 1,
@@ -914,13 +931,94 @@ describe('Axis computational utils', () => {
914931
expect(horizontalAxisGridLines).toEqual([25, 0, 25, 100]);
915932
});
916933

917-
test('should return correct domain based on rotation', () => {
918-
const chartRotation = 180;
919-
expect(getHorizontalDomain(xDomain, [yDomain], chartRotation)).toEqual(xDomain);
920-
expect(getVerticalDomain(xDomain, [yDomain], chartRotation)).toEqual([yDomain]);
934+
test('should determine orientation of axis position', () => {
935+
expect(isVertical(Position.Left)).toBe(true);
936+
expect(isVertical(Position.Right)).toBe(true);
937+
expect(isVertical(Position.Top)).toBe(false);
938+
expect(isVertical(Position.Bottom)).toBe(false);
939+
940+
expect(isHorizontal(Position.Left)).toBe(false);
941+
expect(isHorizontal(Position.Right)).toBe(false);
942+
expect(isHorizontal(Position.Top)).toBe(true);
943+
expect(isHorizontal(Position.Bottom)).toBe(true);
944+
});
945+
946+
test('should determine if axis belongs to yDomain', () => {
947+
const verticalY = isYDomain(Position.Left, 0);
948+
expect(verticalY).toBe(true);
949+
950+
const verticalX = isYDomain(Position.Left, 90);
951+
expect(verticalX).toBe(false);
952+
953+
const horizontalX = isYDomain(Position.Top, 0);
954+
expect(horizontalX).toBe(false);
955+
956+
const horizontalY = isYDomain(Position.Top, 90);
957+
expect(horizontalY).toBe(true);
958+
});
959+
960+
test('should merge axis domains by group id', () => {
961+
const groupId = getGroupId('group_1');
962+
const domainRange1 = {
963+
min: 2,
964+
max: 9,
965+
};
966+
967+
verticalAxisSpec.domain = domainRange1;
968+
969+
const axesSpecs = new Map();
970+
axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec);
971+
972+
// Base case
973+
const expectedSimpleMap = new Map<GroupId, DomainRange>();
974+
expectedSimpleMap.set(groupId, { min: 2, max: 9 });
975+
976+
const simpleDomainsByGroupId = mergeDomainsByGroupId(axesSpecs, 0);
977+
expect(simpleDomainsByGroupId).toEqual(expectedSimpleMap);
978+
979+
// Multiple definitions for the same group
980+
const domainRange2 = {
981+
min: 0,
982+
max: 7,
983+
};
984+
985+
const altVerticalAxisSpec = { ...verticalAxisSpec, id: getAxisId('axis2') };
986+
987+
altVerticalAxisSpec.domain = domainRange2;
988+
axesSpecs.set(altVerticalAxisSpec.id, altVerticalAxisSpec);
989+
990+
const expectedMergedMap = new Map<GroupId, DomainRange>();
991+
expectedMergedMap.set(groupId, { min: 0, max: 9 });
992+
993+
const mergedDomainsByGroupId = mergeDomainsByGroupId(axesSpecs, 0);
994+
expect(mergedDomainsByGroupId).toEqual(expectedMergedMap);
995+
996+
// xDomain limit (bad config)
997+
horizontalAxisSpec.domain = {
998+
min: 5,
999+
max: 15,
1000+
};
1001+
axesSpecs.set(horizontalAxisSpec.id, horizontalAxisSpec);
1002+
1003+
const attemptToMerge = () => { mergeDomainsByGroupId(axesSpecs, 0); };
1004+
1005+
expect(attemptToMerge).toThrowError('[Axis axis_2]: custom domain for xDomain should be defined in Settings');
1006+
});
1007+
1008+
test('should throw on invalid domain', () => {
1009+
const domainRange1 = {
1010+
min: 9,
1011+
max: 2,
1012+
};
1013+
1014+
verticalAxisSpec.domain = domainRange1;
1015+
1016+
const axesSpecs = new Map();
1017+
axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec);
1018+
1019+
const attemptToMerge = () => { mergeDomainsByGroupId(axesSpecs, 0); };
1020+
const expectedError = '[Axis axis_1]: custom domain is invalid, min is greater than max';
9211021

922-
const skewChartRotation = 45;
923-
expect(getHorizontalDomain(xDomain, [yDomain], skewChartRotation)).toEqual([yDomain]);
924-
expect(getVerticalDomain(xDomain, [yDomain], skewChartRotation)).toEqual(xDomain);
1022+
expect(attemptToMerge).toThrowError(expectedError);
9251023
});
9261024
});

src/lib/axes/axis_utils.ts

Lines changed: 61 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { XDomain } from '../series/domains/x_domain';
22
import { YDomain } from '../series/domains/y_domain';
33
import { computeXScale, computeYScales } from '../series/scales';
4-
import { AxisSpec, Position, Rotation, TickFormatter } from '../series/specs';
4+
import { AxisSpec, DomainRange, Position, Rotation, TickFormatter } from '../series/specs';
55
import { AxisConfig, Theme } from '../themes/theme';
66
import { Dimensions, Margins } from '../utils/dimensions';
77
import { Domain } from '../utils/domain';
8-
import { AxisId } from '../utils/ids';
8+
import { AxisId, GroupId } from '../utils/ids';
99
import { Scale, ScaleType } from '../utils/scales/scales';
1010
import { BBox, BBoxCalculator } from './bbox_calculator';
1111

@@ -54,6 +54,10 @@ export function computeAxisTicksDimensions(
5454
chartRotation: Rotation,
5555
axisConfig: AxisConfig,
5656
): AxisTicksDimensions | null {
57+
if (axisSpec.hide) {
58+
return null;
59+
}
60+
5761
const scale = getScaleForAxisSpec(
5862
axisSpec,
5963
xDomain,
@@ -80,6 +84,16 @@ export function computeAxisTicksDimensions(
8084
...dimensions,
8185
};
8286
}
87+
88+
export function isYDomain(position: Position, chartRotation: Rotation): boolean {
89+
const isStraightRotation = chartRotation === 0 || chartRotation === 180;
90+
if (isVertical(position)) {
91+
return isStraightRotation;
92+
}
93+
94+
return !isStraightRotation;
95+
}
96+
8397
export function getScaleForAxisSpec(
8498
axisSpec: AxisSpec,
8599
xDomain: XDomain,
@@ -89,9 +103,9 @@ export function getScaleForAxisSpec(
89103
minRange: number,
90104
maxRange: number,
91105
): Scale | null {
92-
const axisDomain = getAxisDomain(axisSpec.position, xDomain, yDomain, chartRotation);
93-
// If axisDomain is an array of values, this is an array of YDomains
94-
if (Array.isArray(axisDomain)) {
106+
const axisIsYDomain = isYDomain(axisSpec.position, chartRotation);
107+
108+
if (axisIsYDomain) {
95109
const yScales = computeYScales(yDomain, minRange, maxRange);
96110
if (yScales.has(axisSpec.groupId)) {
97111
return yScales.get(axisSpec.groupId)!;
@@ -598,47 +612,52 @@ export function computeAxisGridLinePositions(
598612
return positions;
599613
}
600614

601-
export function getVerticalDomain(
602-
xDomain: XDomain,
603-
yDomain: YDomain[],
604-
chartRotation: number,
605-
): XDomain | YDomain[] {
606-
if (chartRotation === 0 || chartRotation === 180) {
607-
return yDomain;
608-
} else {
609-
return xDomain;
610-
}
611-
}
612-
613-
export function getHorizontalDomain(
614-
xDomain: XDomain,
615-
yDomain: YDomain[],
616-
chartRotation: number,
617-
): XDomain | YDomain[] {
618-
if (chartRotation === 0 || chartRotation === 180) {
619-
return xDomain;
620-
} else {
621-
return yDomain;
622-
}
623-
}
624-
625-
export function getAxisDomain(
626-
position: Position,
627-
xDomain: XDomain,
628-
yDomain: YDomain[],
629-
chartRotation: number,
630-
): XDomain | YDomain[] {
631-
if (!isHorizontal(position)) {
632-
return getVerticalDomain(xDomain, yDomain, chartRotation);
633-
} else {
634-
return getHorizontalDomain(xDomain, yDomain, chartRotation);
635-
}
636-
}
637-
638615
export function isVertical(position: Position) {
639616
return position === Position.Left || position === Position.Right;
640617
}
641618

642619
export function isHorizontal(position: Position) {
643620
return !isVertical(position);
644621
}
622+
623+
export function mergeDomainsByGroupId(
624+
axesSpecs: Map<AxisId, AxisSpec>,
625+
chartRotation: Rotation,
626+
): Map<GroupId, DomainRange> {
627+
const domainsByGroupId = new Map<GroupId, DomainRange>();
628+
629+
axesSpecs.forEach((spec: AxisSpec, id: AxisId) => {
630+
const { groupId, domain } = spec;
631+
632+
if (!domain) {
633+
return;
634+
}
635+
636+
const isAxisYDomain = isYDomain(spec.position, chartRotation);
637+
638+
if (!isAxisYDomain) {
639+
const errorMessage = `[Axis ${id}]: custom domain for xDomain should be defined in Settings`;
640+
throw new Error(errorMessage);
641+
}
642+
643+
if (domain.min > domain.max) {
644+
const errorMessage = `[Axis ${id}]: custom domain is invalid, min is greater than max`;
645+
throw new Error(errorMessage);
646+
}
647+
648+
const prevGroupDomain = domainsByGroupId.get(groupId);
649+
650+
if (prevGroupDomain) {
651+
const mergedDomain = {
652+
min: Math.min(domain.min, prevGroupDomain.min),
653+
max: Math.max(domain.max, prevGroupDomain.max),
654+
};
655+
656+
domainsByGroupId.set(groupId, mergedDomain);
657+
} else {
658+
domainsByGroupId.set(groupId, domain);
659+
}
660+
});
661+
662+
return domainsByGroupId;
663+
}

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,12 +590,12 @@ describe('X Domain', () => {
590590
specDataSeries.set(ds1.id, ds1);
591591
specDataSeries.set(ds2.id, ds2);
592592
const { xValues } = getSplittedSeries(specDataSeries);
593+
593594
const mergedDomain = mergeXDomain(
594595
[
595596
{
596597
seriesType: 'area',
597598
xScaleType: ScaleType.Linear,
598-
xDomain: [0, 10],
599599
},
600600
{
601601
seriesType: 'line',
@@ -630,4 +630,36 @@ describe('X Domain', () => {
630630
const minInterval = findMinInterval([100]);
631631
expect(minInterval).toBe(1);
632632
});
633+
test('should account for custom domain when merging a linear domain', () => {
634+
const xValues = new Set([1, 2, 3, 4, 5]);
635+
const xDomain = { min: 0, max: 3 };
636+
const specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> =
637+
[{ seriesType: 'line', xScaleType: ScaleType.Linear }];
638+
639+
const basicMergedDomain = mergeXDomain(specs, xValues, xDomain);
640+
expect(basicMergedDomain.domain).toEqual([0, 3]);
641+
642+
const arrayXDomain = [1, 2];
643+
const attemptToMergeArrayDomain = () => { mergeXDomain(specs, xValues, arrayXDomain); };
644+
const errorMessage = 'xDomain for continuous scale should be a DomainRange object, not an array';
645+
expect(attemptToMergeArrayDomain).toThrowError(errorMessage);
646+
647+
const invalidXDomain = { min: 10, max: 0 };
648+
const attemptToMerge = () => { mergeXDomain(specs, xValues, invalidXDomain); };
649+
expect(attemptToMerge).toThrowError('custom xDomain is invalid, min is greater than max');
650+
});
651+
652+
test('should account for custom domain when merging an ordinal domain', () => {
653+
const xValues = new Set(['a', 'b', 'c', 'd']);
654+
const xDomain = ['a', 'b', 'c'];
655+
const specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> =
656+
[{ seriesType: 'bar', xScaleType: ScaleType.Ordinal }];
657+
const basicMergedDomain = mergeXDomain(specs, xValues, xDomain);
658+
expect(basicMergedDomain.domain).toEqual(['a', 'b', 'c']);
659+
660+
const objectXDomain = { max: 10, min: 0 };
661+
const attemptToMerge = () => { mergeXDomain(specs, xValues, objectXDomain); };
662+
const errorMessage = 'xDomain for ordinal scale should be an array of values, not a DomainRange object';
663+
expect(attemptToMerge).toThrowError(errorMessage);
664+
});
633665
});

0 commit comments

Comments
 (0)