Skip to content

Commit

Permalink
feat: Add Encoder (#230)
Browse files Browse the repository at this point in the history
* feat: add encoder

* feat: add encoder

* refactor: revamp encoding types and derivations

* test: add unit tests

* fix: unit tests

* test: add unit tests

* fix: remove unused code

* fix: channeltype
  • Loading branch information
kristw authored and zhaoyongjie committed Nov 26, 2021
1 parent e07b621 commit e11071c
Show file tree
Hide file tree
Showing 14 changed files with 389 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,8 @@ export default class ChannelEncoder<Def extends ChannelDef<Output>, Output exten
isY() {
return isY(this.channelType);
}

hasLegend() {
return this.definition.legend !== false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { flatMap } from 'lodash';
import { ChannelDef, TypedFieldDef } from '../types/ChannelDef';
import { MayBeArray } from '../types/Base';
import { isFieldDef } from '../typeGuards/ChannelDef';
import { isNotArray } from '../typeGuards/Base';
import ChannelEncoder from './ChannelEncoder';
import {
EncodingConfig,
DeriveEncoding,
DeriveChannelTypes,
DeriveChannelEncoders,
} from '../types/Encoding';

export default class Encoder<Config extends EncodingConfig> {
readonly encoding: DeriveEncoding<Config>;
readonly channelTypes: DeriveChannelTypes<Config>;
readonly channels: DeriveChannelEncoders<Config>;

readonly legends: {
[key: string]: (keyof Config)[];
};

constructor({
channelTypes,
encoding,
}: {
channelTypes: DeriveChannelTypes<Config>;
encoding: DeriveEncoding<Config>;
}) {
this.channelTypes = channelTypes;
this.encoding = encoding;
const channelNames = this.getChannelNames();

// Create channel encoders
const channels: { [k in keyof Config]?: MayBeArray<ChannelEncoder<ChannelDef>> } = {};

channelNames.forEach(name => {
const channelEncoding = encoding[name] as MayBeArray<ChannelDef>;
if (Array.isArray(channelEncoding)) {
const definitions = channelEncoding;
channels[name] = definitions.map(
(definition, i) =>
new ChannelEncoder({
channelType: channelTypes[name],
definition,
name: `${name}[${i}]`,
}),
);
} else {
const definition = channelEncoding;
channels[name] = new ChannelEncoder({
channelType: channelTypes[name],
definition,
name: name as string,
});
}
});

this.channels = channels as DeriveChannelEncoders<Config>;

// Group the channels that use the same field together
// so they can share the same legend.
this.legends = {};
channelNames
.map(name => this.channels[name])
.forEach(c => {
if (isNotArray(c) && c.hasLegend() && isFieldDef(c.definition)) {
const name = c.name as keyof Config;
const { field } = c.definition;
if (this.legends[field]) {
this.legends[field].push(name);
} else {
this.legends[field] = [name];
}
}
});
}

getChannelNames() {
return Object.keys(this.channelTypes) as (keyof Config)[];
}

getChannelEncoders() {
return this.getChannelNames().map(name => this.channels[name]);
}

getGroupBys() {
const fields = flatMap(this.getChannelEncoders())
.filter(c => c.isGroupBy())
.map(c => (c.definition as TypedFieldDef).field!);

return Array.from(new Set(fields));
}

hasLegend() {
return Object.keys(this.legends).length > 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Encoder from './Encoder';
import { EncodingConfig, DeriveChannelTypes, DeriveEncoding } from '../types/Encoding';
import mergeEncoding from '../utils/mergeEncoding';

type CreateEncoderFactoryParams<Config extends EncodingConfig> = {
channelTypes: DeriveChannelTypes<Config>;
} & (
| {
/**
* use the default approach to merge default encoding with user-specified encoding
* if there are missing fields
*/
defaultEncoding: DeriveEncoding<Config>;
}
| {
/**
* custom way to complete the encoding
* if there are missing fields
*/
completeEncoding: (e: Partial<DeriveEncoding<Config>>) => DeriveEncoding<Config>;
});

export default function createEncoderFactory<Config extends EncodingConfig>(
params: CreateEncoderFactoryParams<Config>,
) {
const { channelTypes } = params;
type PartialEncoding = Partial<DeriveEncoding<Config>>;

const completeEncoding =
'defaultEncoding' in params
? (encoding: PartialEncoding) => mergeEncoding(params.defaultEncoding, encoding)
: params.completeEncoding;

return {
channelTypes,
create: (encoding: PartialEncoding) =>
new Encoder<Config>({
channelTypes,
encoding: completeEncoding(encoding),
}),
DEFAULT_ENCODING: completeEncoding({}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { ChannelDef, NonValueDef } from '../types/ChannelDef';
import { ChannelType } from '../types/Channel';
import { isFieldDef, isValueDef, isTypedFieldDef } from '../typeGuards/ChannelDef';
import completeAxisConfig, { CompleteAxisConfig } from './completeAxisConfig';
import completeLegendConfig, { CompleteLegendConfig } from './completeLegendConfig';
import completeScaleConfig, { CompleteScaleConfig } from './completeScaleConfig';
import { Value, ValueDef, Type } from '../types/VegaLite';
import inferFieldType from './inferFieldType';

export interface CompleteValueDef<Output extends Value = Value> extends ValueDef<Output> {
axis: false;
legend: false;
scale: false;
title: '';
}
Expand All @@ -18,6 +20,7 @@ export type CompleteFieldDef<Output extends Value = Value> = Omit<
> & {
type: Type;
axis: CompleteAxisConfig;
legend: CompleteLegendConfig;
scale: CompleteScaleConfig<Output>;
title: string;
};
Expand All @@ -34,6 +37,7 @@ export default function completeChannelDef<Output extends Value>(
return {
...channelDef,
axis: false,
legend: false,
scale: false,
title: '',
};
Expand All @@ -51,6 +55,7 @@ export default function completeChannelDef<Output extends Value>(
return {
...copy,
axis: completeAxisConfig(channelType, copy),
legend: completeLegendConfig(channelType, copy),
scale: completeScaleConfig(channelType, copy),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Value } from '../types/VegaLite';
import { Legend } from '../types/Legend';
import { ChannelType } from '../types/Channel';
import { ChannelDef } from '../types/ChannelDef';
import { isXOrY } from '../typeGuards/Channel';

export type CompleteLegendConfig = false | Legend;

export default function completeLegendConfig<Output extends Value = Value>(
channelType: ChannelType,
channelDef: ChannelDef<Output>,
): CompleteLegendConfig {
if ('legend' in channelDef && channelDef.legend !== undefined) {
return channelDef.legend;
}

return isXOrY(channelType) ? false : {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ChannelType, ChannelTypeToDefMap } from './Channel';
import { Value } from './VegaLite';
import ChannelEncoder from '../encoders/ChannelEncoder';

export type EncodingConfig = {
[k in string]: [ChannelType, Value, 'multiple'?];
};

export type DeriveChannelTypes<Config extends EncodingConfig> = {
readonly [k in keyof Config]: Config[k]['0'];
};

export type DeriveChannelOutputs<Config extends EncodingConfig> = {
readonly [k in keyof Config]: Config[k]['1'];
};

export type DeriveEncoding<Config extends EncodingConfig> = {
[k in keyof Config]: Config[k]['2'] extends 'multiple'
? ChannelTypeToDefMap<Config[k]['1']>[Config[k]['0']][]
: ChannelTypeToDefMap<Config[k]['1']>[Config[k]['0']];
};

export type DeriveChannelEncoders<Config extends EncodingConfig> = {
readonly [k in keyof Config]: Config[k]['2'] extends 'multiple'
? ChannelEncoder<ChannelTypeToDefMap<Config[k]['1']>[Config[k]['0']]>[]
: ChannelEncoder<ChannelTypeToDefMap<Config[k]['1']>[Config[k]['0']]>;
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type Legend = boolean | null;
export type Legend = {};

export interface WithLegend {
legend?: Legend;
legend?: boolean | Legend;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { EncodingConfig, DeriveEncoding } from '../types/Encoding';

export default function mergeEncoding<Config extends EncodingConfig>(
defaultEncoding: DeriveEncoding<Config>,
encoding: Partial<DeriveEncoding<Config>>,
): DeriveEncoding<Config> {
return {
...defaultEncoding,
...encoding,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,29 @@ describe('ChannelEncoder', () => {
expect(encoder.isXOrY()).toBeFalsy();
});
});

describe('.hasLegend()', () => {
it('returns true if channel has a legend', () => {
const encoder = new ChannelEncoder({
name: 'bubbleColor',
channelType: 'Color',
definition: {
type: 'nominal',
field: 'brand',
},
});
expect(encoder.hasLegend()).toBeTruthy();
});
it('returns false otherwise', () => {
const encoder = new ChannelEncoder({
name: 'x',
channelType: 'X',
definition: {
type: 'quantitative',
field: 'speed',
},
});
expect(encoder.hasLegend()).toBeFalsy();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import createEncoderFactory from '../../src/encoders/createEncoderFactory';

describe('Encoder', () => {
const factory = createEncoderFactory<{
x: ['X', number];
y: ['Y', number];
color: ['Color', string];
shape: ['Category', string];
tooltip: ['Text', string, 'multiple'];
}>({
channelTypes: {
x: 'X',
y: 'Y',
color: 'Color',
shape: 'Category',
tooltip: 'Text',
},
defaultEncoding: {
x: { type: 'quantitative', field: 'speed' },
y: { type: 'quantitative', field: 'price' },
color: { type: 'nominal', field: 'brand' },
shape: { type: 'nominal', field: 'brand' },
tooltip: [{ field: 'make' }, { field: 'model' }],
},
});

const encoder = factory.create();

describe('new Encoder()', () => {
it('creates new encoder', () => {
expect(encoder).toBeDefined();
});
});
describe('.getChannelNames()', () => {
it('returns an array of channel names', () => {
expect(encoder.getChannelNames()).toEqual(['x', 'y', 'color', 'shape', 'tooltip']);
});
});
describe('.getChannelEncoders()', () => {
it('returns an array of channel encoders', () => {
expect(encoder.getChannelEncoders()).toHaveLength(5);
});
});
describe('.getGroupBys()', () => {
it('returns an array of groupby fields', () => {
expect(encoder.getGroupBys()).toEqual(['brand', 'make', 'model']);
});
});
describe('.hasLegend()', () => {
it('returns true if has legend', () => {
expect(encoder.hasLegend()).toBeTruthy();
});
it('returns false if does not have legend', () => {
expect(
factory
.create({
color: { type: 'nominal', field: 'brand', legend: false },
shape: { value: 'diamond' },
})
.hasLegend(),
).toBeFalsy();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import createEncoderFactory from '../../src/encoders/createEncoderFactory';

describe('createEncoderFactory()', () => {
it('supports defaultEncoding as fixed value', () => {
const factory = createEncoderFactory<{
x: ['X', number];
}>({
channelTypes: {
x: 'X',
},
defaultEncoding: {
x: { type: 'quantitative', field: 'speed' },
},
});

const encoder = factory.create();
expect(encoder.encoding).toEqual({
x: { type: 'quantitative', field: 'speed' },
});
});
it('supports completeEncoding for customization', () => {
const factory = createEncoderFactory<{
color: ['Color', string];
}>({
channelTypes: {
color: 'Color',
},
completeEncoding: () => ({
color: { value: 'red' },
}),
});

const encoder = factory.create();
expect(encoder.encoding).toEqual({
color: { value: 'red' },
});
});
});

0 comments on commit e11071c

Please sign in to comment.