Skip to content

Commit

Permalink
feat: add functions for parsing scales (apache#207)
Browse files Browse the repository at this point in the history
* feat: add more util functions

* feat: add unit test

* feat: define HasToString

* fix: unit test

* fix: update unit tests

* feat: add scale types

* feat: update scale parsing

* fix: enum

* feat: add color scale extraction

* refactor: create scale from config

* feat: parse more scales and add more test

* feat: add tests for band and point

* test: add more unit tests

* refactor: separate applyXXX into multiple files

* feat: parse nice time

* test: add unit tests

* test: make 100% coverage

* fix: complete coverage

* refactor: update type definitions

* fix: address comments

* fix: add comments for date parts

* fix: build issue

* fix: broken tests
  • Loading branch information
kristw committed Aug 28, 2019
1 parent 8baede4 commit 6f56ed9
Show file tree
Hide file tree
Showing 30 changed files with 1,648 additions and 19 deletions.
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
],
"license": "Apache-2.0",
"devDependencies": {
"@superset-ui/build-config": "^0.1.1",
"@superset-ui/build-config": "^0.1.3",
"@superset-ui/commit-config": "^0.0.9",
"fast-glob": "^3.0.1",
"fs-extra": "^8.0.1",
Expand Down Expand Up @@ -90,6 +90,11 @@
]
},
"typescript": {
"compilerOptions": {
"typeRoots": [
"../../node_modules/vega-lite/typings"
]
},
"include": [
"./storybook/**/*"
]
Expand Down
16 changes: 10 additions & 6 deletions packages/superset-ui-dimension/src/svg/updateTextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ export default function updateTextNode(
textNode.setAttribute('class', className || '');
}

// clear style
STYLE_FIELDS.forEach((field: keyof TextStyle) => {
textNode.style[field] = null;
});
// Clear style
// Note: multi-word property names are hyphenated and not camel-cased.
textNode.style.removeProperty('font');
textNode.style.removeProperty('font-weight');
textNode.style.removeProperty('font-style');
textNode.style.removeProperty('font-size');
textNode.style.removeProperty('font-family');
textNode.style.removeProperty('letter-spacing');

// apply new style
// Note that the font field will auto-populate other font fields when applicable.
// Apply new style
// Note: the font field will auto-populate other font fields when applicable.
STYLE_FIELDS.filter(
(field: keyof TextStyle) => typeof style[field] !== 'undefined' && style[field] !== null,
).forEach((field: keyof TextStyle) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/superset-ui-encodeable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@
"private": true,
"dependencies": {
"lodash": "^4.17.15",
"@types/d3-scale": "^2.1.1",
"@types/d3-interpolate": "^1.3.1",
"@types/d3-time": "^1.0.10",
"d3-scale": "^3.0.1",
"d3-interpolate": "^1.3.2",
"d3-time": "^1.0.11",
"vega": "^5.4.0",
"vega-expression": "^2.6.0",
"vega-lite": "^3.4.0"
},
"peerDependencies": {
"@superset-ui/color": "^0.12.0",
"@superset-ui/number-format": "^0.12.0",
"@superset-ui/time-format": "^0.12.0"
}
Expand Down
31 changes: 31 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/parseDateTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { parse, codegen } from 'vega-expression';
import { dateTimeExpr } from 'vega-lite/build/src/datetime';
import { DateTime } from '../types/VegaLite';

export default function parseDateTime(dateTime: string | number | DateTime) {
if (typeof dateTime === 'number' || typeof dateTime === 'string') {
return new Date(dateTime);
}

const expression = dateTimeExpr(dateTime, true) as string;
const code = codegen({ globalvar: 'window' })(parse(expression)).code as string;
// Technically the "code" here is safe to eval(),
// but we will use more conservative approach and manually parse at the moment.
const isUtc = code.startsWith('Date.UTC');

const dateParts = code
.replace(/^(Date[.]UTC|new[ ]Date)\(/, '')
.replace(/\)$/, '')
.split(',')
.map((chunk: string) => Number(chunk.trim())) as [
number, // year
number, // month
number, // date
number, // hours
number, // minutes
number, // seconds
number, // milliseconds
];

return isUtc ? new Date(Date.UTC(...dateParts)) : new Date(...dateParts);
}
11 changes: 11 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyAlign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

export default function applyAlign<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('align' in config && typeof config.align !== 'undefined' && 'align' in scale) {
scale.align(config.align);
}
}
11 changes: 11 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyClamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

export default function applyClamp<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('clamp' in config && typeof config.clamp !== 'undefined' && 'clamp' in scale) {
scale.clamp(config.clamp);
}
}
21 changes: 21 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyDomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale, TimeScaleConfig } from '../../types/Scale';
import parseDateTime from '../parseDateTime';
import inferElementTypeFromUnionOfArrayTypes from '../../utils/inferElementTypeFromUnionOfArrayTypes';
import { isTimeScale } from '../../typeGuards/Scale';

