vega-lite
Version:
Vega-Lite is a concise high-level language for interactive visualization.
285 lines (245 loc) • 9.18 kB
text/typescript
import {array, isArray, isObject, isString} from 'vega-util';
import {isArgmaxDef, isArgminDef} from '../../../aggregate.js';
import {isBinned} from '../../../bin.js';
import {Channel, getMainRangeChannel, isXorY, RADIUS, THETA, TOOLTIP} from '../../../channel.js';
import {
defaultTitle,
getFieldDef,
getFormatMixins,
hasConditionalFieldDef,
isCount,
isFieldDef,
isTypedFieldDef,
SecondaryFieldDef,
TypedFieldDef,
vgField,
} from '../../../channeldef.js';
import {Config} from '../../../config.js';
import {Encoding, forEach} from '../../../encoding.js';
import * as log from '../../../log/index.js';
import {
fieldFilterExpression,
isFieldPredicate,
isFieldValidPredicate,
TooltipFieldFilter,
TooltipFieldPredicate,
} from '../../../predicate.js';
import {StackProperties} from '../../../stack.js';
import {normalizeTimeUnit} from '../../../timeunit.js';
import {isDiscrete} from '../../../type.js';
import {hasProperty, logicalExpr} from '../../../util.js';
import {isSignalRef, VgValueRef} from '../../../vega.schema.js';
import {getMarkPropOrConfig} from '../../common.js';
import {binFormatExpression, formatSignalRef} from '../../format.js';
import {UnitModel} from '../../unit.js';
import {wrapCondition} from './conditional.js';
import {textRef} from './text.js';
export function tooltip(model: UnitModel, opt: {reactiveGeom?: boolean} = {}) {
const {encoding, markDef, config, stack} = model;
const channelDef = encoding.tooltip;
if (isArray(channelDef)) {
return {tooltip: tooltipRefForEncoding({tooltip: channelDef}, stack, config, opt)};
} else {
const datum = opt.reactiveGeom ? 'datum.datum' : 'datum';
const mainRefFn = (cDef: Encoding<string>['tooltip']) => {
// use valueRef based on channelDef first
const tooltipRefFromChannelDef = addLineBreaksToTooltip(cDef, config, datum);
if (tooltipRefFromChannelDef) {
return tooltipRefFromChannelDef;
}
if (cDef === null) {
// Allow using encoding.tooltip = null to disable tooltip
return undefined;
}
let markTooltip = getMarkPropOrConfig('tooltip', markDef, config);
if (markTooltip === true) {
markTooltip = {content: 'encoding'};
}
if (isString(markTooltip)) {
return {value: markTooltip};
} else if (isObject(markTooltip)) {
// `tooltip` is `{fields: 'encodings' | 'fields'}`
if (isSignalRef(markTooltip)) {
return markTooltip;
} else if (markTooltip.content === 'encoding') {
return tooltipRefForEncoding(encoding, stack, config, opt);
} else {
return {signal: datum};
}
}
return undefined;
};
return wrapCondition({
model,
channelDef,
vgChannel: 'tooltip',
mainRefFn,
invalidValueRef: undefined, // tooltip encoding doesn't have continuous scales and thus can't have invalid values
});
}
}
export type TooltipTuple = {channel: Channel; key: string; value: string; test?: string};
type FilterableTooltipFieldDef = (TypedFieldDef<string> | SecondaryFieldDef<string>) & {
filter?: TooltipFieldFilter;
};
export function tooltipDataTuples(
encoding: Encoding<string>,
stack: StackProperties,
config: Config,
{reactiveGeom}: {reactiveGeom?: boolean} = {},
) {
const formatConfig = {...config, ...config.tooltipFormat};
const toSkip = new Set();
const expr = reactiveGeom ? 'datum.datum' : 'datum';
const tuples: TooltipTuple[] = [];
function add(fDef: FilterableTooltipFieldDef, channel: Channel) {
const mainChannel = getMainRangeChannel(channel);
const fieldDef: TypedFieldDef<string> = isTypedFieldDef(fDef)
? fDef
: {
...fDef,
type: (encoding[mainChannel] as TypedFieldDef<any>).type, // for secondary field def, copy type from main channel
};
const title = fieldDef.title || defaultTitle(fieldDef, formatConfig);
const key = array(title).join(', ').replaceAll(/"/g, '\\"');
let value: string;
if (isXorY(channel)) {
const channel2 = channel === 'x' ? 'x2' : 'y2';
const fieldDef2 = getFieldDef(encoding[channel2]);
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);
toSkip.add(channel2);
}
}
if (fieldDef.tooltip === false) {
return;
}
const test = tooltipFilterExpression(fieldDef, channel, expr);
if (
(isXorY(channel) || channel === THETA || channel === RADIUS) &&
stack &&
stack.fieldChannel === channel &&
stack.offset === 'normalize'
) {
const {format, formatType} = getFormatMixins(fieldDef);
value = formatSignalRef({
fieldOrDatumDef: fieldDef,
format,
formatType,
expr,
config: formatConfig,
normalizeStack: true,
}).signal;
}
value ??= addLineBreaksToTooltip(fieldDef, formatConfig, expr).signal;
tuples.push({channel, key, value, test});
}
forEach(encoding, (channelDef, channel) => {
if (isFieldDef(channelDef)) {
add(channelDef, channel);
} else if (hasConditionalFieldDef(channelDef)) {
add(channelDef.condition, channel);
}
});
const out: TooltipTuple[] = [];
const keys = new Set<string>();
for (const {channel, key, value, test} of tuples) {
if (!toSkip.has(channel) && !keys.has(key)) {
out.push({channel, key, value, test});
keys.add(key);
}
}
return out;
}
// Converts a tooltip filter for the given field into a Vega expression, undefined when absent or invalid.
function tooltipFilterExpression(
fieldDef: FilterableTooltipFieldDef,
channel: Channel,
expr: 'datum' | 'datum.datum',
): string | undefined {
if (channel !== TOOLTIP || !hasProperty(fieldDef, 'filter')) {
return undefined;
}
const {filter} = fieldDef;
if (isArgminDef(fieldDef.aggregate) || isArgmaxDef(fieldDef.aggregate) || (!fieldDef.field && !isCount(fieldDef))) {
log.warn(log.message.TOOLTIP_FILTER_REQUIRES_FIELD);
return undefined;
}
// The predicates test the mark's datum, where aggregate, bin, and time unit are already applied
// (the raw field may not even exist after aggregation), so resolve the datum field upfront and
// mark time units as binned so they are not recomputed from the raw field.
const timeUnit = normalizeTimeUnit(fieldDef.timeUnit);
const boundFieldDef = {field: vgField(fieldDef), ...(timeUnit ? {timeUnit: {...timeUnit, binned: true}} : {})};
let valid = true;
let leafCount = 0;
const test = logicalExpr(filter, (predicate: TooltipFieldPredicate) => {
leafCount++;
if (isObject(predicate)) {
const fieldPredicate = {...predicate, ...boundFieldDef};
if (isFieldPredicate(fieldPredicate) || isFieldValidPredicate(fieldPredicate)) {
return fieldFilterExpression(fieldPredicate, true, expr);
}
}
valid = false;
return '';
});
if (valid && leafCount > 0) {
return test;
}
log.warn(log.message.invalidTooltipFilter(filter));
return undefined;
}
export function tooltipRefForEncoding(
encoding: Encoding<string>,
stack: StackProperties,
config: Config,
{reactiveGeom}: {reactiveGeom?: boolean} = {},
) {
const tuples = tooltipDataTuples(encoding, stack, config, {reactiveGeom});
if (tuples.length === 0) {
return undefined;
}
const objectExpr = ({key, value}: TooltipTuple) => `{"${key}": ${value}}`;
if (tuples.some(({test}) => !!test)) {
if (tuples.length === 1) {
return {signal: `(${tuples[0].test}) ? ${objectExpr(tuples[0])} : null`};
}
const keyValues = tuples.map((tuple) =>
tuple.test ? `(${tuple.test}) ? ${objectExpr(tuple)} : {}` : objectExpr(tuple),
);
const value = `merge(${keyValues.join(', ')})`;
if (tuples.every(({test}) => !!test)) {
// With all fields filtered, return null instead of an empty object so that no tooltip is shown.
const anyVisible = tuples.map(({test}) => `(${test})`).join(' || ');
return {signal: `(${anyVisible}) ? ${value} : null`};
}
return {signal: value};
}
const keyValues = tuples.map(({key, value}) => `"${key}": ${value}`);
return {signal: `{${keyValues.join(', ')}}`};
}
/**
* Transforms a tooltip value that is an array to a string with line breaks
*/
function addLineBreaksToTooltip(
channelDef: Encoding<string>['text' | 'tooltip'],
config: Config,
expr: 'datum' | 'datum.datum' = 'datum',
): VgValueRef {
if (
isFieldDef(channelDef) &&
isDiscrete(channelDef.type) &&
!channelDef.timeUnit &&
!getFormatMixins(channelDef).format &&
!getFormatMixins(channelDef).formatType
) {
const fieldString = `${expr}["${channelDef.field}"]`;
return {
signal: `isValid(${fieldString}) ? isArray(${fieldString}) ? join(${fieldString}, '\\n') : ${fieldString} : ""+${fieldString}`,
};
}
return textRef(channelDef, config, expr);
}