Skip to content

Commit

Permalink
feat(encodable): make applyDomain() able to handle domain from dataset (
Browse files Browse the repository at this point in the history
#254)

* feat: make applyDomain() able to handle domain from dataset

* test: add unit tests

* fix: rename variable
  • Loading branch information
kristw authored and zhaoyongjie committed Nov 26, 2021
1 parent 497a4b0 commit f5f944b
Show file tree
Hide file tree
Showing 13 changed files with 294 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ChannelInput } from '../../types/Channel';
import { ScaleType } from '../../types/VegaLite';
import { timeScaleTypesSet } from '../scale/scaleCategories';

/**
* Convert each element in the array into
* - Date (for time scales)
* - number (for other continuous scales)
* @param domain
* @param scaleType
*/
export default function parseContinuousDomain<T extends ChannelInput>(
domain: T[],
scaleType: ScaleType,
) {
if (timeScaleTypesSet.has(scaleType)) {
type TimeDomain = Exclude<T, string | number | boolean>[];

return domain
.filter(d => typeof d !== 'boolean')
.map(d => (typeof d === 'string' || typeof d === 'number' ? new Date(d) : d)) as TimeDomain;
}

type NumberDomain = Exclude<T, string | boolean>[];

return domain.map(d =>
typeof d === 'string' || typeof d === 'boolean' ? Number(d) : d,
) as NumberDomain;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ChannelInput } from '../../types/Channel';

