UNPKG

vega-lite

Version:

Vega-Lite is a concise high-level language for interactive visualization.

285 lines (245 loc) • 9.18 kB
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); }