diff --git a/site/docs/invaliddata.md b/site/docs/invaliddata.md index 378e15faae..4c0566a670 100644 --- a/site/docs/invaliddata.md +++ b/site/docs/invaliddata.md @@ -5,7 +5,7 @@ title: Modes for Handling Invalid Data permalink: /docs/invalid-data.html --- -This page discusses modes in Vega-Lite for handling invalid data (`null` and `NaN` in continuous scales). +This page discusses modes in Vega-Lite for handling invalid data (`null` and `NaN` in continuous scales *without* defined output for invalid values in `config.scale.invalid`). The main configurations are [`mark.invalid`](#mark) and [`config.scale.invalid`](#scale). In addition, you can use [other Vega-Lite features including conditional encodings, layering, or window transform to handle invalid and missing data](#other). diff --git a/src/channeldef.ts b/src/channeldef.ts index 0b8b96f0eb..fd90a38ea7 100644 --- a/src/channeldef.ts +++ b/src/channeldef.ts @@ -398,7 +398,7 @@ export interface DatumDef< // `F extends RepeatRef` probably should be `RepeatRef extends F` but there is likely a bug in TS. } -export interface FormatMixins { +export interface FormatMixins { /** * When used with the default `"number"` and `"time"` format type, the text formatting pattern for labels of guides (axes, legends, headers) and text marks. * @@ -420,7 +420,7 @@ export interface FormatMixins { * - `"time"` for temporal fields and ordinal and nominal fields with `timeUnit`. * - `"number"` for quantitative fields as well as ordinal and nominal fields without `timeUnit`. */ - formatType?: 'number' | 'time' | string; + formatType?: FT; } export type StringDatumDef = DatumDef & FormatMixins; @@ -974,7 +974,7 @@ export function defaultTitle(fieldDef: FieldDefBase, config: Config) { return titleFormatter(fieldDef, config); } -export function getFormatMixins(fieldDef: TypedFieldDef | DatumDef) { +export function getFormatMixins(fieldDef: TypedFieldDef | DatumDef): FormatMixins { if (isStringFieldOrDatumDef(fieldDef)) { const {format, formatType} = fieldDef; return {format, formatType}; diff --git a/src/compile/axis/encode.ts b/src/compile/axis/encode.ts index 4a42815813..59db638fc7 100644 --- a/src/compile/axis/encode.ts +++ b/src/compile/axis/encode.ts @@ -16,8 +16,7 @@ export function labels(model: UnitModel, channel: PositionScaleChannel, specifie text: formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format, - formatType, + formatMixins: {format, formatType}, config }), ...specifiedLabelsSpec @@ -33,8 +32,10 @@ export function labels(model: UnitModel, channel: PositionScaleChannel, specifie text: formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.normalizedNumberFormat, - formatType: config.normalizedNumberFormatType, + formatMixins: { + format: config.normalizedNumberFormat, + formatType: config.normalizedNumberFormatType + }, config }), ...specifiedLabelsSpec @@ -44,8 +45,10 @@ export function labels(model: UnitModel, channel: PositionScaleChannel, specifie text: formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.numberFormat, - formatType: config.numberFormatType, + formatMixins: { + format: config.numberFormat, + formatType: config.numberFormatType + }, config }), ...specifiedLabelsSpec @@ -62,8 +65,10 @@ export function labels(model: UnitModel, channel: PositionScaleChannel, specifie text: formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.timeFormat, - formatType: config.timeFormatType, + formatMixins: { + format: config.timeFormat, + formatType: config.timeFormatType + }, config }), ...specifiedLabelsSpec diff --git a/src/compile/data/bin.ts b/src/compile/data/bin.ts index 1c43602e77..74eaa7f3fc 100644 --- a/src/compile/data/bin.ts +++ b/src/compile/data/bin.ts @@ -25,7 +25,7 @@ function rangeFormula(model: ModelWithField, fieldDef: TypedFieldDef, ch return { formulaAs: vgField(fieldDef, {binSuffix: 'range', forAs: true}), - formula: binFormatExpression(startField, endField, guide.format, guide.formatType, config) + formula: binFormatExpression(startField, endField, guide, config) }; } return {}; diff --git a/src/compile/format.ts b/src/compile/format.ts index be42af2cdb..37787a52a2 100644 --- a/src/compile/format.ts +++ b/src/compile/format.ts @@ -5,6 +5,7 @@ import { channelDefType, DatumDef, FieldDef, + FormatMixins, isFieldDef, isFieldOrDatumDefForTimeFormat, isPositionFieldOrDatumDef, @@ -21,7 +22,9 @@ import {isSignalRef} from '../vega.schema'; import {TimeUnit} from './../timeunit'; import {datumDefToExpr} from './mark/encode/valueref'; -export function isCustomFormatType(formatType: string) { +export type CustomFormatType = Exclude; + +export function isCustomFormatType(formatType: string): formatType is CustomFormatType { return formatType && formatType !== 'number' && formatType !== 'time'; } @@ -33,24 +36,23 @@ export const BIN_RANGE_DELIMITER = ' \u2013 '; export function formatSignalRef({ fieldOrDatumDef, - format, - formatType, + formatMixins, expr, normalizeStack, config }: { fieldOrDatumDef: FieldDef | DatumDef; - format: string | Dict; - formatType: string; + formatMixins: FormatMixins; expr?: 'datum' | 'parent' | 'datum.datum'; normalizeStack?: boolean; config: Config; }) { + const {formatType} = formatMixins; + let {format} = formatMixins; if (isCustomFormatType(formatType)) { return formatCustomType({ fieldOrDatumDef, - format, - formatType, + formatMixins: {...formatMixins, formatType}, expr, config }); @@ -64,16 +66,20 @@ export function formatSignalRef({ if (normalizeStack && config.normalizedNumberFormatType) return formatCustomType({ fieldOrDatumDef, - format: config.normalizedNumberFormat, - formatType: config.normalizedNumberFormatType, + formatMixins: { + format: config.normalizedNumberFormat, + formatType: config.normalizedNumberFormatType + }, expr, config }); if (config.numberFormatType) { return formatCustomType({ fieldOrDatumDef, - format: config.numberFormat, - formatType: config.numberFormatType, + formatMixins: { + format: config.numberFormat, + formatType: config.numberFormatType + }, expr, config }); @@ -87,8 +93,7 @@ export function formatSignalRef({ ) { return formatCustomType({ fieldOrDatumDef, - format: config.timeFormat, - formatType: config.timeFormatType, + formatMixins, expr, config }); @@ -96,7 +101,7 @@ export function formatSignalRef({ } if (isFieldOrDatumDefForTimeFormat(fieldOrDatumDef)) { - const signal = timeFormatExpression({ + const mainFormatExpr = timeFormatExpression({ field, timeUnit: isFieldDef(fieldOrDatumDef) ? normalizeTimeUnit(fieldOrDatumDef.timeUnit)?.unit : undefined, format, @@ -104,6 +109,11 @@ export function formatSignalRef({ rawTimeFormat: config.timeFormat, isUTCScale: isScaleFieldDef(fieldOrDatumDef) && fieldOrDatumDef.scale?.type === ScaleType.UTC }); + + const signal = wrapFormatExprToHandleInvalidValues({ + mainFormatExpr, + fieldExpr: field + }); return signal ? {signal} : undefined; } @@ -111,17 +121,23 @@ export function formatSignalRef({ if (isFieldDef(fieldOrDatumDef) && isBinning(fieldOrDatumDef.bin)) { const endField = vgField(fieldOrDatumDef, {expr, binSuffix: 'end'}); return { - signal: binFormatExpression(field, endField, format, formatType, config) + signal: binFormatExpression(field, endField, {format, formatType}, config) }; } else if (format || channelDefType(fieldOrDatumDef) === 'quantitative') { - return { - signal: `${formatExpr(field, format)}` - }; + const signal = wrapFormatExprToHandleInvalidValues({ + mainFormatExpr: builtInFormatExpr(field, format), + fieldExpr: field + }); + return {signal}; } else { - return {signal: `isValid(${field}) ? ${field} : ""+${field}`}; + return {signal: toStringExpr(field)}; } } +function toStringExpr(expr: string) { + return `"" + ${expr}`; +} + function fieldToFormat( fieldOrDatumDef: FieldDef | DatumDef, expr: 'datum' | 'parent' | 'datum.datum', @@ -143,16 +159,14 @@ function fieldToFormat( export function formatCustomType({ fieldOrDatumDef, - format, - formatType, expr, normalizeStack, config, + formatMixins, field }: { fieldOrDatumDef: FieldDef | DatumDef; - format: string | Dict; - formatType: string; + formatMixins: FormatMixins; expr?: 'datum' | 'parent' | 'datum.datum'; normalizeStack?: boolean; config: Config; @@ -167,10 +181,15 @@ export function formatCustomType({ ) { const endField = vgField(fieldOrDatumDef, {expr, binSuffix: 'end'}); return { - signal: binFormatExpression(field, endField, format, formatType, config) + signal: binFormatExpression(field, endField, formatMixins, config) }; } - return {signal: customFormatExpr(formatType, field, format)}; + const {format, formatType} = formatMixins; + const signal = wrapFormatExprToHandleInvalidValues({ + mainFormatExpr: customFormatExpr(formatType, field, format), + fieldExpr: field + }); + return {signal}; } export function guideFormat( @@ -289,31 +308,54 @@ export function timeFormat({ return omitTimeFormatConfig ? undefined : config.timeFormat; } -function formatExpr(field: string, format: string) { +function builtInFormatExpr(field: string, format: string) { return `format(${field}, "${format || ''}")`; } -function binNumberFormatExpr(field: string, format: string | Dict, formatType: string, config: Config) { +/** + * return format expr for number bin's start/end + */ +function binExtentFormatExpr(field: string, format: string | Dict, formatType: string, config: Config) { if (isCustomFormatType(formatType)) { return customFormatExpr(formatType, field, format); } + format = (isString(format) ? format : undefined) ?? config.numberFormat; + return builtInFormatExpr(field, format); +} - return formatExpr(field, (isString(format) ? format : undefined) ?? config.numberFormat); +function wrapFormatExprToHandleInvalidValues({ + fieldExpr, + mainFormatExpr +}: { + mainFormatExpr: string; + fieldExpr: string; +}): string { + return `${fieldValidPredicate(fieldExpr, false)} ? ${toStringExpr(fieldExpr)} : ${mainFormatExpr}`; } export function binFormatExpression( startField: string, endField: string, - format: string | Dict, - formatType: string, + {format, formatType}: FormatMixins, config: Config ): string { if (format === undefined && formatType === undefined && config.customFormatTypes && config.numberFormatType) { - return binFormatExpression(startField, endField, config.numberFormat, config.numberFormatType, config); + return binFormatExpression( + startField, + endField, + { + format: config.numberFormat, + formatType: config.numberFormatType + }, + config + ); } - const start = binNumberFormatExpr(startField, format, formatType, config); - const end = binNumberFormatExpr(endField, format, formatType, config); - return `${fieldValidPredicate(startField, false)} ? "null" : ${start} + "${BIN_RANGE_DELIMITER}" + ${end}`; + const start = binExtentFormatExpr(startField, format, formatType, config); + const end = binExtentFormatExpr(endField, format, formatType, config); + return wrapFormatExprToHandleInvalidValues({ + fieldExpr: startField, + mainFormatExpr: `${start} + "${BIN_RANGE_DELIMITER}" + ${end}` + }); } /** diff --git a/src/compile/header/assemble.ts b/src/compile/header/assemble.ts index 1bc29596fd..96bcde5911 100644 --- a/src/compile/header/assemble.ts +++ b/src/compile/header/assemble.ts @@ -131,8 +131,7 @@ export function assembleLabelTitle( const titleTextExpr = formatSignalRef({ fieldOrDatumDef: facetFieldDef, - format, - formatType, + formatMixins: {format, formatType}, expr: 'parent', config }).signal; diff --git a/src/compile/legend/encode.ts b/src/compile/legend/encode.ts index 334e0b0381..4457fe0acd 100644 --- a/src/compile/legend/encode.ts +++ b/src/compile/legend/encode.ts @@ -156,8 +156,7 @@ export function labels(specifiedlabelsSpec: any, {fieldOrDatumDef, model, channe text = formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format, - formatType, + formatMixins: {format, formatType}, config }); } else if (format === undefined && formatType === undefined && config.customFormatTypes) { @@ -165,8 +164,10 @@ export function labels(specifiedlabelsSpec: any, {fieldOrDatumDef, model, channe text = formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.numberFormat, - formatType: config.numberFormatType, + formatMixins: { + format: config.numberFormat, + formatType: config.numberFormatType + }, config }); } else if ( @@ -178,8 +179,10 @@ export function labels(specifiedlabelsSpec: any, {fieldOrDatumDef, model, channe text = formatCustomType({ fieldOrDatumDef, field: 'datum.value', - format: config.timeFormat, - formatType: config.timeFormatType, + formatMixins: { + format: config.timeFormat, + formatType: config.timeFormatType + }, config }); } diff --git a/src/compile/mark/encode/text.ts b/src/compile/mark/encode/text.ts index 9b1bfc2241..16d7fb8429 100644 --- a/src/compile/mark/encode/text.ts +++ b/src/compile/mark/encode/text.ts @@ -29,8 +29,8 @@ export function textRef( return signalOrValueRef(channelDef.value); } if (isFieldOrDatumDef(channelDef)) { - const {format, formatType} = getFormatMixins(channelDef); - return formatSignalRef({fieldOrDatumDef: channelDef, format, formatType, expr, config}); + const formatMixins = getFormatMixins(channelDef); + return formatSignalRef({fieldOrDatumDef: channelDef, formatMixins, expr, config}); } } return undefined; diff --git a/src/compile/mark/encode/tooltip.ts b/src/compile/mark/encode/tooltip.ts index 48f70ce3af..a3b5b68d39 100644 --- a/src/compile/mark/encode/tooltip.ts +++ b/src/compile/mark/encode/tooltip.ts @@ -107,8 +107,8 @@ export function tooltipData( if (isBinned(fieldDef.bin) && fieldDef2) { const startField = vgField(fieldDef, {expr}); const endField = vgField(fieldDef2, {expr}); - const {format, formatType} = getFormatMixins(fieldDef); - value = binFormatExpression(startField, endField, format, formatType, formatConfig); + const formatMixins = getFormatMixins(fieldDef); + value = binFormatExpression(startField, endField, formatMixins, formatConfig); toSkip[channel2] = true; } } @@ -119,11 +119,10 @@ export function tooltipData( stack.fieldChannel === channel && stack.offset === 'normalize' ) { - const {format, formatType} = getFormatMixins(fieldDef); + const formatMixins = getFormatMixins(fieldDef); value = formatSignalRef({ fieldOrDatumDef: fieldDef, - format, - formatType, + formatMixins, expr, config: formatConfig, normalizeStack: true diff --git a/src/invalid.ts b/src/invalid.ts index 006d400d11..f767525229 100644 --- a/src/invalid.ts +++ b/src/invalid.ts @@ -8,7 +8,7 @@ import {isObject} from 'vega-util'; */ export interface MarkInvalidMixins { /** - * Invalid data mode, which defines how the marks and corresponding scales should represent invalid values (`null` and `NaN` in continuous scales *without* defined output for invalid values). + * Invalid data mode, which defines how the marks and corresponding scales should represent invalid values (`null` and `NaN` in continuous scales *without* defined output for invalid values in `config.scale.invalid`). * * - `"filter"` — *Exclude* all invalid values from the visualization's *marks* and *scales*. * For path marks (for line, area, trail), this option will create paths that connect valid points, as if the data rows with invalid values do not exist. diff --git a/test/compile/format.test.ts b/test/compile/format.test.ts index ada7be09f4..a9e688d9f7 100644 --- a/test/compile/format.test.ts +++ b/test/compile/format.test.ts @@ -161,8 +161,10 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {field: 'foo', type: 'ordinal'}, - format: '.2f', - formatType: undefined, + formatMixins: { + format: '.2f', + formatType: undefined + }, expr: 'parent', config: {} }) @@ -175,8 +177,10 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {bin: true, field: 'foo', type: 'quantitative'}, - format: undefined, - formatType: undefined, + formatMixins: { + format: undefined, + formatType: undefined + }, expr: 'parent', config: {numberFormat: 'abc', numberFormatType: 'customFormatter'} }) @@ -190,13 +194,15 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {datum: 200, type: 'quantitative'}, - format: '.2f', - formatType: undefined, + formatMixins: { + format: '.2f', + formatType: undefined + }, expr: 'parent', config: {} }) ).toEqual({ - signal: 'format(200, ".2f")' + signal: '!isValid(200) || !isFinite(+200) ? toString(200) : format(200, ".2f")' }); }); @@ -204,8 +210,10 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {datum: 200, type: 'quantitative'}, - format: 'abc', - formatType: 'customFormatter', + formatMixins: { + format: 'abc', + formatType: 'customFormatter' + }, expr: 'parent', config: {} }) @@ -218,8 +226,10 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {datum: 200, type: 'quantitative'}, - format: undefined, - formatType: undefined, + formatMixins: { + format: undefined, + formatType: undefined + }, expr: 'parent', config: {numberFormat: 'abc', numberFormatType: 'customFormatter', customFormatTypes: true} }) @@ -232,8 +242,10 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {field: 'date', type: 'temporal'}, - format: undefined, - formatType: undefined, + formatMixins: { + format: undefined, + formatType: undefined + }, expr: 'parent', config: {timeFormat: 'abc', timeFormatType: 'customFormatter', customFormatTypes: true} }) @@ -246,8 +258,7 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {datum: 200, type: 'quantitative'}, - format: undefined, - formatType: undefined, + formatMixins: {}, expr: 'parent', normalizeStack: true, config: { @@ -265,8 +276,7 @@ describe('Format', () => { expect( formatSignalRef({ fieldOrDatumDef: {datum: 200, type: 'quantitative'}, - format: undefined, - formatType: undefined, + formatMixins: {}, expr: 'parent', normalizeStack: true, config: {