/**
* Discrete domains are converted into string[]
* when using D3 scales
* @param domain
*/
export default function parseDiscreteDomain<T extends ChannelInput>(domain: T[]) {
return domain.map(d => `${d}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { isDateTime } from 'vega-lite/build/src/datetime';
import { DateTime } from '../types/VegaLite';
import parseDateTime from './parseDateTime';

export default function parseDateTimeIfPossible<T>(d: DateTime | T) {
return !(d instanceof Date) && isDateTime(d) ? parseDateTime(d) : d;
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,57 @@
import { isDateTime } from 'vega-lite/build/src/datetime';
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';
import parseDateTime from '../parseDateTime';
import inferElementTypeFromUnionOfArrayTypes from '../../utils/inferElementTypeFromUnionOfArrayTypes';
import { isEveryElementDefined } from '../../typeGuards/Base';
import { isContinuousScale } from '../../typeGuards/Scale';
import combineCategories from '../../utils/combineCategories';
import parseDateTimeIfPossible from '../parseDateTimeIfPossible';
import parseContinuousDomain from '../domain/parseContinuousDomain';
import parseDiscreteDomain from '../domain/parseDiscreteDomain';
import combineContinuousDomains from '../../utils/combineContinuousDomains';

function createOrderFunction(reverse: boolean | undefined) {
return reverse ? <T>(array: T[]) => array.slice().reverse() : <T>(array: T[]) => array;
}

export default function applyDomain<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
domainFromDataset?: string[] | number[] | boolean[] | Date[],
) {
const { domain, reverse } = config;
if (typeof domain !== 'undefined' && domain.length > 0) {
const processedDomain = inferElementTypeFromUnionOfArrayTypes(domain);
const { domain, reverse, type } = config;

const order = createOrderFunction(reverse);

const inputDomain =
domainFromDataset && domainFromDataset.length
? inferElementTypeFromUnionOfArrayTypes(domainFromDataset)
: undefined;

// Only set domain if all items are defined
if (isEveryElementDefined(processedDomain)) {
if (domain && domain.length) {
const fixedDomain = inferElementTypeFromUnionOfArrayTypes(domain).map(parseDateTimeIfPossible);

if (isContinuousScale(scale, type)) {
const combined = combineContinuousDomains(
parseContinuousDomain(fixedDomain, type),
inputDomain && parseContinuousDomain(inputDomain, type),
);
if (combined) {
scale.domain(order(combined));
}
} else {
scale.domain(
(reverse ? processedDomain.slice().reverse() : processedDomain).map(d =>
isDateTime(d) ? parseDateTime(d) : d,
order(
combineCategories(
parseDiscreteDomain(fixedDomain),
inputDomain && parseDiscreteDomain(inputDomain),
),
),
);
}
} else if (inputDomain) {
if (isContinuousScale(scale, type)) {
scale.domain(order(parseContinuousDomain(inputDomain, type)));
} else {
scale.domain(order(parseDiscreteDomain(inputDomain)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function isNotArray<T>(maybeArray: T | T[]): maybeArray is T {
return !Array.isArray(maybeArray);
}

export function isDefined<T>(value: any): value is T {
export function isDefined<T>(value: T | undefined | null): value is T {
return typeof value !== 'undefined' && value !== null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ export interface CombinedScaleConfig<Output extends Value = Value>
/**
* domain of the scale
*/
domain?: (number | undefined | null)[] | string[] | boolean[] | (DateTime | undefined | null)[];
domain?:
| number[]
| string[]
| boolean[]
| DateTime[]
| (number | undefined | null)[]
| (DateTime | undefined | null)[];
/**
* range of the scale
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Combine two arrays into a unique list
* by keeping the order the fixedCategories
* and append new categories at the end.
* @param fixedCategories
* @param inputCategories
*/
export default function combineCategories<T>(fixedCategories: T[], inputCategories: T[] = []) {
if (fixedCategories.length === 0) {
return inputCategories;
}

const fixedSet = new Set(fixedCategories);

return fixedCategories.concat(inputCategories.filter(d => !fixedSet.has(d)));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isEveryElementDefined, isDefined } from '../typeGuards/Base';

/**
* Combine two continuous domain and ensure that the output
* does not go beyond fixedDomain
* @param userSpecifiedDomain
* @param dataDomain
*/
export default function combineContinuousDomains(
userSpecifiedDomain: (number | Date | null | undefined)[],
dataDomain?: (number | Date)[],
) {
if (userSpecifiedDomain.length > 0 && isEveryElementDefined(userSpecifiedDomain)) {
return userSpecifiedDomain;
} else if (dataDomain) {
if (
userSpecifiedDomain.length === 2 &&
dataDomain.length === 2 &&
userSpecifiedDomain.filter(isDefined).length > 0
) {
const [userSpecifiedMin, userSpecifiedMax] = userSpecifiedDomain;
const [dataMin, dataMax] = dataDomain;
let min = dataMin;
if (isDefined(userSpecifiedMin)) {
min = userSpecifiedMin.valueOf() > dataMin.valueOf() ? userSpecifiedMin : dataMin;
}
let max = dataMax;
if (isDefined(userSpecifiedMax)) {
max = userSpecifiedMax.valueOf() < dataMax.valueOf() ? userSpecifiedMax : dataMax;
}

return [min, max];
}

return dataDomain;
}

return undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import parseContinuousDomain from '../../../src/parsers/domain/parseContinuousDomain';

describe('parseContinuousDomain()', () => {
it('parses time for time scale', () => {
expect(
parseContinuousDomain(
[0, 1, true, false, '2019-01-01', new Date(2019, 10, 1), null, undefined],
'time',
),
).toEqual([
new Date(0),
new Date(1),
new Date('2019-01-01'),
new Date(2019, 10, 1),
null,
undefined,
]);
});
it('parses number or leave date as-is for other scale', () => {
expect(
parseContinuousDomain(
[0, 1, true, false, '2019-01-01', new Date(2019, 10, 1), null, undefined],
'linear',
),
).toEqual([0, 1, 1, 0, NaN, new Date(2019, 10, 1), null, undefined]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import parseDiscreteDomain from '../../../src/parsers/domain/parseDiscreteDomain';

describe('parseDiscreteDomain()', () => {
it('parses every element to string', () => {
expect(
parseDiscreteDomain([1560384000000, 'abc', false, true, 0, 1, null, undefined]),
).toEqual(['1560384000000', 'abc', 'false', 'true', '0', '1', 'null', 'undefined']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { scaleLinear, scaleOrdinal } from 'd3-scale';
import applyDomain from '../../../src/parsers/scale/applyDomain';
import { HasToString } from '../../../src/types/Base';

describe('applyDomain()', () => {
describe('with scale.domain', () => {
describe('with domainFromDataset', () => {
it('continuous domain', () => {
const scale = scaleLinear();
applyDomain({ type: 'linear', domain: [null, 10] }, scale, [1, 20]);
expect(scale.domain()).toEqual([1, 10]);
});
it('discrete domain', () => {
const scale = scaleOrdinal<HasToString, string>();
applyDomain(
{ type: 'ordinal', domain: ['a', 'c'], range: ['red', 'green', 'blue'] },
scale,
['a', 'b', 'c'],
);
expect(scale.domain()).toEqual(['a', 'c', 'b']);
});
});
describe('without domainFromDataset', () => {
it('continuous domain', () => {
const scale = scaleLinear();
applyDomain({ type: 'linear', domain: [1, 10] }, scale);
expect(scale.domain()).toEqual([1, 10]);
});
it('discrete domain', () => {
const scale = scaleOrdinal<HasToString, string>();
applyDomain(
{ type: 'ordinal', domain: ['a', 'c'], range: ['red', 'green', 'blue'] },
scale,
);
expect(scale.domain()).toEqual(['a', 'c']);
});
});
});
describe('with domainFromDataset only', () => {
it('continuous domain', () => {
const scale = scaleLinear();
applyDomain({ type: 'linear' }, scale, [1, 20]);
expect(scale.domain()).toEqual([1, 20]);
});
it('discrete domain', () => {
const scale = scaleOrdinal<HasToString, string>();
applyDomain({ type: 'ordinal', range: ['red', 'green', 'blue'] }, scale, ['a', 'b', 'c']);
expect(scale.domain()).toEqual(['a', 'b', 'c']);
});
});
it('uses default domain if none is specified', () => {
const scale = scaleLinear();
applyDomain({ type: 'linear' }, scale);
expect(scale.domain()).toEqual([0, 1]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import combineCategories from '../../src/utils/combineCategories';

describe('combineCategories()', () => {
it('adds all categories from the first list and add new categories from the second list at the end', () => {
expect(combineCategories(['fish', 'beef', 'lamb'], ['lamb', 'fish', 'pork'])).toEqual([
'fish',
'beef',
'lamb',
'pork',
]);
});
it('works if the first list is empty', () => {
expect(combineCategories([], ['lamb', 'fish', 'pork'])).toEqual(['lamb', 'fish', 'pork']);
});
it('works if the second list is not given', () => {
expect(combineCategories(['fish', 'beef'])).toEqual(['fish', 'beef']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import combineContinuousDomains from '../../src/utils/combineContinuousDomains';

describe('combineContinuousDomains()', () => {
it('uses the fixedDomain if all values are defined', () => {
expect(combineContinuousDomains([1, 2, 3], [4, 5, 6])).toEqual([1, 2, 3]);
expect(combineContinuousDomains([1, 2], [4, 5])).toEqual([1, 2]);
});
describe('if both fixedDomain and inputDomain are of length two, uses the fixedDomain as boundary', () => {
describe('min only', () => {
it('exceeds bound', () => {
expect(combineContinuousDomains([1, null], [0, 10])).toEqual([1, 10]);
});
it('is within bound', () => {
expect(combineContinuousDomains([1, null], [2, 10])).toEqual([2, 10]);
});
});
describe('max only', () => {
it('exceeds bound', () => {
expect(combineContinuousDomains([null, 5], [0, 10])).toEqual([0, 5]);
});
it('is within bound', () => {
expect(combineContinuousDomains([null, 5], [2, 4])).toEqual([2, 4]);
});
});
});
it('returns inputDomain for invalid bound', () => {
expect(combineContinuousDomains([null, null], [2, 10])).toEqual([2, 10]);
expect(combineContinuousDomains([], [2, 10])).toEqual([2, 10]);
});
it('returns undefined if there is also no inputDomain', () => {
expect(combineContinuousDomains([null, null])).toBeUndefined();
});
});

0 comments on commit f5f944b

Please sign in to comment.