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 authored and zhaoyongjie committed Nov 26, 2021
1 parent 3138aaa commit 024b318
Show file tree
Hide file tree
Showing 30 changed files with 1,648 additions and 19 deletions.
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
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
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
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);
}
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);
}
}
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);
}
}
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.');
}
}
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);
}
}
}
}
}
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);
}
}
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);
}
}
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);
}
}
}
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 024b318

Please sign in to comment.