From 5995ece05e1dd82a889414a23a2eb34827cf062a Mon Sep 17 00:00:00 2001 From: Lukas Hermann Date: Wed, 3 Aug 2022 23:01:30 -0700 Subject: [PATCH] feat: Add `timeFormatType` field (#8320) Co-authored-by: GitHub Actions Bot --- build/vega-lite-schema.json | 4 + site/docs/config.md | 4 +- src/compile/axis/encode.ts | 60 +++++++----- src/compile/format.ts | 145 +++++++++++++++------------ src/compile/legend/encode.ts | 37 ++++--- src/config.ts | 9 ++ test/compile/axis/encode.test.ts | 24 +++++ test/compile/format.test.ts | 152 ++++++++++++++++++++++------- test/compile/legend/encode.test.ts | 39 ++++++++ 9 files changed, 339 insertions(+), 135 deletions(-) diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 3b4cfcbb47..56bd01bbbc 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -7725,6 +7725,10 @@ "description": "Default time format for raw time values (without time units) in text marks, legend labels and header labels.\n\n__Default value:__ `\"%b %d, %Y\"` __Note:__ Axes automatically determine the format for each label automatically so this config does not affect axes.", "type": "string" }, + "timeFormatType": { + "description": "[Custom format type](https://vega.github.io/vega-lite/docs/config.html#custom-format-type) for `config.timeFormat`.\n\n__Default value:__ `undefined` -- This is equilvalent to call D3-time-format, which is exposed as [`timeFormat` in Vega-Expression](https://vega.github.io/vega/docs/expressions/#timeFormat). __Note:__ You must also set `customFormatTypes` to `true` and there must *not* be a `timeUnit` defined to use this feature.", + "type": "string" + }, "title": { "$ref": "#/definitions/TitleConfig", "description": "Title configuration, which determines default properties for all [titles](https://vega.github.io/vega-lite/docs/title.html). For a full list of title configuration options, please see the [corresponding section of the title documentation](https://vega.github.io/vega-lite/docs/title.html#config)." diff --git a/site/docs/config.md b/site/docs/config.md index c1449f22c4..809f275d98 100644 --- a/site/docs/config.md +++ b/site/docs/config.md @@ -50,9 +50,9 @@ A Vega-Lite `config` object can have the following top-level properties: ## Format Configuration -These two config properties define the default number and time formats for text marks as well as axes, headers, and legends: +These config properties define the default number and time formats for text marks as well as axes, headers, and legends: -{% include table.html props="numberFormat,numberFormatType,timeFormat,customFormatTypes" source="Config" %} +{% include table.html props="numberFormat,numberFormatType,normalizedNumberFormat,normalizedNumberFormatType,timeFormat,timeFormatType,customFormatTypes" source="Config" %} {:#custom-format-type} diff --git a/src/compile/axis/encode.ts b/src/compile/axis/encode.ts index b9a49b46c9..4a42815813 100644 --- a/src/compile/axis/encode.ts +++ b/src/compile/axis/encode.ts @@ -1,5 +1,5 @@ import {getSecondaryRangeChannel, PositionScaleChannel} from '../../channel'; -import {channelDefType, getFieldOrDatumDef, isPositionFieldOrDatumDef} from '../../channeldef'; +import {channelDefType, getFieldOrDatumDef, isFieldDef, isPositionFieldOrDatumDef} from '../../channeldef'; import {formatCustomType, isCustomFormatType} from '../format'; import {UnitModel} from '../unit'; @@ -22,34 +22,48 @@ export function labels(model: UnitModel, channel: PositionScaleChannel, specifie }), ...specifiedLabelsSpec }; - } else if ( - format === undefined && - formatType === undefined && - channelDefType(fieldOrDatumDef) === 'quantitative' && - config.customFormatTypes - ) { + } else if (format === undefined && formatType === undefined && config.customFormatTypes) { + if (channelDefType(fieldOrDatumDef) === 'quantitative') { + if ( + isPositionFieldOrDatumDef(fieldOrDatumDef) && + fieldOrDatumDef.stack === 'normalize' && + config.normalizedNumberFormatType + ) { + return { + text: formatCustomType({ + fieldOrDatumDef, + field: 'datum.value', + format: config.normalizedNumberFormat, + formatType: config.normalizedNumberFormatType, + config + }), + ...specifiedLabelsSpec + }; + } else if (config.numberFormatType) { + return { + text: formatCustomType({ + fieldOrDatumDef, + field: 'datum.value', + format: config.numberFormat, + formatType: config.numberFormatType, + config + }), + ...specifiedLabelsSpec + }; + } + } if ( - isPositionFieldOrDatumDef(fieldOrDatumDef) && - fieldOrDatumDef.stack === 'normalize' && - config.normalizedNumberFormatType + channelDefType(fieldOrDatumDef) === 'temporal' && + config.timeFormatType && + isFieldDef(fieldOrDatumDef) && + !fieldOrDatumDef.timeUnit ) { return { text: formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.normalizedNumberFormat, - formatType: config.normalizedNumberFormatType, - config - }), - ...specifiedLabelsSpec - }; - } else if (config.numberFormatType) { - return { - text: formatCustomType({ - fieldOrDatumDef, - field: 'datum.value', - format: config.numberFormat, - formatType: config.numberFormatType, + format: config.timeFormat, + formatType: config.timeFormatType, config }), ...specifiedLabelsSpec diff --git a/src/compile/format.ts b/src/compile/format.ts index ade16dbdf2..aa5d76b1e6 100644 --- a/src/compile/format.ts +++ b/src/compile/format.ts @@ -59,47 +59,51 @@ export function formatSignalRef({ const field = fieldToFormat(fieldOrDatumDef, expr, normalizeStack); const type = channelDefType(fieldOrDatumDef); - if ( - normalizeStack && - type === 'quantitative' && - format === undefined && - formatType === undefined && - config.customFormatTypes && - config.normalizedNumberFormatType - ) { - return formatCustomType({ - fieldOrDatumDef, - format: config.normalizedNumberFormat, - formatType: config.normalizedNumberFormatType, - expr, - config - }); - } - - if ( - type === 'quantitative' && - format === undefined && - formatType === undefined && - config.customFormatTypes && - config.numberFormatType - ) { - return formatCustomType({ - fieldOrDatumDef, - format: config.numberFormat, - formatType: config.numberFormatType, - expr, - config - }); + if (format === undefined && formatType === undefined && config.customFormatTypes) { + if (type === 'quantitative') { + if (normalizeStack && config.normalizedNumberFormatType) + return formatCustomType({ + fieldOrDatumDef, + format: config.normalizedNumberFormat, + formatType: config.normalizedNumberFormatType, + expr, + config + }); + if (config.numberFormatType) { + return formatCustomType({ + fieldOrDatumDef, + format: config.numberFormat, + formatType: config.numberFormatType, + expr, + config + }); + } + } + if ( + type === 'temporal' && + config.timeFormatType && + isFieldDef(fieldOrDatumDef) && + fieldOrDatumDef.timeUnit === undefined + ) { + return formatCustomType({ + fieldOrDatumDef, + format: config.timeFormat, + formatType: config.timeFormatType, + expr, + config + }); + } } if (isFieldOrDatumDefForTimeFormat(fieldOrDatumDef)) { - const signal = timeFormatExpression( + const signal = timeFormatExpression({ field, - isFieldDef(fieldOrDatumDef) ? normalizeTimeUnit(fieldOrDatumDef.timeUnit)?.unit : undefined, + timeUnit: isFieldDef(fieldOrDatumDef) ? normalizeTimeUnit(fieldOrDatumDef.timeUnit)?.unit : undefined, format, - config.timeFormat, - isScaleFieldDef(fieldOrDatumDef) && fieldOrDatumDef.scale?.type === ScaleType.UTC - ); + formatType: config.timeFormatType, + rawTimeFormat: config.timeFormat, + isUTCScale: isScaleFieldDef(fieldOrDatumDef) && fieldOrDatumDef.scale?.type === ScaleType.UTC + }); return signal ? {signal} : undefined; } @@ -179,21 +183,18 @@ export function guideFormat( ) { if (isCustomFormatType(formatType)) { return undefined; // handled in encode block - } else if ( - format === undefined && - formatType === undefined && - config.customFormatTypes && - channelDefType(fieldOrDatumDef) === 'quantitative' - ) { - if ( - config.normalizedNumberFormatType && - isPositionFieldOrDatumDef(fieldOrDatumDef) && - fieldOrDatumDef.stack === 'normalize' - ) { - return undefined; // handled in encode block - } - if (config.numberFormatType) { - return undefined; // handled in encode block + } else if (format === undefined && formatType === undefined && config.customFormatTypes) { + if (channelDefType(fieldOrDatumDef) === 'quantitative') { + if ( + config.normalizedNumberFormatType && + isPositionFieldOrDatumDef(fieldOrDatumDef) && + fieldOrDatumDef.stack === 'normalize' + ) { + return undefined; // handled in encode block + } + if (config.numberFormatType) { + return undefined; // handled in encode block + } } } @@ -211,8 +212,11 @@ export function guideFormat( if (isFieldOrDatumDefForTimeFormat(fieldOrDatumDef)) { const timeUnit = isFieldDef(fieldOrDatumDef) ? normalizeTimeUnit(fieldOrDatumDef.timeUnit)?.unit : undefined; + if (timeUnit === undefined && config.customFormatTypes && config.timeFormatType) { + return undefined; // hanlded in encode block + } - return timeFormat(format as string, timeUnit, config, omitTimeFormatConfig); + return timeFormat({specifiedFormat: format as string, timeUnit, config, omitTimeFormatConfig}); } return numberFormat({type, specifiedFormat: format, config}); @@ -261,7 +265,17 @@ export function numberFormat({ /** * Returns time format for a fieldDef for use in guides. */ -export function timeFormat(specifiedFormat: string, timeUnit: TimeUnit, config: Config, omitTimeFormatConfig: boolean) { +export function timeFormat({ + specifiedFormat, + timeUnit, + config, + omitTimeFormatConfig +}: { + specifiedFormat?: string; + timeUnit?: TimeUnit; + config: Config; + omitTimeFormatConfig?: boolean; +}) { if (specifiedFormat) { return specifiedFormat; } @@ -305,15 +319,26 @@ export function binFormatExpression( /** * Returns the time expression used for axis/legend labels or text mark for a temporal field */ -export function timeFormatExpression( - field: string, - timeUnit: TimeUnit, - format: string | Dict, - rawTimeFormat: string, // should be provided only for actual text and headers, not axis/legend labels - isUTCScale: boolean -): string { +export function timeFormatExpression({ + field, + timeUnit, + format, + formatType, + rawTimeFormat, + isUTCScale +}: { + field: string; + timeUnit?: TimeUnit; + format?: string | Dict; + formatType?: string; + rawTimeFormat?: string; // should be provided only for actual text and headers, not axis/legend labels + isUTCScale?: boolean; +}): string { if (!timeUnit || format) { // If there is no time unit, or if user explicitly specifies format for axis/legend/text. + if (!timeUnit && formatType) { + return `${formatType}(${field}, '${format}')`; + } format = isString(format) ? format : rawTimeFormat; // only use provided timeFormat if there is no timeUnit. return `${isUTCScale ? 'utc' : 'time'}Format(${field}, '${format}')`; } else { diff --git a/src/compile/legend/encode.ts b/src/compile/legend/encode.ts index 91a60ac9db..334e0b0381 100644 --- a/src/compile/legend/encode.ts +++ b/src/compile/legend/encode.ts @@ -160,20 +160,29 @@ export function labels(specifiedlabelsSpec: any, {fieldOrDatumDef, model, channe formatType, config }); - } else if ( - fieldOrDatumDef.type === 'quantitative' && - format === undefined && - formatType === undefined && - config.customFormatTypes && - config.numberFormatType - ) { - text = formatCustomType({ - fieldOrDatumDef, - field: 'datum.value', - format: config.numberFormat, - formatType: config.numberFormatType, - config - }); + } else if (format === undefined && formatType === undefined && config.customFormatTypes) { + if (fieldOrDatumDef.type === 'quantitative' && config.numberFormatType) { + text = formatCustomType({ + fieldOrDatumDef, + field: 'datum.value', + format: config.numberFormat, + formatType: config.numberFormatType, + config + }); + } else if ( + fieldOrDatumDef.type === 'temporal' && + config.timeFormatType && + isFieldDef(fieldOrDatumDef) && + fieldOrDatumDef.timeUnit === undefined + ) { + text = formatCustomType({ + fieldOrDatumDef, + field: 'datum.value', + format: config.timeFormat, + formatType: config.timeFormatType, + config + }); + } } const labelsSpec = { diff --git a/src/config.ts b/src/config.ts index 4c20c662f1..4c1a905e3c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -189,6 +189,15 @@ export interface VLOnlyConfig { */ timeFormat?: string; + /** + * [Custom format type](https://vega.github.io/vega-lite/docs/config.html#custom-format-type) + * for `config.timeFormat`. + * + * __Default value:__ `undefined` -- This is equilvalent to call D3-time-format, which is exposed as [`timeFormat` in Vega-Expression](https://vega.github.io/vega/docs/expressions/#timeFormat). + * __Note:__ You must also set `customFormatTypes` to `true` and there must *not* be a `timeUnit` defined to use this feature. + */ + timeFormatType?: string; + /** * Allow the `formatType` property for text marks and guides to accept a custom formatter function [registered as a Vega expression](https://vega.github.io/vega-lite/usage/compile.html#format-type). */ diff --git a/test/compile/axis/encode.test.ts b/test/compile/axis/encode.test.ts index 37120dccbc..8b64e8b900 100644 --- a/test/compile/axis/encode.test.ts +++ b/test/compile/axis/encode.test.ts @@ -77,5 +77,29 @@ describe('compile/axis/encode', () => { const labels = encode.labels(model, 'x', {}); expect(labels.text.signal).toBe('customNumberFormat(datum.value, "abc")'); }); + + it('applies custom timeFormatType from config', () => { + const model = parseUnitModelWithScale({ + mark: 'point', + encoding: { + x: {field: 'a', type: 'temporal'} + }, + config: {customFormatTypes: true, timeFormat: 'abc', timeFormatType: 'customTimeFormat'} + }); + const labels = encode.labels(model, 'x', {}); + expect(labels.text.signal).toBe('customTimeFormat(datum.value, "abc")'); + }); + + it('prefers timeUnit over timeFormatType from config', () => { + const model = parseUnitModelWithScale({ + mark: 'point', + encoding: { + x: {field: 'a', type: 'temporal', timeUnit: 'date'} + }, + config: {customFormatTypes: true, timeFormat: 'abc', timeFormatType: 'customTimeFormat'} + }); + const labels = encode.labels(model, 'x', {}); + expect(labels).toEqual({}); + }); }); }); diff --git a/test/compile/format.test.ts b/test/compile/format.test.ts index 95deafbd96..ada7be09f4 100644 --- a/test/compile/format.test.ts +++ b/test/compile/format.test.ts @@ -4,7 +4,8 @@ import { guideFormat, guideFormatType, numberFormat, - timeFormatExpression + timeFormatExpression, + timeFormat } from '../../src/compile/format'; import {defaultConfig} from '../../src/config'; import {NOMINAL, ORDINAL, QUANTITATIVE, TEMPORAL} from '../../src/type'; @@ -13,13 +14,11 @@ describe('Format', () => { describe('timeFormatExpression()', () => { it('should get the right time expression for month', () => { const fieldDef = {timeUnit: 'month', field: 'a', type: TEMPORAL} as const; - const expression = timeFormatExpression( - vgField(fieldDef, {expr: 'datum'}), - 'month', - undefined, - defaultConfig.timeFormat, - false - ); + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + timeUnit: 'month', + rawTimeFormat: defaultConfig.timeFormat + }); expect(expression).toBe( 'timeFormat(datum["month_a"], timeUnitSpecifier(["month"], {"year-month":"%b %Y ","year-month-date":"%b %d, %Y "}))' ); @@ -27,38 +26,33 @@ describe('Format', () => { it('should get the right time expression for yearmonth with custom format', () => { const fieldDef = {timeUnit: 'yearmonth', field: 'a', type: TEMPORAL} as const; - const expression = timeFormatExpression( - vgField(fieldDef, {expr: 'datum'}), - 'month', - '%Y', - defaultConfig.timeFormat, - false - ); + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + timeUnit: 'month', + format: '%Y', + rawTimeFormat: defaultConfig.timeFormat + }); expect(expression).toBe(`timeFormat(datum["yearmonth_a"], '%Y')`); }); it('should get the right time expression for quarter', () => { const fieldDef = {timeUnit: 'quarter', field: 'a', type: TEMPORAL} as const; - const expression = timeFormatExpression( - vgField(fieldDef, {expr: 'datum'}), - 'quarter', - undefined, - defaultConfig.timeFormat, - false - ); + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + timeUnit: 'quarter', + rawTimeFormat: defaultConfig.timeFormat + }); expect(expression).toBe( 'timeFormat(datum["quarter_a"], timeUnitSpecifier(["quarter"], {"year-month":"%b %Y ","year-month-date":"%b %d, %Y "}))' ); }); it('should get the right time expression for yearquarter', () => { - const expression = timeFormatExpression( - 'datum["data"]', - 'yearquarter', - undefined, - defaultConfig.timeFormat, - false - ); + const expression = timeFormatExpression({ + field: 'datum["data"]', + timeUnit: 'yearquarter', + rawTimeFormat: defaultConfig.timeFormat + }); expect(expression).toBe( 'timeFormat(datum["data"], timeUnitSpecifier(["year","quarter"], {"year-month":"%b %Y ","year-month-date":"%b %d, %Y "}))' ); @@ -66,15 +60,60 @@ describe('Format', () => { it('should get the right time expression for yearmonth with custom format and utc scale type', () => { const fieldDef = {timeUnit: 'yearmonth', field: 'a', type: TEMPORAL} as const; - const expression = timeFormatExpression( - vgField(fieldDef, {expr: 'datum'}), - 'month', - '%Y', - defaultConfig.timeFormat, - true - ); + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + timeUnit: 'month', + format: '%Y', + rawTimeFormat: defaultConfig.timeFormat, + isUTCScale: true + }); expect(expression).toBe(`utcFormat(datum["yearmonth_a"], '%Y')`); }); + + it('should get the right time expression for with a custom timeFormatType', () => { + const fieldDef = {field: 'a', type: TEMPORAL} as const; + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + format: '%Y', + formatType: 'customFormat' + }); + expect(expression).toBe(`customFormat(datum["a"], '%Y')`); + }); + + it('should prefer timeUnit over timeFormatType', () => { + const fieldDef = {field: 'a', type: TEMPORAL, timeUnit: 'date'} as const; + const expression = timeFormatExpression({ + field: vgField(fieldDef, {expr: 'datum'}), + format: '%Y', + timeUnit: 'date', + formatType: 'customFormat' + }); + expect(expression).toBe(`timeFormat(datum["date_a"], '%Y')`); + }); + }); + + describe('timeFormat()', () => { + it('returns the specifiedFormat if it exists', () => { + const formatted = timeFormat({specifiedFormat: 'abc', config: {}}); + expect(formatted).toBe('abc'); + }); + + it('returns the the formatted timeUnitExpression', () => { + const formatted = timeFormat({timeUnit: 'date', config: {}}); + expect(formatted).toEqual({ + signal: 'timeUnitSpecifier(["date"], {"year-month":"%b %Y ","year-month-date":"%b %d, %Y "})' + }); + }); + + it('omits the timeFormat when omitTimeFormatConfig and no specifiedFormat', () => { + const formatted = timeFormat({config: {timeFormat: '%y'}, omitTimeFormatConfig: true}); + expect(formatted).toBeUndefined(); + }); + + it('returns the timeFormat when !omitTimeFormatConfig and no specifiedFormat', () => { + const formatted = timeFormat({config: {timeFormat: '%y'}, omitTimeFormatConfig: false}); + expect(formatted).toBe('%y'); + }); }); describe('numberFormat()', () => { @@ -189,6 +228,20 @@ describe('Format', () => { }); }); + it('should use a custom formatter datumDef if config.timeFormatType is present', () => { + expect( + formatSignalRef({ + fieldOrDatumDef: {field: 'date', type: 'temporal'}, + format: undefined, + formatType: undefined, + expr: 'parent', + config: {timeFormat: 'abc', timeFormatType: 'customFormatter', customFormatTypes: true} + }) + ).toEqual({ + signal: 'customFormatter(parent["date"], "abc")' + }); + }); + it('should use a custom formatter datumDef if config.normalizedNumberFormatType is present and stack is normalized', () => { expect( formatSignalRef({ @@ -235,6 +288,7 @@ describe('Format', () => { const format = guideFormat({datum: 200, type: 'quantitative'}, 'quantitative', 'abc', 'custom', {}, false); expect(format).toBeUndefined(); }); + it('returns undefined for custom formatType in the config', () => { const format = guideFormat( {datum: 200, type: 'quantitative'}, @@ -259,6 +313,32 @@ describe('Format', () => { expect(format).toBeUndefined(); }); + it('returns undefined for a field if custom timeFormatType is in the config', () => { + const format = guideFormat( + {datum: 200, type: 'temporal'}, + 'temporal', + undefined, + undefined, + {timeFormat: 'abc', timeFormatType: 'customFormatter', customFormatTypes: true}, + false + ); + expect(format).toBeUndefined(); + }); + + it('Prefers timeUnit to timeFormatType', () => { + const format = guideFormat( + {field: 'x', type: 'temporal', timeUnit: 'hours'}, + 'temporal', + undefined, + undefined, + {timeFormat: 'abc', timeFormatType: 'customFormatter', customFormatTypes: true}, + false + ); + expect(format).toEqual({ + signal: 'timeUnitSpecifier(["hours"], {"year-month":"%b %Y ","year-month-date":"%b %d, %Y "})' + }); + }); + it('returns format as normalizedNumberFormatType it is not in the config', () => { const format = guideFormat( {datum: 200, type: 'quantitative', stack: 'normalize'} as PositionDatumDef, diff --git a/test/compile/legend/encode.test.ts b/test/compile/legend/encode.test.ts index 074ca6d0bf..d36bbaae7f 100644 --- a/test/compile/legend/encode.test.ts +++ b/test/compile/legend/encode.test.ts @@ -185,4 +185,43 @@ describe('compile/legend', () => { ); expect(label.text).toEqual({signal: 'customDateFormat(datum.value, "abc")'}); }); + + it('returns correct expression for custom format Type from config.timeFormatType', () => { + const fieldDef: Encoding['color'] = { + field: 'a', + type: 'temporal' + }; + + const model = parseUnitModelWithScale({ + mark: 'point', + encoding: {color: fieldDef}, + config: {customFormatTypes: true, timeFormat: 'abc', timeFormatType: 'customDateFormat'} + }); + + const label = encode.labels( + {}, + {fieldOrDatumDef: fieldDef, model, channel: COLOR, legendCmpt: symbolLegend, legendType: 'symbol'} + ); + expect(label.text).toEqual({signal: 'customDateFormat(datum.value, "abc")'}); + }); + + it('prefers timeUnit over config.timeFormatType', () => { + const fieldDef: Encoding['color'] = { + field: 'a', + type: 'temporal', + timeUnit: 'date' + }; + + const model = parseUnitModelWithScale({ + mark: 'point', + encoding: {color: fieldDef}, + config: {customFormatTypes: true, timeFormat: 'abc', timeFormatType: 'customDateFormat'} + }); + + const label = encode.labels( + {}, + {fieldOrDatumDef: fieldDef, model, channel: COLOR, legendCmpt: symbolLegend, legendType: 'symbol'} + ); + expect(label).toBeUndefined(); + }); });