vega-lite
Version:
Vega-Lite is a concise high-level language for interactive visualization.
359 lines (331 loc) • 10.5 kB
text/typescript
import type {SignalRef} from 'vega';
import {isString} from 'vega-util';
import {isBinning} from '../bin.js';
import {
channelDefType,
DatumDef,
FieldDef,
Format,
isFieldDef,
isFieldOrDatumDefForTimeFormat,
isPositionFieldOrDatumDef,
isScaleFieldDef,
vgField,
} from '../channeldef.js';
import {Config} from '../config.js';
import {fieldValidPredicate} from '../predicate.js';
import {ScaleType} from '../scale.js';
import {formatExpression, normalizeTimeUnit, timeUnitSpecifierExpression} from '../timeunit.js';
import {QUANTITATIVE, Type} from '../type.js';
import {stringify} from '../util.js';
import {isSignalRef} from '../vega.schema.js';
import {TimeUnit} from './../timeunit.js';
import {datumDefToExpr} from './mark/encode/valueref.js';
import {BIN_RANGE_DELIMITER} from './common.js';
export function isCustomFormatType(formatType: string) {
return formatType && formatType !== 'number' && formatType !== 'time';
}
function customFormatExpr(formatType: string, field: string, format: Format) {
return `${formatType}(${field}${format ? `, ${stringify(format)}` : ''})`;
}
export function formatSignalRef({
fieldOrDatumDef,
format,
formatType,
expr,
normalizeStack,
config,
}: {
fieldOrDatumDef: FieldDef<string> | DatumDef<string>;
format: Format;
formatType: string;
expr?: 'datum' | 'parent' | 'datum.datum';
normalizeStack?: boolean;
config: Config;
}) {
if (isCustomFormatType(formatType)) {
return formatCustomType({
fieldOrDatumDef,
format,
formatType,
expr,
config,
});
}
const field = fieldToFormat(fieldOrDatumDef, expr, normalizeStack);
const type = channelDefType(fieldOrDatumDef);
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,
});
}
}
function getTimeDef(def: FieldDef<string> | DatumDef<string>) {
if (!isFieldDef(def)) {
return {
unit: undefined,
utc: undefined,
};
}
return normalizeTimeUnit(def.timeUnit) || {};
}
if (isFieldOrDatumDefForTimeFormat(fieldOrDatumDef)) {
const {unit: timeUnit, utc: isUTCUnit} = getTimeDef(fieldOrDatumDef);
const signal = timeFormatExpression({
field,
timeUnit,
format,
formatType: config.timeFormatType,
rawTimeFormat: config.timeFormat,
isUTCScale: isUTCUnit || (isScaleFieldDef(fieldOrDatumDef) && fieldOrDatumDef.scale?.type === ScaleType.UTC),
});
return signal ? {signal} : undefined;
}
format = numberFormat({type, specifiedFormat: format, config, normalizeStack});
if (isFieldDef(fieldOrDatumDef) && isBinning(fieldOrDatumDef.bin)) {
const endField = vgField(fieldOrDatumDef, {expr, binSuffix: 'end'});
return {
signal: binFormatExpression(field, endField, format, formatType, config),
};
} else if (format || channelDefType(fieldOrDatumDef) === 'quantitative') {
return {
signal: `${formatExpr(field, format)}`,
};
} else {
return {signal: `isValid(${field}) ? ${field} : ""+${field}`};
}
}
function fieldToFormat(
fieldOrDatumDef: FieldDef<string> | DatumDef<string>,
expr: 'datum' | 'parent' | 'datum.datum',
normalizeStack: boolean,
) {
if (isFieldDef(fieldOrDatumDef)) {
if (normalizeStack) {
return `${vgField(fieldOrDatumDef, {expr, suffix: 'end'})}-${vgField(fieldOrDatumDef, {
expr,
suffix: 'start',
})}`;
} else {
return vgField(fieldOrDatumDef, {expr});
}
} else {
return datumDefToExpr(fieldOrDatumDef);
}
}
export function formatCustomType({
fieldOrDatumDef,
format,
formatType,
expr,
normalizeStack,
config,
field,
}: {
fieldOrDatumDef: FieldDef<string> | DatumDef<string>;
format: Format;
formatType: string;
expr?: 'datum' | 'parent' | 'datum.datum';
normalizeStack?: boolean;
config: Config;
field?: string; // axis/legend "use datum.value"
}) {
field ??= fieldToFormat(fieldOrDatumDef, expr, normalizeStack);
if (
field !== 'datum.value' && // For axis/legend, we can't correctly know the end of the bin from `datum`
isFieldDef(fieldOrDatumDef) &&
isBinning(fieldOrDatumDef.bin)
) {
const endField = vgField(fieldOrDatumDef, {expr, binSuffix: 'end'});
return {
signal: binFormatExpression(field, endField, format, formatType, config),
};
}
return {signal: customFormatExpr(formatType, field, format)};
}
export function guideFormat(
fieldOrDatumDef: FieldDef<string> | DatumDef<string>,
type: Type,
format: Format,
formatType: string | SignalRef,
config: Config,
omitTimeFormatConfig: boolean, // axis doesn't use config.timeFormat
) {
if (isString(formatType) && isCustomFormatType(formatType)) {
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
}
}
}
if (
isPositionFieldOrDatumDef(fieldOrDatumDef) &&
fieldOrDatumDef.stack === 'normalize' &&
config.normalizedNumberFormat
) {
return numberFormat({
type: 'quantitative',
config,
normalizeStack: true,
});
}
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({specifiedFormat: format as string, timeUnit, config, omitTimeFormatConfig});
}
return numberFormat({type, specifiedFormat: format, config});
}
export function guideFormatType(
formatType: string | SignalRef,
fieldOrDatumDef: FieldDef<string> | DatumDef<string>,
scaleType: ScaleType,
) {
if (formatType && (isSignalRef(formatType) || formatType === 'number' || formatType === 'time')) {
return formatType;
}
if (isFieldOrDatumDefForTimeFormat(fieldOrDatumDef) && scaleType !== 'time' && scaleType !== 'utc') {
return isFieldDef(fieldOrDatumDef) && normalizeTimeUnit(fieldOrDatumDef?.timeUnit)?.utc ? 'utc' : 'time';
}
return undefined;
}
/**
* Returns number format for a fieldDef.
*/
export function numberFormat({
type,
specifiedFormat,
config,
normalizeStack,
}: {
type: Type;
specifiedFormat?: Format;
config: Config;
normalizeStack?: boolean;
}) {
// Specified format in axis/legend has higher precedence than fieldDef.format
if (isString(specifiedFormat)) {
return specifiedFormat;
}
if (type === QUANTITATIVE) {
// we only apply the default if the field is quantitative
return normalizeStack ? config.normalizedNumberFormat : config.numberFormat;
}
return undefined;
}
/**
* Returns time format for a fieldDef for use in guides.
*/
export function timeFormat({
specifiedFormat,
timeUnit,
config,
omitTimeFormatConfig,
}: {
specifiedFormat?: string;
timeUnit?: TimeUnit;
config: Config;
omitTimeFormatConfig?: boolean;
}) {
if (specifiedFormat) {
return specifiedFormat;
}
if (timeUnit) {
return {
signal: timeUnitSpecifierExpression(timeUnit),
};
}
return omitTimeFormatConfig ? undefined : config.timeFormat;
}
function formatExpr(field: string, format: string) {
return `format(${field}, "${format || ''}")`;
}
function binNumberFormatExpr(field: string, format: Format, formatType: string, config: Config) {
if (isCustomFormatType(formatType)) {
return customFormatExpr(formatType, field, format);
}
return formatExpr(field, (isString(format) ? format : undefined) ?? config.numberFormat);
}
export function binFormatExpression(
startField: string,
endField: string,
format: Format,
formatType: string,
config: Config,
): string {
if (format === undefined && formatType === undefined && config.customFormatTypes && config.numberFormatType) {
return binFormatExpression(startField, endField, config.numberFormat, 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}`;
}
/**
* Returns the time expression used for axis/legend labels or text mark for a temporal field
*/
export function timeFormatExpression({
field,
timeUnit,
format,
formatType,
rawTimeFormat,
isUTCScale,
}: {
field: string;
timeUnit?: TimeUnit;
format?: Format;
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}, ${stringify(format)})`;
}
format = isString(format) ? format : rawTimeFormat; // only use provided timeFormat if there is no timeUnit.
return `${isUTCScale ? 'utc' : 'time'}Format(${field}, ${stringify(format)})`;
} else {
return formatExpression(timeUnit, field, isUTCScale);
}
}