export default function applyDomain<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
const { domain, reverse, type } = config;
if (typeof domain !== 'undefined') {
const processedDomain = reverse ? domain.slice().reverse() : domain;
if (isTimeScale(scale, type)) {
const timeDomain = processedDomain as TimeScaleConfig['domain'];
scale.domain(inferElementTypeFromUnionOfArrayTypes(timeDomain).map(d => parseDateTime(d)));
} else {
scale.domain(processedDomain);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

export default function applyInterpolate<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if (
'interpolate' in config &&
typeof config.interpolate !== 'undefined' &&
'interpolate' in scale
) {
// TODO: Need to convert interpolate string into interpolate function
throw new Error('"scale.interpolate" is not supported yet.');
}
}
77 changes: 77 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyNice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
timeSecond,
timeMinute,
timeHour,
timeDay,
timeYear,
timeMonth,
timeWeek,
utcSecond,
utcMinute,
utcHour,
utcDay,
utcWeek,
utcMonth,
utcYear,
CountableTimeInterval,
} from 'd3-time';
import { ScaleTime } from 'd3-scale';
import { Value, ScaleType, NiceTime } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

const localTimeIntervals: {
[key in NiceTime]: CountableTimeInterval;
} = {
day: timeDay,
hour: timeHour,
minute: timeMinute,
month: timeMonth,
second: timeSecond,
week: timeWeek,
year: timeYear,
};

const utcIntervals: {
[key in NiceTime]: CountableTimeInterval;
} = {
day: utcDay,
hour: utcHour,
minute: utcMinute,
month: utcMonth,
second: utcSecond,
week: utcWeek,
year: utcYear,
};

// eslint-disable-next-line complexity
export default function applyNice<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('nice' in config && typeof config.nice !== 'undefined' && 'nice' in scale) {
const { nice } = config;
if (typeof nice === 'boolean') {
if (nice === true) {
scale.nice();
}
} else if (typeof nice === 'number') {
scale.nice(nice);
} else {
const timeScale = scale as ScaleTime<Output, Output>;
const { type } = config;
if (typeof nice === 'string') {
timeScale.nice(type === ScaleType.UTC ? utcIntervals[nice] : localTimeIntervals[nice]);
} else {
const { interval, step } = nice;
const parsedInterval = (type === ScaleType.UTC
? utcIntervals[interval]
: localTimeIntervals[interval]
).every(step);

if (parsedInterval !== null) {
timeScale.nice(parsedInterval as CountableTimeInterval);
}
}
}
}
}
27 changes: 27 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyPadding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

export default function applyPadding<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('padding' in config && typeof config.padding !== 'undefined' && 'padding' in scale) {
scale.padding(config.padding);
}

if (
'paddingInner' in config &&
typeof config.paddingInner !== 'undefined' &&
'paddingInner' in scale
) {
scale.paddingInner(config.paddingInner);
}

if (
'paddingOuter' in config &&
typeof config.paddingOuter !== 'undefined' &&
'paddingOuter' in scale
) {
scale.paddingOuter(config.paddingOuter);
}
}
21 changes: 21 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getSequentialSchemeRegistry } from '@superset-ui/color';
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale } from '../../types/Scale';

