mirror of
https://github.com/apache/superset.git
synced 2024-09-19 20:19:37 -04:00
feat: Add Encoder (#230)
* 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
This commit is contained in:
parent
e07b6210bd
commit
e11071cd9c
@ -126,4 +126,8 @@ export default class ChannelEncoder<Def extends ChannelDef<Output>, Output exten
|
|||||||
isY() {
|
isY() {
|
||||||
return isY(this.channelType);
|
return isY(this.channelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasLegend() {
|
||||||
|
return this.definition.legend !== false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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({}),
|
||||||
|
};
|
||||||
|
}
|
@ -2,12 +2,14 @@ import { ChannelDef, NonValueDef } from '../types/ChannelDef';
|
|||||||
import { ChannelType } from '../types/Channel';
|
import { ChannelType } from '../types/Channel';
|
||||||
import { isFieldDef, isValueDef, isTypedFieldDef } from '../typeGuards/ChannelDef';
|
import { isFieldDef, isValueDef, isTypedFieldDef } from '../typeGuards/ChannelDef';
|
||||||
import completeAxisConfig, { CompleteAxisConfig } from './completeAxisConfig';
|
import completeAxisConfig, { CompleteAxisConfig } from './completeAxisConfig';
|
||||||
|
import completeLegendConfig, { CompleteLegendConfig } from './completeLegendConfig';
|
||||||
import completeScaleConfig, { CompleteScaleConfig } from './completeScaleConfig';
|
import completeScaleConfig, { CompleteScaleConfig } from './completeScaleConfig';
|
||||||
import { Value, ValueDef, Type } from '../types/VegaLite';
|
import { Value, ValueDef, Type } from '../types/VegaLite';
|
||||||
import inferFieldType from './inferFieldType';
|
import inferFieldType from './inferFieldType';
|
||||||
|
|
||||||
export interface CompleteValueDef<Output extends Value = Value> extends ValueDef<Output> {
|
export interface CompleteValueDef<Output extends Value = Value> extends ValueDef<Output> {
|
||||||
axis: false;
|
axis: false;
|
||||||
|
legend: false;
|
||||||
scale: false;
|
scale: false;
|
||||||
title: '';
|
title: '';
|
||||||
}
|
}
|
||||||
@ -18,6 +20,7 @@ export type CompleteFieldDef<Output extends Value = Value> = Omit<
|
|||||||
> & {
|
> & {
|
||||||
type: Type;
|
type: Type;
|
||||||
axis: CompleteAxisConfig;
|
axis: CompleteAxisConfig;
|
||||||
|
legend: CompleteLegendConfig;
|
||||||
scale: CompleteScaleConfig<Output>;
|
scale: CompleteScaleConfig<Output>;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
@ -34,6 +37,7 @@ export default function completeChannelDef<Output extends Value>(
|
|||||||
return {
|
return {
|
||||||
...channelDef,
|
...channelDef,
|
||||||
axis: false,
|
axis: false,
|
||||||
|
legend: false,
|
||||||
scale: false,
|
scale: false,
|
||||||
title: '',
|
title: '',
|
||||||
};
|
};
|
||||||
@ -51,6 +55,7 @@ export default function completeChannelDef<Output extends Value>(
|
|||||||
return {
|
return {
|
||||||
...copy,
|
...copy,
|
||||||
axis: completeAxisConfig(channelType, copy),
|
axis: completeAxisConfig(channelType, copy),
|
||||||
|
legend: completeLegendConfig(channelType, copy),
|
||||||
scale: completeScaleConfig(channelType, copy),
|
scale: completeScaleConfig(channelType, copy),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 : {};
|
||||||
|
}
|
@ -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']]>;
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
export type Legend = boolean | null;
|
export type Legend = {};
|
||||||
|
|
||||||
export interface WithLegend {
|
export interface WithLegend {
|
||||||
legend?: Legend;
|
legend?: boolean | Legend;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -405,4 +405,29 @@ describe('ChannelEncoder', () => {
|
|||||||
expect(encoder.isXOrY()).toBeFalsy();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -19,6 +19,7 @@ const DEFAULT_OUTPUT = {
|
|||||||
title: 'speed',
|
title: 'speed',
|
||||||
titlePadding: 4,
|
titlePadding: 4,
|
||||||
},
|
},
|
||||||
|
legend: false,
|
||||||
scale: { type: 'linear', nice: true, clamp: true, zero: true },
|
scale: { type: 'linear', nice: true, clamp: true, zero: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,12 +50,12 @@ describe('completeChannelDef(channelType, channelDef)', () => {
|
|||||||
completeChannelDef('X', {
|
completeChannelDef('X', {
|
||||||
value: 1,
|
value: 1,
|
||||||
}),
|
}),
|
||||||
).toEqual({ axis: false, scale: false, title: '', value: 1 });
|
).toEqual({ axis: false, legend: false, scale: false, title: '', value: 1 });
|
||||||
});
|
});
|
||||||
it('leaves the title blank for invalid Def', () => {
|
it('leaves the title blank for invalid Def', () => {
|
||||||
expect(
|
expect(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
completeChannelDef('X', {}),
|
completeChannelDef('X', {}),
|
||||||
).toEqual({ axis: false, scale: false, title: '', type: 'quantitative' });
|
).toEqual({ axis: false, legend: false, scale: false, title: '', type: 'quantitative' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import completeLegendConfig from '../../src/fillers/completeLegendConfig';
|
||||||
|
|
||||||
|
describe('completeLegendConfig()', () => {
|
||||||
|
it('returns input legend config if legend is defined', () => {
|
||||||
|
expect(
|
||||||
|
completeLegendConfig('Color', {
|
||||||
|
type: 'nominal',
|
||||||
|
field: 'brand',
|
||||||
|
legend: { a: 1 },
|
||||||
|
}),
|
||||||
|
).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
it('returns default legend config if legend is undefined', () => {
|
||||||
|
expect(
|
||||||
|
completeLegendConfig('X', {
|
||||||
|
type: 'quantitative',
|
||||||
|
field: 'consumption',
|
||||||
|
}),
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
it('returns default legend config if legend is undefined and channel is not X or Y', () => {
|
||||||
|
expect(
|
||||||
|
completeLegendConfig('Color', {
|
||||||
|
type: 'nominal',
|
||||||
|
field: 'brand',
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
it('returns false if legend is false', () => {
|
||||||
|
expect(
|
||||||
|
completeLegendConfig('Color', {
|
||||||
|
type: 'nominal',
|
||||||
|
field: 'brand',
|
||||||
|
legend: false,
|
||||||
|
}),
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,13 @@
|
|||||||
|
import mergeEncoding from '../../src/utils/mergeEncoding';
|
||||||
|
|
||||||
|
describe('mergeEncoding()', () => {
|
||||||
|
it('combines two encoding together', () => {
|
||||||
|
expect(
|
||||||
|
mergeEncoding<{
|
||||||
|
size: ['Numeric', number];
|
||||||
|
}>({ size: { value: 1 } }, {}),
|
||||||
|
).toEqual({
|
||||||
|
size: { value: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user