From 6f56ed9ded759eb1f3510912b69d80fabcdf16dc Mon Sep 17 00:00:00 2001 From: Krist Wongsuphasawat Date: Wed, 28 Aug 2019 12:11:49 -0700 Subject: [PATCH] feat: add functions for parsing scales (#207) * 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 --- package.json | 7 +- .../src/svg/updateTextNode.ts | 16 +- packages/superset-ui-encodeable/package.json | 8 + .../src/parsers/parseDateTime.ts | 31 + .../src/parsers/scale/applyAlign.ts | 11 + .../src/parsers/scale/applyClamp.ts | 11 + .../src/parsers/scale/applyDomain.ts | 21 + .../src/parsers/scale/applyInterpolate.ts | 16 + .../src/parsers/scale/applyNice.ts | 77 +++ .../src/parsers/scale/applyPadding.ts | 27 + .../src/parsers/scale/applyRange.ts | 21 + .../src/parsers/scale/applyRound.ts | 22 + .../src/parsers/scale/applyZero.ts | 12 + .../scale/createScaleFromScaleConfig.ts | 63 ++ .../parsers/scale/createScaleFromScaleType.ts | 62 ++ .../scale/getScaleCategoryFromScaleType.ts | 23 + .../src/parsers/scale/inferScaleType.ts | 63 ++ .../scale/isPropertySupportedByScaleType.ts | 53 ++ .../src/parsers/scale/scaleCategories.ts | 75 +++ .../src/typeGuards/Scale.ts | 24 + .../superset-ui-encodeable/src/types/Base.ts | 3 + .../superset-ui-encodeable/src/types/Scale.ts | 227 ++++++- .../src/types/VegaLite.ts | 2 +- .../inferElementTypeFromUnionOfArrayTypes.ts | 10 + .../test/parsers/parseDateTime.test.ts | 29 + .../scale/createScaleFromScaleConfig.test.ts | 624 ++++++++++++++++++ .../getScaleCategoryFromScaleType.test.ts | 27 + .../test/parsers/scale/inferScaleType.test.ts | 58 ++ .../isPropertySupportedByScaleType.test.ts | 10 + .../test/typeGuards/Scale.test.ts | 34 + 30 files changed, 1648 insertions(+), 19 deletions(-) create mode 100644 packages/superset-ui-encodeable/src/parsers/parseDateTime.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyAlign.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyClamp.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyDomain.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyInterpolate.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyNice.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyPadding.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyRange.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyRound.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/applyZero.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleConfig.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleType.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/getScaleCategoryFromScaleType.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/inferScaleType.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/isPropertySupportedByScaleType.ts create mode 100644 packages/superset-ui-encodeable/src/parsers/scale/scaleCategories.ts create mode 100644 packages/superset-ui-encodeable/src/typeGuards/Scale.ts create mode 100644 packages/superset-ui-encodeable/src/utils/inferElementTypeFromUnionOfArrayTypes.ts create mode 100644 packages/superset-ui-encodeable/test/parsers/parseDateTime.test.ts create mode 100644 packages/superset-ui-encodeable/test/parsers/scale/createScaleFromScaleConfig.test.ts create mode 100644 packages/superset-ui-encodeable/test/parsers/scale/getScaleCategoryFromScaleType.test.ts create mode 100644 packages/superset-ui-encodeable/test/parsers/scale/inferScaleType.test.ts create mode 100644 packages/superset-ui-encodeable/test/parsers/scale/isPropertySupportedByScaleType.test.ts create mode 100644 packages/superset-ui-encodeable/test/typeGuards/Scale.test.ts diff --git a/package.json b/package.json index a3f31fa68a..29525a5b49 100644 --- a/package.json +++ b/package.json @@ -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", @@ -90,6 +90,11 @@ ] }, "typescript": { + "compilerOptions": { + "typeRoots": [ + "../../node_modules/vega-lite/typings" + ] + }, "include": [ "./storybook/**/*" ] diff --git a/packages/superset-ui-dimension/src/svg/updateTextNode.ts b/packages/superset-ui-dimension/src/svg/updateTextNode.ts index 4dea4791c1..b57b79bbce 100644 --- a/packages/superset-ui-dimension/src/svg/updateTextNode.ts +++ b/packages/superset-ui-dimension/src/svg/updateTextNode.ts @@ -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) => { diff --git a/packages/superset-ui-encodeable/package.json b/packages/superset-ui-encodeable/package.json index fca30474dd..e1ed8e5381 100644 --- a/packages/superset-ui-encodeable/package.json +++ b/packages/superset-ui-encodeable/package.json @@ -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" } diff --git a/packages/superset-ui-encodeable/src/parsers/parseDateTime.ts b/packages/superset-ui-encodeable/src/parsers/parseDateTime.ts new file mode 100644 index 0000000000..7d3ee2e4fd --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/parseDateTime.ts @@ -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); +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyAlign.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyAlign.ts new file mode 100644 index 0000000000..3af1971148 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyAlign.ts @@ -0,0 +1,11 @@ +import { Value } from '../../types/VegaLite'; +import { ScaleConfig, D3Scale } from '../../types/Scale'; + +export default function applyAlign( + config: ScaleConfig, + scale: D3Scale, +) { + if ('align' in config && typeof config.align !== 'undefined' && 'align' in scale) { + scale.align(config.align); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyClamp.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyClamp.ts new file mode 100644 index 0000000000..e17b866793 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyClamp.ts @@ -0,0 +1,11 @@ +import { Value } from '../../types/VegaLite'; +import { ScaleConfig, D3Scale } from '../../types/Scale'; + +export default function applyClamp( + config: ScaleConfig, + scale: D3Scale, +) { + if ('clamp' in config && typeof config.clamp !== 'undefined' && 'clamp' in scale) { + scale.clamp(config.clamp); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyDomain.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyDomain.ts new file mode 100644 index 0000000000..312f87d4f3 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyDomain.ts @@ -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( + config: ScaleConfig, + scale: D3Scale, +) { + 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); + } + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyInterpolate.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyInterpolate.ts new file mode 100644 index 0000000000..a53144aa55 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyInterpolate.ts @@ -0,0 +1,16 @@ +import { Value } from '../../types/VegaLite'; +import { ScaleConfig, D3Scale } from '../../types/Scale'; + +export default function applyInterpolate( + config: ScaleConfig, + scale: D3Scale, +) { + 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.'); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyNice.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyNice.ts new file mode 100644 index 0000000000..6c3dc8afdd --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyNice.ts @@ -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( + config: ScaleConfig, + scale: D3Scale, +) { + 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; + 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); + } + } + } + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyPadding.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyPadding.ts new file mode 100644 index 0000000000..8048114e70 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyPadding.ts @@ -0,0 +1,27 @@ +import { Value } from '../../types/VegaLite'; +import { ScaleConfig, D3Scale } from '../../types/Scale'; + +export default function applyPadding( + config: ScaleConfig, + scale: D3Scale, +) { + 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); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyRange.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyRange.ts new file mode 100644 index 0000000000..42354fe174 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyRange.ts @@ -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( + config: ScaleConfig, + scale: D3Scale, +) { + 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); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyRound.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyRound.ts new file mode 100644 index 0000000000..1776c79900 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyRound.ts @@ -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( + config: ScaleConfig, + scale: D3Scale, +) { + if ('round' in config && typeof config.round !== 'undefined') { + const roundableScale = scale as + | ContinuousD3Scale + | ScalePoint + | ScaleBand; + if ('round' in roundableScale) { + roundableScale.round(config.round); + } else { + roundableScale.interpolate(interpolateRound); + } + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/applyZero.ts b/packages/superset-ui-encodeable/src/parsers/scale/applyZero.ts new file mode 100644 index 0000000000..c28c0024bb --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/applyZero.ts @@ -0,0 +1,12 @@ +import { Value } from '../../types/VegaLite'; +import { ScaleConfig, D3Scale, ContinuousD3Scale } from '../../types/Scale'; + +export default function applyZero( + config: ScaleConfig, + scale: D3Scale, +) { + if ('zero' in config && typeof config.zero !== 'undefined') { + const [min, max] = (scale as ContinuousD3Scale).domain() as number[]; + scale.domain([Math.min(0, min), Math.max(0, max)]); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleConfig.ts b/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleConfig.ts new file mode 100644 index 0000000000..d72356b11f --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleConfig.ts @@ -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( + config: ScaleConfig, +) { + 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; +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleType.ts b/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleType.ts new file mode 100644 index 0000000000..3adc1a51b5 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/createScaleFromScaleType.ts @@ -0,0 +1,62 @@ +import { + scaleLinear, + scaleLog, + scalePow, + scaleSqrt, + scaleTime, + scaleUtc, + scaleQuantile, + scaleQuantize, + scaleThreshold, + scaleOrdinal, + scalePoint, + scaleBand, +} from 'd3-scale'; +import { HasToString } from '../../types/Base'; +import { ScaleConfig } from '../../types/Scale'; +import { ScaleType, Value } from '../../types/VegaLite'; + +// eslint-disable-next-line complexity +export default function createScaleFromScaleType( + config: ScaleConfig, +) { + switch (config.type) { + case ScaleType.LINEAR: + return scaleLinear(); + case ScaleType.LOG: + return typeof config.base === 'undefined' + ? scaleLog() + : scaleLog().base(config.base); + case ScaleType.POW: + return typeof config.exponent === 'undefined' + ? scalePow() + : scalePow().exponent(config.exponent); + case ScaleType.SQRT: + return scaleSqrt(); + case ScaleType.TIME: + return scaleTime(); + case ScaleType.UTC: + return scaleUtc(); + case ScaleType.QUANTILE: + return scaleQuantile(); + case ScaleType.QUANTIZE: + return scaleQuantize(); + case ScaleType.THRESHOLD: + return scaleThreshold(); + case ScaleType.ORDINAL: + return scaleOrdinal(); + case ScaleType.POINT: + return scalePoint(); + case ScaleType.BAND: + return scaleBand(); + case ScaleType.SYMLOG: + // TODO: d3-scale typings does not include scaleSymlog yet + // needs to patch the declaration file before continue. + throw new Error(`"scale.type = ${config.type}" is not supported yet.`); + case ScaleType.BIN_ORDINAL: + // TODO: Pending scale.bins implementation + throw new Error(`"scale.type = ${config.type}" is not supported yet.`); + default: + return scaleLinear(); + } +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/getScaleCategoryFromScaleType.ts b/packages/superset-ui-encodeable/src/parsers/scale/getScaleCategoryFromScaleType.ts new file mode 100644 index 0000000000..092f328b6f --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/getScaleCategoryFromScaleType.ts @@ -0,0 +1,23 @@ +import { ScaleType } from '../../types/VegaLite'; +import { + continuousScaleTypesSet, + discreteScaleTypesSet, + discretizingScaleTypesSet, +} from './scaleCategories'; +import { ScaleCategory } from '../../types/Scale'; + +export default function getScaleCategoryFromScaleType( + scaleType: ScaleType, +): ScaleCategory | undefined { + if (continuousScaleTypesSet.has(scaleType)) { + return 'continuous'; + } + if (discreteScaleTypesSet.has(scaleType)) { + return 'discrete'; + } + if (discretizingScaleTypesSet.has(scaleType)) { + return 'discretizing'; + } + + return undefined; +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/inferScaleType.ts b/packages/superset-ui-encodeable/src/parsers/scale/inferScaleType.ts new file mode 100644 index 0000000000..9331000651 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/inferScaleType.ts @@ -0,0 +1,63 @@ +import { Type, ScaleType } from '../../types/VegaLite'; +import { ChannelType } from '../../types/Channel'; + +/** + * Sometimes scale type is not specified but can be inferred + * from other fields. + * See https://vega.github.io/vega-lite/docs/scale.html + * @param channelType type of the channel + * @param fieldType type of the field + * @param isBinned is value binned + */ +// eslint-disable-next-line complexity +export default function inferScaleType( + channelType: ChannelType, + fieldType?: Type, + isBinned: boolean = false, +): ScaleType | undefined { + if (fieldType === 'nominal' || fieldType === 'ordinal') { + switch (channelType) { + // For positional (x and y) ordinal and ordinal fields, + // "point" is the default scale type for all marks + // except bar and rect marks, which use "band" scales. + // https://vega.github.io/vega-lite/docs/scale.html + case 'XBand': + case 'YBand': + return ScaleType.BAND; + case 'X': + case 'Y': + case 'Numeric': + return ScaleType.POINT; + case 'Color': + case 'Category': + return ScaleType.ORDINAL; + default: + } + } else if (fieldType === 'quantitative') { + switch (channelType) { + case 'XBand': + case 'YBand': + case 'X': + case 'Y': + case 'Numeric': + return ScaleType.LINEAR; + case 'Color': + return isBinned ? ScaleType.BIN_ORDINAL : ScaleType.LINEAR; + default: + } + } else if (fieldType === 'temporal') { + switch (channelType) { + case 'XBand': + case 'YBand': + case 'X': + case 'Y': + case 'Numeric': + return ScaleType.UTC; + case 'Color': + return ScaleType.LINEAR; + default: + } + } + + return undefined; +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/isPropertySupportedByScaleType.ts b/packages/superset-ui-encodeable/src/parsers/scale/isPropertySupportedByScaleType.ts new file mode 100644 index 0000000000..93beaa3d5b --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/isPropertySupportedByScaleType.ts @@ -0,0 +1,53 @@ +import { ScaleType } from '../../types/VegaLite'; +import { CombinedScaleConfig } from '../../types/Scale'; +import { + allScaleTypesSet, + allScaleTypes, + continuousDomainScaleTypes, + continuousScaleTypes, + continuousScaleTypesSet, +} from './scaleCategories'; + +const pointOrBand: ScaleType[] = [ScaleType.POINT, ScaleType.BAND]; +const pointOrBandSet = new Set(pointOrBand); +const exceptPointOrBand = allScaleTypes.filter(type => !pointOrBandSet.has(type)); +const exceptPointOrBandSet = new Set(exceptPointOrBand); +const continuousOrPointOrBandSet = new Set(continuousScaleTypes.concat(pointOrBand)); + +const zeroSet = new Set(continuousDomainScaleTypes); +// log scale cannot have zero value +zeroSet.delete(ScaleType.LOG); +// zero is not meaningful for time +zeroSet.delete(ScaleType.TIME); +zeroSet.delete(ScaleType.UTC); +// threshold requires custom domain so zero does not matter +zeroSet.delete(ScaleType.THRESHOLD); +// quantile depends on distribution so zero does not matter +zeroSet.delete(ScaleType.QUANTILE); + +const supportedScaleTypes: Record> = { + align: pointOrBandSet, + base: new Set([ScaleType.LOG]), + clamp: continuousScaleTypesSet, + constant: new Set([ScaleType.SYMLOG]), + domain: allScaleTypesSet, + exponent: new Set([ScaleType.POW]), + interpolate: exceptPointOrBandSet, + namespace: new Set([ScaleType.ORDINAL]), + nice: new Set(continuousScaleTypes.concat([ScaleType.QUANTIZE, ScaleType.THRESHOLD])), + padding: continuousOrPointOrBandSet, + paddingInner: new Set([ScaleType.BAND]), + paddingOuter: pointOrBandSet, + range: allScaleTypesSet, + reverse: allScaleTypesSet, + round: continuousOrPointOrBandSet, + scheme: exceptPointOrBandSet, + zero: zeroSet, +}; + +export default function isPropertySupportedByScaleType( + property: keyof CombinedScaleConfig, + scaleType: ScaleType, +) { + return supportedScaleTypes[property].has(scaleType); +} diff --git a/packages/superset-ui-encodeable/src/parsers/scale/scaleCategories.ts b/packages/superset-ui-encodeable/src/parsers/scale/scaleCategories.ts new file mode 100644 index 0000000000..450e12b381 --- /dev/null +++ b/packages/superset-ui-encodeable/src/parsers/scale/scaleCategories.ts @@ -0,0 +1,75 @@ +import { ScaleType } from '../../types/VegaLite'; + +// Grouped by domain and range + +export const continuousToContinuousScaleTypes: ScaleType[] = [ + ScaleType.LINEAR, + ScaleType.POW, + ScaleType.SQRT, + ScaleType.SYMLOG, + ScaleType.LOG, + ScaleType.TIME, + ScaleType.UTC, +]; +export const continuousToContinuousScaleTypesSet = new Set(continuousToContinuousScaleTypes); + +export const continuousToDiscreteScaleTypes: ScaleType[] = [ + ScaleType.QUANTILE, + ScaleType.QUANTIZE, + ScaleType.THRESHOLD, +]; +export const continuousToDiscreteScaleTypesSet = new Set(continuousToDiscreteScaleTypes); + +// Grouped by Domain + +export const continuousDomainScaleTypes: ScaleType[] = continuousToContinuousScaleTypes.concat( + continuousToDiscreteScaleTypes, +); +export const continuousDomainScaleTypesSet = new Set(continuousDomainScaleTypes); + +export const discreteDomainScaleTypes: ScaleType[] = [ + ScaleType.ORDINAL, + ScaleType.BIN_ORDINAL, + ScaleType.POINT, + ScaleType.BAND, +]; +export const discreteDomainScaleTypesSet = new Set(discreteDomainScaleTypes); + +// Three broad categories + +export const continuousScaleTypes: ScaleType[] = continuousToContinuousScaleTypes; +export const continuousScaleTypesSet = continuousToContinuousScaleTypesSet; + +export const discreteScaleTypes: ScaleType[] = [ScaleType.BAND, ScaleType.POINT, ScaleType.ORDINAL]; +export const discreteScaleTypesSet = new Set(discreteScaleTypes); + +export const discretizingScaleTypes: ScaleType[] = [ + ScaleType.BIN_ORDINAL, + ScaleType.QUANTILE, + ScaleType.QUANTIZE, + ScaleType.THRESHOLD, +]; +export const discretizingScaleTypesSet = new Set(discretizingScaleTypes); + +// Others + +export const timeScaleTypes: ScaleType[] = [ScaleType.TIME, ScaleType.UTC]; +export const timeScaleTypesSet = new Set(timeScaleTypes); + +export const allScaleTypes = [ + ScaleType.LINEAR, + ScaleType.LOG, + ScaleType.POW, + ScaleType.SQRT, + ScaleType.SYMLOG, + ScaleType.TIME, + ScaleType.UTC, + ScaleType.QUANTILE, + ScaleType.QUANTIZE, + ScaleType.THRESHOLD, + ScaleType.BIN_ORDINAL, + ScaleType.ORDINAL, + ScaleType.POINT, + ScaleType.BAND, +]; +export const allScaleTypesSet = new Set(allScaleTypes); diff --git a/packages/superset-ui-encodeable/src/typeGuards/Scale.ts b/packages/superset-ui-encodeable/src/typeGuards/Scale.ts new file mode 100644 index 0000000000..c9641f74b6 --- /dev/null +++ b/packages/superset-ui-encodeable/src/typeGuards/Scale.ts @@ -0,0 +1,24 @@ +import { CategoricalColorScale } from '@superset-ui/color'; +import { ScaleTime } from 'd3-scale'; +import { D3Scale } from '../types/Scale'; +import { Value, ScaleType } from '../types/VegaLite'; +import { timeScaleTypesSet } from '../parsers/scale/scaleCategories'; + +export function isCategoricalColorScale( + scale: D3Scale | CategoricalColorScale, +): scale is CategoricalColorScale { + return scale instanceof CategoricalColorScale; +} + +export function isD3Scale( + scale: D3Scale | CategoricalColorScale, +): scale is D3Scale { + return !isCategoricalColorScale(scale); +} + +export function isTimeScale( + scale: D3Scale | CategoricalColorScale, + scaleType: ScaleType, +): scale is ScaleTime { + return scale && timeScaleTypesSet.has(scaleType); +} diff --git a/packages/superset-ui-encodeable/src/types/Base.ts b/packages/superset-ui-encodeable/src/types/Base.ts index 9f45402e08..bc31304aed 100644 --- a/packages/superset-ui-encodeable/src/types/Base.ts +++ b/packages/superset-ui-encodeable/src/types/Base.ts @@ -3,3 +3,6 @@ export type Unarray = T extends Array ? U : T; /** T or an array of T */ export type MayBeArray = T | T[]; + +/** A value that has .toString() function */ +export type HasToString = { toString(): string }; diff --git a/packages/superset-ui-encodeable/src/types/Scale.ts b/packages/superset-ui-encodeable/src/types/Scale.ts index 324e6b5c6c..7e8741c1d2 100644 --- a/packages/superset-ui-encodeable/src/types/Scale.ts +++ b/packages/superset-ui-encodeable/src/types/Scale.ts @@ -1,19 +1,224 @@ -import { Value, DateTime, ScaleType, SchemeParams } from './VegaLite'; +import { + ScaleOrdinal, + ScaleLinear, + ScaleLogarithmic, + ScalePower, + ScaleTime, + ScaleQuantile, + ScaleQuantize, + ScaleThreshold, + ScalePoint, + ScaleBand, +} from 'd3-scale'; +import { Value, DateTime, NiceTime, ScaleType, Scale as VegaLiteScale } from './VegaLite'; +import { HasToString } from './Base'; -export interface Scale { - type?: ScaleType; +// Pick properties inherited from vega-lite +// and overrides a few properties. +// Then make the specific scales pick +// from this interface to share property documentation +// (which is useful for auto-complete/intellisense) +// and add `type` property as discriminant of union type. + +export interface CombinedScaleConfig + extends Pick< + VegaLiteScale, + | 'align' + | 'base' + | 'clamp' + | 'constant' + | 'exponent' + | 'interpolate' + | 'padding' + | 'paddingInner' + | 'paddingOuter' + | 'reverse' + | 'round' + | 'zero' + > { + // These fields have different types from original vega-lite + /** + * domain of the scale + */ domain?: number[] | string[] | boolean[] | DateTime[]; - paddingInner?: number; - paddingOuter?: number; + /** + * range of the scale + */ range?: Output[]; - clamp?: boolean; - nice?: boolean; - /** color scheme name */ - scheme?: string | SchemeParams; - /** vega-lite does not have this */ + /** + * name of the color scheme. + * vega-lite also support SchemeParams object + * but encodeable only accepts string at the moment + */ + scheme?: string; + /** + * color namespace. + * vega-lite does not have this field + */ namespace?: string; + /** + * Extending the domain so that it starts and ends on nice round values. This method typically modifies the scale’s domain, and may only extend the bounds to the nearest round value. Nicing is useful if the domain is computed from data and may be irregular. For example, for a domain of _[0.201479…, 0.996679…]_, a nice domain might be _[0.2, 1.0]_. + * + * For quantitative scales such as linear, `nice` can be either a boolean flag or a number. If `nice` is a number, it will represent a desired tick count. This allows greater control over the step size used to extend the bounds, guaranteeing that the returned ticks will exactly cover the domain. + * + * For temporal fields with time and utc scales, the `nice` value can be a string indicating the desired time interval. Legal values are `"millisecond"`, `"second"`, `"minute"`, `"hour"`, `"day"`, `"week"`, `"month"`, and `"year"`. Alternatively, `time` and `utc` scales can accept an object-valued interval specifier of the form `{"interval": "month", "step": 3}`, which includes a desired number of interval steps. Here, the domain would snap to quarter (Jan, Apr, Jul, Oct) boundaries. + * + * __Default value:__ `true` for unbinned _quantitative_ fields; `false` otherwise. + * + */ + nice?: boolean | number | NiceTime | { interval: NiceTime; step: number }; +} + +type PickFromCombinedScaleConfig< + Output extends Value, + Fields extends keyof CombinedScaleConfig +> = Pick, 'domain' | 'range' | 'reverse' | Fields>; + +export interface LinearScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'clamp' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' | 'zero' + > { + type: 'linear'; +} + +export interface LogScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'base' | 'clamp' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' | 'zero' + > { + type: 'log'; +} + +export interface PowScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'clamp' | 'exponent' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' + > { + type: 'pow'; +} + +export interface SqrtScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'clamp' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' | 'zero' + > { + type: 'sqrt'; +} + +export interface SymlogScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'clamp' | 'constant' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' | 'zero' + > { + type: 'symlog'; +} + +interface BaseTimeScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'clamp' | 'interpolate' | 'nice' | 'padding' | 'round' | 'scheme' + > { + domain?: number[] | string[] | DateTime[]; +} + +export interface TimeScaleConfig extends BaseTimeScaleConfig { + type: 'time'; +} + +export interface UtcScaleConfig extends BaseTimeScaleConfig { + type: 'utc'; +} + +export interface QuantileScaleConfig + extends PickFromCombinedScaleConfig { + type: 'quantile'; +} + +export interface QuantizeScaleConfig + extends PickFromCombinedScaleConfig { + type: 'quantize'; +} + +export interface ThresholdScaleConfig + extends PickFromCombinedScaleConfig { + type: 'threshold'; } +export interface BinOrdinalScaleConfig + extends PickFromCombinedScaleConfig { + type: 'bin-ordinal'; +} + +export interface OrdinalScaleConfig + extends PickFromCombinedScaleConfig { + type: 'ordinal'; +} + +export interface PointScaleConfig + extends PickFromCombinedScaleConfig { + type: 'point'; +} + +export interface BandScaleConfig + extends PickFromCombinedScaleConfig< + Output, + 'align' | 'padding' | 'paddingInner' | 'paddingOuter' | 'round' + > { + type: 'band'; +} + +export type ScaleConfig = + | LinearScaleConfig + | LogScaleConfig + | PowScaleConfig + | SqrtScaleConfig + | SymlogScaleConfig + | TimeScaleConfig + | UtcScaleConfig + | QuantileScaleConfig + | QuantizeScaleConfig + | ThresholdScaleConfig + | BinOrdinalScaleConfig + | OrdinalScaleConfig + | PointScaleConfig + | BandScaleConfig; + export interface WithScale { - scale?: Scale; + scale?: Partial>; } + +/** Each ScaleCategory contains one or more ScaleType */ +export type ScaleCategory = 'continuous' | 'discrete' | 'discretizing'; + +export interface ScaleTypeToD3ScaleType { + [ScaleType.LINEAR]: ScaleLinear; + [ScaleType.LOG]: ScaleLogarithmic; + [ScaleType.POW]: ScalePower; + [ScaleType.SQRT]: ScalePower; + [ScaleType.SYMLOG]: ScaleLogarithmic; + [ScaleType.TIME]: ScaleTime; + [ScaleType.UTC]: ScaleTime; + [ScaleType.QUANTILE]: ScaleQuantile; + [ScaleType.QUANTIZE]: ScaleQuantize; + [ScaleType.THRESHOLD]: ScaleThreshold; + [ScaleType.BIN_ORDINAL]: ScaleOrdinal; + [ScaleType.ORDINAL]: ScaleOrdinal; + [ScaleType.POINT]: ScalePoint; + [ScaleType.BAND]: ScaleBand; +} + +export type ContinuousD3Scale = + | ScaleLinear + | ScaleLogarithmic + | ScalePower + | ScaleTime; + +export type D3Scale = + | ContinuousD3Scale + | ScaleQuantile + | ScaleQuantize + | ScaleThreshold + | ScaleOrdinal + | ScalePoint + | ScaleBand; diff --git a/packages/superset-ui-encodeable/src/types/VegaLite.ts b/packages/superset-ui-encodeable/src/types/VegaLite.ts index 4eaf4500d8..27285b1e3f 100644 --- a/packages/superset-ui-encodeable/src/types/VegaLite.ts +++ b/packages/superset-ui-encodeable/src/types/VegaLite.ts @@ -2,5 +2,5 @@ export { ValueDef, Value } from 'vega-lite/build/src/channeldef'; export { DateTime } from 'vega-lite/build/src/datetime'; -export { SchemeParams, ScaleType } from 'vega-lite/build/src/scale'; +export { SchemeParams, ScaleType, Scale, NiceTime } from 'vega-lite/build/src/scale'; export { Type } from 'vega-lite/build/src/type'; diff --git a/packages/superset-ui-encodeable/src/utils/inferElementTypeFromUnionOfArrayTypes.ts b/packages/superset-ui-encodeable/src/utils/inferElementTypeFromUnionOfArrayTypes.ts new file mode 100644 index 0000000000..8e8600d4d6 --- /dev/null +++ b/packages/superset-ui-encodeable/src/utils/inferElementTypeFromUnionOfArrayTypes.ts @@ -0,0 +1,10 @@ +type ArrayElement = A extends Array ? Elem : never; + +/** + * Type workaround for https://github.com/Microsoft/TypeScript/issues/7294#issuecomment-465794460 + * to avoid error "Cannot invoke an expression whose type lacks a call signature" + * when using array.map + */ +export default function inferElementTypeFromUnionOfArrayTypes(array: T): ArrayElement[] { + return array as any; +} diff --git a/packages/superset-ui-encodeable/test/parsers/parseDateTime.test.ts b/packages/superset-ui-encodeable/test/parsers/parseDateTime.test.ts new file mode 100644 index 0000000000..2ef894970c --- /dev/null +++ b/packages/superset-ui-encodeable/test/parsers/parseDateTime.test.ts @@ -0,0 +1,29 @@ +import parseDateTime from '../../src/parsers/parseDateTime'; + +describe('parseDateTime(dateTime)', () => { + it('parses number', () => { + expect(parseDateTime(1560384000000)).toEqual(new Date(Date.UTC(2019, 5, 13))); + }); + it('parses string', () => { + expect(parseDateTime('2019-01-01')).toEqual(new Date('2019-01-01')); + }); + it('parse DateTime object', () => { + expect( + parseDateTime({ + year: 2019, + month: 6, + date: 14, + }), + ).toEqual(new Date(2019, 5, 14)); + }); + it('handles utc correctly', () => { + expect( + parseDateTime({ + year: 2019, + month: 6, + date: 14, + utc: true, + }), + ).toEqual(new Date(Date.UTC(2019, 5, 14))); + }); +}); diff --git a/packages/superset-ui-encodeable/test/parsers/scale/createScaleFromScaleConfig.test.ts b/packages/superset-ui-encodeable/test/parsers/scale/createScaleFromScaleConfig.test.ts new file mode 100644 index 0000000000..c4692c4747 --- /dev/null +++ b/packages/superset-ui-encodeable/test/parsers/scale/createScaleFromScaleConfig.test.ts @@ -0,0 +1,624 @@ +import { + getSequentialSchemeRegistry, + SequentialScheme, + getCategoricalSchemeRegistry, + CategoricalScheme, +} from '@superset-ui/color'; +import { ScaleLinear, ScaleTime } from 'd3-scale'; +import createScaleFromScaleConfig from '../../../src/parsers/scale/createScaleFromScaleConfig'; + +describe('createScaleFromScaleConfig(config)', () => { + describe('default', () => { + it('returns linear scale', () => { + // @ts-ignore + const scale = createScaleFromScaleConfig({}); + expect(scale(1)).toEqual(1); + }); + }); + + describe('linear scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + range: [0, 100], + }); + expect(scale(10)).toEqual(100); + }); + it('with reverse domain', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + range: [0, 100], + reverse: true, + }); + expect(scale(10)).toEqual(0); + }); + it('with color scheme as range', () => { + getSequentialSchemeRegistry().registerValue( + 'test-scheme', + new SequentialScheme({ + id: 'test-scheme', + colors: ['#ff0000', '#ffff00'], + }), + ); + + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + scheme: 'test-scheme', + }); + + expect(scale(0)).toEqual('rgb(255, 0, 0)'); + expect(scale(10)).toEqual('rgb(255, 255, 0)'); + + getSequentialSchemeRegistry().remove('test-scheme'); + }); + it('with color scheme as range, but no color scheme available', () => { + getSequentialSchemeRegistry().clearDefaultKey(); + + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + scheme: 'test-scheme', + }); + + expect(scale(0)).toEqual(0); + expect(scale(10)).toEqual(1); + }); + it('with nice', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 9.9], + range: [0, 100], + nice: true, + }); + expect(scale(10)).toEqual(100); + }); + it('with nice=false', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 9.9], + range: [0, 100], + nice: false, + }); + expect(Number(scale(10)).toFixed(2)).toEqual('101.01'); + }); + it('with nice is number', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 9.9], + range: [0, 100], + nice: 3, + }); + expect((scale as ScaleLinear).ticks(3)).toEqual([0, 5, 10]); + }); + it('with clamp', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + range: [0, 100], + clamp: true, + }); + expect(scale(-10000)).toEqual(0); + expect(scale(10000)).toEqual(100); + }); + it('with round', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [0, 10], + range: [0, 10], + round: true, + }); + expect(scale(9.9)).toEqual(10); + }); + it('with zero', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [2, 10], + range: [0, 10], + zero: true, + }); + expect(scale(5)).toEqual(5); + }); + it('with zero (negative domain)', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [-10, -2], + range: [0, 10], + zero: true, + }); + expect(scale(-5)).toEqual(5); + }); + it('with zero (no effect)', () => { + const scale = createScaleFromScaleConfig({ + type: 'linear', + domain: [-5, 5], + range: [0, 10], + zero: true, + }); + expect(scale(0)).toEqual(5); + }); + it('with interpolate', () => { + expect(() => + createScaleFromScaleConfig({ + type: 'linear', + interpolate: 'cubehelix', + }), + ).toThrowError('"scale.interpolate" is not supported yet.'); + }); + }); + + describe('log scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'log', + domain: [1, 100], + range: [1, 10], + }); + expect(scale(10)).toEqual(5.5); + expect(scale(100)).toEqual(10); + }); + it('with base', () => { + const scale = createScaleFromScaleConfig({ + type: 'log', + domain: [1, 16], + base: 2, + }); + expect(scale(8)).toEqual(0.75); + }); + }); + + describe('power scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'pow', + domain: [0, 100], + }); + expect(scale(10)).toEqual(0.1); + expect(scale(100)).toEqual(1); + }); + it('with exponent', () => { + const scale = createScaleFromScaleConfig({ + type: 'pow', + exponent: 2, + }); + expect(scale(3)).toEqual(9); + expect(scale(4)).toEqual(16); + }); + }); + + describe('sqrt scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'sqrt', + }); + expect(scale(4)).toEqual(2); + expect(scale(9)).toEqual(3); + }); + }); + + describe('symlog scale', () => { + it('is not supported yet', () => { + expect(() => createScaleFromScaleConfig({ type: 'symlog' })).toThrowError( + '"scale.type = symlog" is not supported yet.', + ); + }); + }); + + describe('time scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'time', + domain: [ + { + year: 2019, + month: 7, + date: 1, + }, + { + year: 2019, + month: 7, + date: 31, + }, + ], + range: [0, 100], + }); + expect(scale(new Date(2019, 6, 1))).toEqual(0); + expect(scale(new Date(2019, 6, 16))).toEqual(50); + expect(scale(new Date(2019, 6, 31))).toEqual(100); + }); + it('with nice is string', () => { + const scale = createScaleFromScaleConfig({ + type: 'time', + domain: [ + { + year: 2019, + month: 7, + date: 5, + }, + { + year: 2019, + month: 7, + date: 30, + }, + ], + range: [0, 100], + nice: 'month', + }); + expect((scale as ScaleTime).domain()).toEqual([ + new Date(2019, 6, 1), + new Date(2019, 7, 1), + ]); + }); + it('with nice is interval object', () => { + const scale = createScaleFromScaleConfig({ + type: 'time', + domain: [ + { + year: 2019, + month: 7, + date: 5, + }, + { + year: 2019, + month: 7, + date: 30, + }, + ], + range: [0, 100], + nice: { interval: 'month', step: 2 }, + }); + expect((scale as ScaleTime).domain()).toEqual([ + new Date(2019, 6, 1), + new Date(2019, 8, 1), + ]); + }); + }); + + describe('UTC scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'utc', + domain: [ + { + year: 2019, + month: 7, + date: 1, + utc: true, + }, + { + year: 2019, + month: 7, + date: 31, + utc: true, + }, + ], + range: [0, 100], + }); + expect(scale(new Date(Date.UTC(2019, 6, 1)))).toEqual(0); + expect(scale(new Date(Date.UTC(2019, 6, 16)))).toEqual(50); + expect(scale(new Date(Date.UTC(2019, 6, 31)))).toEqual(100); + }); + it('with nice is string', () => { + const scale = createScaleFromScaleConfig({ + type: 'utc', + domain: [ + { + year: 2019, + month: 7, + date: 5, + utc: true, + }, + { + year: 2019, + month: 7, + date: 30, + utc: true, + }, + ], + range: [0, 100], + nice: 'month', + }); + expect((scale as ScaleTime).domain()).toEqual([ + new Date(Date.UTC(2019, 6, 1)), + new Date(Date.UTC(2019, 7, 1)), + ]); + }); + it('with nice is interval object', () => { + const scale = createScaleFromScaleConfig({ + type: 'utc', + domain: [ + { + year: 2019, + month: 7, + date: 5, + utc: true, + }, + { + year: 2019, + month: 7, + date: 30, + utc: true, + }, + ], + range: [0, 100], + nice: { interval: 'month', step: 2 }, + }); + expect((scale as ScaleTime).domain()).toEqual([ + new Date(Date.UTC(2019, 6, 1)), + new Date(Date.UTC(2019, 8, 1)), + ]); + }); + it('with nice is interval object that has invalid step', () => { + const scale = createScaleFromScaleConfig({ + type: 'utc', + domain: [ + { + year: 2019, + month: 7, + date: 5, + utc: true, + }, + { + year: 2019, + month: 7, + date: 30, + utc: true, + }, + ], + range: [0, 100], + nice: { interval: 'month', step: 0.5 }, + }); + expect((scale as ScaleTime).domain()).toEqual([ + new Date(Date.UTC(2019, 6, 5)), + new Date(Date.UTC(2019, 6, 30)), + ]); + }); + }); + + describe('quantile scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'quantile', + domain: [0, 100], + range: [0, 1, 2, 3], + }); + expect(scale(0)).toEqual(0); + expect(scale(10)).toEqual(0); + expect(scale(25)).toEqual(1); + expect(scale(50)).toEqual(2); + expect(scale(75)).toEqual(3); + expect(scale(100)).toEqual(3); + }); + }); + + describe('quantize scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'quantize', + domain: [10, 100], + range: [1, 2, 4], + }); + expect(scale(20)).toEqual(1); + expect(scale(50)).toEqual(2); + expect(scale(80)).toEqual(4); + }); + it('with string range', () => { + const scale = createScaleFromScaleConfig({ + type: 'quantize', + domain: [0, 1], + range: ['calm-brown', 'shocking-pink'], + }); + expect(scale(0.49)).toEqual('calm-brown'); + expect(scale(0.51)).toEqual('shocking-pink'); + }); + }); + + describe('threshold scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'threshold', + domain: [0, 1], + range: ['red', 'white', 'green'], + }); + expect(scale(-1)).toEqual('red'); + expect(scale(0)).toEqual('white'); + expect(scale(0.5)).toEqual('white'); + expect(scale(1)).toEqual('green'); + expect(scale(1000)).toEqual('green'); + }); + }); + + describe('ordinal scale', () => { + beforeEach(() => { + getCategoricalSchemeRegistry() + .registerValue( + 'test-scheme', + new CategoricalScheme({ + id: 'test-scheme', + colors: ['red', 'white', 'green'], + }), + ) + .registerValue( + 'test-scheme2', + new CategoricalScheme({ + id: 'test-scheme', + colors: ['pink', 'charcoal', 'orange'], + }), + ) + .setDefaultKey('test-scheme'); + }); + + afterEach(() => { + getCategoricalSchemeRegistry() + .remove('test-scheme') + .remove('test-scheme2') + .clearDefaultKey(); + }); + + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'ordinal', + }); + expect(scale('fish')).toEqual('red'); + expect(scale('dinosaur')).toEqual('white'); + expect(scale('whale')).toEqual('green'); + }); + it('with range', () => { + const scale = createScaleFromScaleConfig({ + type: 'ordinal', + domain: ['fish', 'dinosaur'], + range: ['red', 'white', 'green'], + }); + expect(scale('fish')).toEqual('red'); + expect(scale('dinosaur')).toEqual('white'); + expect(scale('whale')).toEqual('green'); + }); + it('with color scheme', () => { + const scale = createScaleFromScaleConfig({ + type: 'ordinal', + scheme: 'test-scheme', + }); + expect(scale('fish')).toEqual('red'); + expect(scale('dinosaur')).toEqual('white'); + expect(scale('whale')).toEqual('green'); + }); + it('with color scheme and domain', () => { + const scale = createScaleFromScaleConfig({ + type: 'ordinal', + domain: ['fish', 'dinosaur'], + scheme: 'test-scheme2', + }); + expect(scale('fish')).toEqual('pink'); + expect(scale('dinosaur')).toEqual('charcoal'); + expect(scale('whale')).toEqual('pink'); + }); + it('with color scheme and reversed domain', () => { + const scale = createScaleFromScaleConfig({ + type: 'ordinal', + domain: ['pig', 'panda'], + reverse: true, + scheme: 'test-scheme2', + }); + expect(scale('pig')).toEqual('charcoal'); + expect(scale('panda')).toEqual('pink'); + }); + it('with namespace', () => { + const scale1 = createScaleFromScaleConfig({ + type: 'ordinal', + namespace: 'abc', + }); + const scale2 = createScaleFromScaleConfig({ + type: 'ordinal', + namespace: 'def', + }); + const scale3 = createScaleFromScaleConfig({ + type: 'ordinal', + namespace: 'def', + }); + + expect(scale1('fish')).toEqual('red'); + expect(scale1('dinosaur')).toEqual('white'); + expect(scale1('whale')).toEqual('green'); + + expect(scale2('whale')).toEqual('red'); + expect(scale2('dinosaur')).toEqual('white'); + expect(scale2('fish')).toEqual('green'); + + expect(scale3('fish')).toEqual('green'); + expect(scale3('dinosaur')).toEqual('white'); + expect(scale3('whale')).toEqual('red'); + }); + }); + + describe('bin-ordinal scale', () => { + it('is not supported yet', () => { + expect(() => createScaleFromScaleConfig({ type: 'bin-ordinal' })).toThrowError( + '"scale.type = bin-ordinal" is not supported yet.', + ); + }); + }); + + describe('point scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'point', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + }); + expect(scale('fish')).toEqual(0); + expect(scale('dinosaur')).toEqual(50); + expect(scale('whale')).toEqual(100); + }); + it('with padding', () => { + const scale = createScaleFromScaleConfig({ + type: 'point', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + padding: 1, + }); + expect(scale('fish')).toEqual(25); + expect(scale('dinosaur')).toEqual(50); + expect(scale('whale')).toEqual(75); + }); + it('with round', () => { + const scale = createScaleFromScaleConfig({ + type: 'point', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + padding: 0.5, + round: true, + }); + expect(scale('fish')).toEqual(17); + expect(scale('dinosaur')).toEqual(50); + expect(scale('whale')).toEqual(83); + }); + }); + + describe('band scale', () => { + it('basic', () => { + const scale = createScaleFromScaleConfig({ + type: 'band', + domain: ['fish', 'dinosaur'], + range: [0, 100], + }); + expect(scale('fish')).toEqual(0); + expect(scale('dinosaur')).toEqual(50); + }); + it('with paddingInner', () => { + const scale = createScaleFromScaleConfig({ + type: 'band', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + paddingInner: 0.5, + }); + expect(scale('fish')).toEqual(0); + expect(scale('dinosaur')).toEqual(40); + expect(scale('whale')).toEqual(80); + }); + it('with paddingOuter', () => { + const scale = createScaleFromScaleConfig({ + type: 'band', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + paddingOuter: 0.5, + }); + expect(scale('fish')).toEqual(12.5); + expect(scale('dinosaur')).toEqual(37.5); + expect(scale('whale')).toEqual(62.5); + }); + it('with align', () => { + const scale = createScaleFromScaleConfig({ + type: 'band', + domain: ['fish', 'dinosaur', 'whale'], + range: [0, 100], + align: 0, + paddingOuter: 0.5, + }); + expect(scale('fish')).toEqual(0); + expect(scale('dinosaur')).toEqual(25); + expect(scale('whale')).toEqual(50); + }); + }); +}); diff --git a/packages/superset-ui-encodeable/test/parsers/scale/getScaleCategoryFromScaleType.test.ts b/packages/superset-ui-encodeable/test/parsers/scale/getScaleCategoryFromScaleType.test.ts new file mode 100644 index 0000000000..4363585f81 --- /dev/null +++ b/packages/superset-ui-encodeable/test/parsers/scale/getScaleCategoryFromScaleType.test.ts @@ -0,0 +1,27 @@ +import getScaleCategoryFromScaleType from '../../../src/parsers/scale/getScaleCategoryFromScaleType'; + +describe('getScaleCategoryFromScaleType(scaleType)', () => { + it('handles continuous types', () => { + expect(getScaleCategoryFromScaleType('linear')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('pow')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('sqrt')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('symlog')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('log')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('time')).toEqual('continuous'); + expect(getScaleCategoryFromScaleType('utc')).toEqual('continuous'); + }); + it('handles discrete types', () => { + expect(getScaleCategoryFromScaleType('band')).toEqual('discrete'); + expect(getScaleCategoryFromScaleType('point')).toEqual('discrete'); + expect(getScaleCategoryFromScaleType('ordinal')).toEqual('discrete'); + }); + it('handles discretizing types', () => { + expect(getScaleCategoryFromScaleType('bin-ordinal')).toEqual('discretizing'); + expect(getScaleCategoryFromScaleType('quantile')).toEqual('discretizing'); + expect(getScaleCategoryFromScaleType('threshold')).toEqual('discretizing'); + }); + it('handles unknown types', () => { + // @ts-ignore + expect(getScaleCategoryFromScaleType('surprise!')).toBeUndefined(); + }); +}); diff --git a/packages/superset-ui-encodeable/test/parsers/scale/inferScaleType.test.ts b/packages/superset-ui-encodeable/test/parsers/scale/inferScaleType.test.ts new file mode 100644 index 0000000000..8e6881d352 --- /dev/null +++ b/packages/superset-ui-encodeable/test/parsers/scale/inferScaleType.test.ts @@ -0,0 +1,58 @@ +import inferScaleType from '../../../src/parsers/scale/inferScaleType'; + +describe('inferScaleType(channelType, fieldType, isBinned)', () => { + describe('for nominal and ordinal fields', () => { + it('returns band when it should', () => { + expect(inferScaleType('XBand', 'nominal')).toEqual('band'); + expect(inferScaleType('YBand', 'ordinal')).toEqual('band'); + }); + it('returns point when it should', () => { + expect(inferScaleType('X', 'nominal')).toEqual('point'); + expect(inferScaleType('Y', 'ordinal')).toEqual('point'); + }); + it('returns ordinal when it should', () => { + expect(inferScaleType('Color', 'nominal')).toEqual('ordinal'); + expect(inferScaleType('Category', 'ordinal')).toEqual('ordinal'); + }); + }); + describe('for quantitative fields', () => { + it('returns linear in general', () => { + expect(inferScaleType('XBand', 'quantitative')).toEqual('linear'); + expect(inferScaleType('YBand', 'quantitative')).toEqual('linear'); + expect(inferScaleType('X', 'quantitative')).toEqual('linear'); + expect(inferScaleType('Y', 'quantitative')).toEqual('linear'); + expect(inferScaleType('Numeric', 'quantitative')).toEqual('linear'); + }); + it('return bin-ordinal for binned color', () => { + expect(inferScaleType('Color', 'quantitative', true)).toEqual('bin-ordinal'); + }); + it('return linear for color', () => { + expect(inferScaleType('Color', 'quantitative')).toEqual('linear'); + }); + }); + describe('for temporal fields', () => { + it('returns UTC time scale in general', () => { + expect(inferScaleType('XBand', 'temporal')).toEqual('utc'); + expect(inferScaleType('YBand', 'temporal')).toEqual('utc'); + expect(inferScaleType('X', 'temporal')).toEqual('utc'); + expect(inferScaleType('Y', 'temporal')).toEqual('utc'); + expect(inferScaleType('Numeric', 'temporal')).toEqual('utc'); + }); + it('returns linear for color', () => { + expect(inferScaleType('Color', 'temporal')).toEqual('linear'); + }); + }); + describe('for other channel types', () => { + it('returns undefined', () => { + expect(inferScaleType('Text', 'quantitative')).toBeUndefined(); + expect(inferScaleType('Text', 'nominal')).toBeUndefined(); + expect(inferScaleType('Text', 'ordinal')).toBeUndefined(); + expect(inferScaleType('Text', 'temporal')).toBeUndefined(); + }); + }); + describe('for undefined fieldType', () => { + it('returns undefined', () => { + expect(inferScaleType('X')).toBeUndefined(); + }); + }); +}); diff --git a/packages/superset-ui-encodeable/test/parsers/scale/isPropertySupportedByScaleType.test.ts b/packages/superset-ui-encodeable/test/parsers/scale/isPropertySupportedByScaleType.test.ts new file mode 100644 index 0000000000..c4fc2bcc21 --- /dev/null +++ b/packages/superset-ui-encodeable/test/parsers/scale/isPropertySupportedByScaleType.test.ts @@ -0,0 +1,10 @@ +import isPropertySupportedByScaleType from '../../../src/parsers/scale/isPropertySupportedByScaleType'; + +describe('isPropertySupportedByScaleType(property, scaleType)', () => { + it('returns true for compatible pairs', () => { + expect(isPropertySupportedByScaleType('scheme', 'ordinal')).toBeTruthy(); + }); + it('returns false otherwise', () => { + expect(isPropertySupportedByScaleType('zero', 'log')).toBeFalsy(); + }); +}); diff --git a/packages/superset-ui-encodeable/test/typeGuards/Scale.test.ts b/packages/superset-ui-encodeable/test/typeGuards/Scale.test.ts new file mode 100644 index 0000000000..919b76bdfe --- /dev/null +++ b/packages/superset-ui-encodeable/test/typeGuards/Scale.test.ts @@ -0,0 +1,34 @@ +import { CategoricalColorScale } from '@superset-ui/color'; +import { scaleLinear, scaleOrdinal, scaleTime, scaleLog } from 'd3-scale'; +import { isD3Scale, isCategoricalColorScale, isTimeScale } from '../../src/typeGuards/Scale'; +import { HasToString } from '../../src/types/Base'; + +describe('type guards', () => { + describe('isD3Scale(scale)', () => { + it('returns true if it is one of D3 scales', () => { + expect(isD3Scale(scaleLinear())).toBeTruthy(); + expect(isD3Scale(scaleOrdinal())).toBeTruthy(); + }); + it('returns false otherwise', () => { + expect(isD3Scale(new CategoricalColorScale(['red', 'yellow']))).toBeFalsy(); + }); + }); + describe('isCategoricalColorScale(scale)', () => { + it('returns true if it is CategoricalColorScale', () => { + expect(isCategoricalColorScale(new CategoricalColorScale(['red', 'yellow']))).toBeTruthy(); + }); + it('returns false otherwise', () => { + expect(isCategoricalColorScale(scaleLinear())).toBeFalsy(); + }); + }); + describe('isTimeScale(scale, type)', () => { + it('returns true if type is one of the time scale types', () => { + expect(isTimeScale(scaleTime(), 'time')).toBeTruthy(); + expect(isTimeScale(scaleTime(), 'utc')).toBeTruthy(); + }); + it('returns false otherwise', () => { + expect(isTimeScale(scaleLinear(), 'linear')).toBeFalsy(); + expect(isTimeScale(scaleLog(), 'log')).toBeFalsy(); + }); + }); +});