export default function applyRange<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
const { range } = config;
if (typeof range === 'undefined') {
if ('scheme' in config && typeof config.scheme !== 'undefined') {
const { scheme } = config;
const colorScheme = getSequentialSchemeRegistry().get(scheme);
if (typeof colorScheme !== 'undefined') {
scale.range(colorScheme.colors as Output[]);
}
}
} else {
scale.range(range);
}
}
22 changes: 22 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyRound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { interpolateRound } from 'd3-interpolate';
import { ScalePoint, ScaleBand } from 'd3-scale';
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale, ContinuousD3Scale } from '../../types/Scale';
import { HasToString } from '../../types/Base';

export default function applyRound<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('round' in config && typeof config.round !== 'undefined') {
const roundableScale = scale as
| ContinuousD3Scale<number>
| ScalePoint<HasToString>
| ScaleBand<HasToString>;
if ('round' in roundableScale) {
roundableScale.round(config.round);
} else {
roundableScale.interpolate(interpolateRound);
}
}
}
12 changes: 12 additions & 0 deletions packages/superset-ui-encodeable/src/parsers/scale/applyZero.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Value } from '../../types/VegaLite';
import { ScaleConfig, D3Scale, ContinuousD3Scale } from '../../types/Scale';

export default function applyZero<Output extends Value>(
config: ScaleConfig<Output>,
scale: D3Scale<Output>,
) {
if ('zero' in config && typeof config.zero !== 'undefined') {
const [min, max] = (scale as ContinuousD3Scale<Output>).domain() as number[];
scale.domain([Math.min(0, min), Math.max(0, max)]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { CategoricalColorNamespace } from '@superset-ui/color';
import { ScaleType, Value } from '../../types/VegaLite';
import { ScaleConfig } from '../../types/Scale';
import createScaleFromScaleType from './createScaleFromScaleType';
import applyNice from './applyNice';
import applyZero from './applyZero';
import applyInterpolate from './applyInterpolate';
import applyRound from './applyRound';
import applyDomain from './applyDomain';
import applyRange from './applyRange';
import applyPadding from './applyPadding';
import applyAlign from './applyAlign';
import applyClamp from './applyClamp';

export default function createScaleFromScaleConfig<Output extends Value>(
config: ScaleConfig<Output>,
) {
const { domain, range, reverse } = config;

// Handle categorical color scales
// An ordinal scale without specified range
// is assumed to be a color scale.
if (config.type === ScaleType.ORDINAL && typeof range === 'undefined') {
const scheme = 'scheme' in config ? config.scheme : undefined;
const namespace = 'namespace' in config ? config.namespace : undefined;
const colorScale = CategoricalColorNamespace.getScale(scheme, namespace);

// If domain is also provided,
// ensure the nth item is assigned the nth color
if (typeof domain !== 'undefined') {
const { colors } = colorScale;
(reverse ? domain.slice().reverse() : domain).forEach((value: any, index: number) => {
colorScale.setColor(`${value}`, colors[index % colors.length]);
});
}

// Need to manually cast here to make the unioned output types
// considered function.
// Otherwise have to add type guards before using the scale function.
//
// const scaleFn = createScaleFromScaleConfig(...)
// if (isAFunction(scaleFn)) const encodedValue = scaleFn(10)
//
// CategoricalColorScale is actually a function,
// but TypeScript is not smart enough to realize that by itself.
return (colorScale as unknown) as (val?: any) => string;
}

const scale = createScaleFromScaleType(config);
// domain and range apply to all scales
applyDomain(config, scale);
applyRange(config, scale);
// Sort other properties alphabetically.
applyAlign(config, scale);
applyClamp(config, scale);
applyInterpolate(config, scale);
applyNice(config, scale);
applyPadding(config, scale);
applyRound(config, scale);
applyZero(config, scale);

return scale;
}
Loading

0 comments on commit 6f56ed9

Please sign in to comment.