Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add filter and sort support for tooltips #9148

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
138 changes: 138 additions & 0 deletions build/vega-lite-schema.json
Expand Up @@ -5052,12 +5052,22 @@
},
"type": "array"
},
{
"items": {
"$ref": "#/definitions/TooltipFieldDef"
},
"type": "array"
},
{
"type": "null"
}
],
"description": "The tooltip text to show upon mouse hover. Specifying `tooltip` encoding overrides [the `tooltip` property in the mark definition](https://vega.github.io/vega-lite/docs/mark.html#mark-def).\n\nSee the [`tooltip`](https://vega.github.io/vega-lite/docs/tooltip.html) documentation for a detailed discussion about tooltip in Vega-Lite."
},
"sort_tooltip": {
"$ref": "#/definitions/TooltipSort",
"description": "Parameter to control whether and how the tooltip is sorted. 'ascending' or 'descending' only. It's users' responsibilities to make sure to put non-sortable fields or the fields they do not want to sort at the beginning or the end of the tooltip array, otherwise the order will be messed up."
},
"url": {
"anyOf": [
{
Expand Down Expand Up @@ -27196,6 +27206,134 @@
},
"type": "object"
},
"TooltipFilter": {
"properties": {
"operator": {
"enum": [
"==",
"!=",
"<",
"<=",
">",
">="
]
},
"literal": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
}
]
}
},
"required": [
"operator",
"literal"
]
},
"TooltipSort": {
"properties": {
"value": {
"enum": [
"ascending",
"descending"
],
"type": "string"
}
},
"required": [
"value"
]
},
"TooltipFieldDef": {
"additionalProperties": false,
"properties": {
"aggregate": {
"$ref": "#/definitions/Aggregate",
"description": "Aggregation function for the field (e.g., `\"mean\"`, `\"sum\"`, `\"median\"`, `\"min\"`, `\"max\"`, `\"count\"`).\n\n__Default value:__ `undefined` (None)\n\n__See also:__ [`aggregate`](https://vega.github.io/vega-lite/docs/aggregate.html) documentation."
},
"bandPosition": {
"description": "Relative position on a band of a stacked, binned, time unit, or band scale. For example, the marks will be positioned at the beginning of the band if set to `0`, and at the middle of the band if set to `0.5`.",
"maximum": 1,
"minimum": 0,
"type": "number"
},
"bin": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/BinParams"
},
{
"const": "binned",
"type": "string"
},
{
"type": "null"
}
],
"description": "A flag for binning a `quantitative` field, [an object defining binning parameters](https://vega.github.io/vega-lite/docs/bin.html#bin-parameters), or indicating that the data for `x` or `y` channel are binned before they are imported into Vega-Lite (`\"binned\"`).\n\n- If `true`, default [binning parameters](https://vega.github.io/vega-lite/docs/bin.html#bin-parameters) will be applied.\n\n- If `\"binned\"`, this indicates that the data for the `x` (or `y`) channel are already binned. You can map the bin-start field to `x` (or `y`) and the bin-end field to `x2` (or `y2`). The scale and axis will be formatted similar to binning in Vega-Lite. To adjust the axis ticks based on the bin step, you can also set the axis's [`tickMinStep`](https://vega.github.io/vega-lite/docs/axis.html#ticks) property.\n\n__Default value:__ `false`\n\n__See also:__ [`bin`](https://vega.github.io/vega-lite/docs/bin.html) documentation."
},
"field": {
"$ref": "#/definitions/Field",
"description": "__Required.__ A string defining the name of the field from which to pull a data value or an object defining iterated values from the [`repeat`](https://vega.github.io/vega-lite/docs/repeat.html) operator.\n\n__See also:__ [`field`](https://vega.github.io/vega-lite/docs/field.html) documentation.\n\n__Notes:__ 1) Dots (`.`) and brackets (`[` and `]`) can be used to access nested objects (e.g., `\"field\": \"foo.bar\"` and `\"field\": \"foo['bar']\"`). If field names contain dots or brackets but are not nested, you can use `\\\\` to escape dots and brackets (e.g., `\"a\\\\.b\"` and `\"a\\\\[0\\\\]\"`). See more details about escaping in the [field documentation](https://vega.github.io/vega-lite/docs/field.html). 2) `field` is not required if `aggregate` is `count`."
},
"format": {
"anyOf": [
{
"type": "string"
},
{
"$ref": "#/definitions/Dict"
}
],
"description": "When used with the default `\"number\"` and `\"time\"` format type, the text formatting pattern for labels of guides (axes, legends, headers) and text marks.\n\n- If the format type is `\"number\"` (e.g., for quantitative fields), this is D3's [number format pattern](https://github.com/d3/d3-format#locale_format).\n- If the format type is `\"time\"` (e.g., for temporal fields), this is D3's [time format pattern](https://github.com/d3/d3-time-format#locale_format).\n\nSee the [format documentation](https://vega.github.io/vega-lite/docs/format.html) for more examples.\n\nWhen used with a [custom `formatType`](https://vega.github.io/vega-lite/docs/config.html#custom-format-type), this value will be passed as `format` alongside `datum.value` to the registered function.\n\n__Default value:__ Derived from [numberFormat](https://vega.github.io/vega-lite/docs/config.html#format) config for number format and from [timeFormat](https://vega.github.io/vega-lite/docs/config.html#format) config for time format."
},
"formatType": {
"description": "The format type for labels. One of `\"number\"`, `\"time\"`, or a [registered custom format type](https://vega.github.io/vega-lite/docs/config.html#custom-format-type).\n\n__Default value:__\n- `\"time\"` for temporal fields and ordinal and nominal fields with `timeUnit`.\n- `\"number\"` for quantitative fields as well as ordinal and nominal fields without `timeUnit`.",
"type": "string"
},
"timeUnit": {
"anyOf": [
{
"$ref": "#/definitions/TimeUnit"
},
{
"$ref": "#/definitions/TimeUnitParams"
}
],
"description": "Time unit (e.g., `year`, `yearmonth`, `month`, `hours`) for a temporal field. or [a temporal field that gets casted as ordinal](https://vega.github.io/vega-lite/docs/type.html#cast).\n\n__Default value:__ `undefined` (None)\n\n__See also:__ [`timeUnit`](https://vega.github.io/vega-lite/docs/timeunit.html) documentation."
},
"title": {
"anyOf": [
{
"$ref": "#/definitions/Text"
},
{
"type": "null"
}
],
"description": "A title for the field. If `null`, the title will be removed.\n\n__Default value:__ derived from the field's name and transformation function (`aggregate`, `bin` and `timeUnit`). If the field has an aggregate function, the function is displayed as part of the title (e.g., `\"Sum of Profit\"`). If the field is binned or has a time unit applied, the applied function is shown in parentheses (e.g., `\"Profit (binned)\"`, `\"Transaction Date (year-month)\"`). Otherwise, the title is simply the field name.\n\n__Notes__:\n\n1) You can customize the default field title format by providing the [`fieldTitle`](https://vega.github.io/vega-lite/docs/config.html#top-level-config) property in the [config](https://vega.github.io/vega-lite/docs/config.html) or [`fieldTitle` function via the `compile` function's options](https://vega.github.io/vega-lite/usage/compile.html#field-title).\n\n2) If both field definition's `title` and axis, header, or legend `title` are defined, axis/header/legend title will be used."
},
"type": {
"$ref": "#/definitions/StandardType",
"description": "The type of measurement (`\"quantitative\"`, `\"temporal\"`, `\"ordinal\"`, or `\"nominal\"`) for the encoded field or constant value (`datum`). It can also be a `\"geojson\"` type for encoding ['geoshape'](https://vega.github.io/vega-lite/docs/geoshape.html).\n\nVega-Lite automatically infers data types in many cases as discussed below. However, type is required for a field if: (1) the field is not nominal and the field encoding has no specified `aggregate` (except `argmin` and `argmax`), `bin`, scale type, custom `sort` order, nor `timeUnit` or (2) if you wish to use an ordinal scale for a field with `bin` or `timeUnit`.\n\n__Default value:__\n\n1) For a data `field`, `\"nominal\"` is the default data type unless the field encoding has `aggregate`, `channel`, `bin`, scale type, `sort`, or `timeUnit` that satisfies the following criteria:\n- `\"quantitative\"` is the default type if (1) the encoded field contains `bin` or `aggregate` except `\"argmin\"` and `\"argmax\"`, (2) the encoding channel is `latitude` or `longitude` channel or (3) if the specified scale type is [a quantitative scale](https://vega.github.io/vega-lite/docs/scale.html#type).\n- `\"temporal\"` is the default type if (1) the encoded field contains `timeUnit` or (2) the specified scale type is a time or utc scale\n- `\"ordinal\"` is the default type if (1) the encoded field contains a [custom `sort` order](https://vega.github.io/vega-lite/docs/sort.html#specifying-custom-sort-order), (2) the specified scale type is an ordinal/point/band scale, or (3) the encoding channel is `order`.\n\n2) For a constant value in data domain (`datum`):\n- `\"quantitative\"` if the datum is a number\n- `\"nominal\"` if the datum is a string\n- `\"temporal\"` if the datum is [a date time object](https://vega.github.io/vega-lite/docs/datetime.html)\n\n__Note:__\n- Data `type` describes the semantics of the data rather than the primitive data types (number, string, etc.). The same primitive data type can have different types of measurement. For example, numeric data can represent quantitative, ordinal, or nominal data.\n- Data values for a temporal field can be either a date-time string (e.g., `\"2015-03-07 12:32:17\"`, `\"17:01\"`, `\"2015-03-16\"`. `\"2015\"`) or a timestamp number (e.g., `1552199579097`).\n- When using with [`bin`](https://vega.github.io/vega-lite/docs/bin.html), the `type` property can be either `\"quantitative\"` (for using a linear bin scale) or [`\"ordinal\"` (for using an ordinal bin scale)](https://vega.github.io/vega-lite/docs/type.html#cast-bin).\n- When using with [`timeUnit`](https://vega.github.io/vega-lite/docs/timeunit.html), the `type` property can be either `\"temporal\"` (default, for using a temporal scale) or [`\"ordinal\"` (for using an ordinal scale)](https://vega.github.io/vega-lite/docs/type.html#cast-bin).\n- When using with [`aggregate`](https://vega.github.io/vega-lite/docs/aggregate.html), the `type` property refers to the post-aggregation data type. For example, we can calculate count `distinct` of a categorical field `\"cat\"` using `{\"aggregate\": \"distinct\", \"field\": \"cat\"}`. The `\"type\"` of the aggregate output is `\"quantitative\"`.\n- Secondary channels (e.g., `x2`, `y2`, `xError`, `yError`) do not have `type` as they must have exactly the same type as their primary channels (e.g., `x`, `y`).\n\n__See also:__ [`type`](https://vega.github.io/vega-lite/docs/type.html) documentation."
},
"filter": {
"$ref": "#/definitions/TooltipFilter",
"description": "The tooltip filter object to control what values to show/hide in the tooltip, the operator should be one of '==', '!=', '<', '<=', '>', '>='. The literal (value to be compared with) should be one of type string, number or boolean."
}
},
"type": "object"
},
"StringFieldDefWithCondition": {
"$ref": "#/definitions/FieldOrDatumDefWithCondition<StringFieldDef,string>"
},
Expand Down
8 changes: 7 additions & 1 deletion src/channel.ts
Expand Up @@ -67,6 +67,8 @@ export const DETAIL = 'detail' as const;
export const KEY = 'key' as const;

export const TOOLTIP = 'tooltip' as const;

export const SORT_TOOLTIP = 'sort_tooltip' as const;
export const HREF = 'href' as const;

export const URL = 'url' as const;
Expand Down Expand Up @@ -154,7 +156,8 @@ const UNIT_CHANNEL_INDEX: Flag<Channel> = {
tooltip: 1,
href: 1,
url: 1,
description: 1
description: 1,
sort_tooltip: 1
};

export type ColorChannel = 'color' | 'fill' | 'stroke';
Expand Down Expand Up @@ -431,6 +434,7 @@ const {
// href has neither format, nor scale
text: _t,
tooltip: _tt,
sort_tooltip: _stt,
href: _hr,
url: _u,
description: _al,
Expand Down Expand Up @@ -532,6 +536,7 @@ function getSupportedMark(channel: ExtendedChannel): SupportedMark {
case DETAIL:
case KEY:
case TOOLTIP:
case SORT_TOOLTIP:
case HREF:
case ORDER: // TODO: revise (order might not support rect, which is not stackable?)
case OPACITY:
Expand Down Expand Up @@ -641,6 +646,7 @@ export function rangeType(channel: ExtendedChannel): RangeType {
// TEXT, TOOLTIP, URL, and HREF have no scale but have discrete output [falls through]
case TEXT:
case TOOLTIP:
case SORT_TOOLTIP:
case HREF:
case URL:
case DESCRIPTION:
Expand Down
41 changes: 31 additions & 10 deletions src/channeldef.ts
Expand Up @@ -33,6 +33,7 @@ import {
ROW,
SHAPE,
SIZE,
SORT_TOOLTIP,
STROKE,
STROKEDASH,
STROKEOPACITY,
Expand Down Expand Up @@ -130,9 +131,9 @@ export type ValueDefWithCondition<F extends FieldDef<any> | DatumDef<any>, V ext
* A field definition or one or more value definition(s) with a parameter predicate.
*/
condition?:
| Conditional<F>
| Conditional<ValueDef<V | ExprRef | SignalRef>>
| Conditional<ValueDef<V | ExprRef | SignalRef>>[];
| Conditional<F>
| Conditional<ValueDef<V | ExprRef | SignalRef>>
| Conditional<ValueDef<V | ExprRef | SignalRef>>[];
};

export type StringValueDefWithCondition<F extends Field, T extends Type = StandardType> = ValueDefWithCondition<
Expand Down Expand Up @@ -385,8 +386,8 @@ export interface DatumDef<
F extends Field = string,
V extends PrimitiveValue | DateTime | ExprRef | SignalRef = PrimitiveValue | DateTime | ExprRef | SignalRef
> extends Partial<TypeMixins<Type>>,
BandMixins,
TitleMixins {
BandMixins,
TitleMixins {
/**
* A constant value in data domain.
*/
Expand Down Expand Up @@ -642,7 +643,26 @@ export interface OrderFieldDef<F extends Field> extends FieldDefWithoutScale<F>

export type OrderValueDef = ConditionValueDefMixins<number> & NumericValueDef;

export interface StringFieldDef<F extends Field> extends FieldDefWithoutScale<F, StandardType>, FormatMixins {}
export interface StringFieldDef<F extends Field> extends FieldDefWithoutScale<F, StandardType>, FormatMixins { }

export interface TooltipFilter {
operator: "==" | "!=" | "<" | "<=" | ">" | ">=",
literal: String | Number | Boolean
};

export interface TooltipFieldDef<F extends Field> extends StringFieldDef<F> {
filter?: TooltipFilter
};

export interface TooltipSort {
value: "ascending" | "descending"
}

export function isTooltipFieldDef<F extends Field>(fieldDef: FieldDef<F>): fieldDef is TooltipFieldDef<F> {
return "filter" in fieldDef;
}



export type FieldDef<F extends Field, T extends Type = any> = SecondaryFieldDef<F> | TypedFieldDef<F, T>;
export type ChannelDef<F extends Field = string> = Encoding<F>[keyof Encoding<F>];
Expand Down Expand Up @@ -1087,10 +1107,10 @@ export function initFieldOrDatumDef(
const guideType = isPositionFieldOrDatumDef(fd)
? 'axis'
: isMarkPropFieldOrDatumDef(fd)
? 'legend'
: isFacetFieldDef(fd)
? 'header'
: null;
? 'legend'
: isFacetFieldDef(fd)
? 'header'
: null;
if (guideType && fd[guideType]) {
const {format, formatType, ...newGuide} = fd[guideType];
if (isCustomFormatType(formatType) && !config.customFormatTypes) {
Expand Down Expand Up @@ -1264,6 +1284,7 @@ export function channelCompatibility(
case DETAIL:
case KEY:
case TOOLTIP:
case SORT_TOOLTIP:
case HREF:
case URL:
case ANGLE:
Expand Down
24 changes: 19 additions & 5 deletions src/compile/mark/encode/tooltip.ts
Expand Up @@ -7,8 +7,11 @@ import {
getFormatMixins,
hasConditionalFieldDef,
isFieldDef,
isTooltipFieldDef,
isTypedFieldDef,
SecondaryFieldDef,
TooltipFilter,
TooltipSort,
TypedFieldDef,
vgField
} from '../../../channeldef';
Expand All @@ -27,7 +30,8 @@ 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)};
const sort_tooltip: TooltipSort | undefined = "sort_tooltip" in encoding ? encoding.sort_tooltip : undefined;
return {tooltip: tooltipRefForEncoding({tooltip: channelDef, sort_tooltip: sort_tooltip}, stack, config, opt)};
} else {
const datum = opt.reactiveGeom ? 'datum.datum' : 'datum';
return wrapCondition(model, channelDef, 'tooltip', cDef => {
Expand Down Expand Up @@ -72,19 +76,23 @@ export function tooltipData(
config: Config,
{reactiveGeom}: {reactiveGeom?: boolean} = {}
) {

const toSkip = {};
const expr = reactiveGeom ? 'datum.datum' : 'datum';
const tuples: {channel: Channel; key: string; value: string}[] = [];
if (encoding.sort_tooltip != undefined && (encoding.sort_tooltip.value == "ascending" || encoding.sort_tooltip.value == "descending")) {
tuples.push({channel: "sort_tooltip", key: "tooltip_sort_placeholder", value: (encoding.sort_tooltip.value == "ascending" ? "0" : "1")}); // Encode ascending as "0", descending as "1"
}

function add(fDef: TypedFieldDef<string> | SecondaryFieldDef<string>, 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
};
...fDef,
type: (encoding[mainChannel] as TypedFieldDef<any>).type // for secondary field def, copy type from main channel
};

const title = fieldDef.title || defaultTitle(fieldDef, config);
const key = array(title).join(', ');
Expand Down Expand Up @@ -123,6 +131,13 @@ export function tooltipData(

value ??= textRef(fieldDef, config, expr).signal;

// New feature: add filter for each tooltip field.
if (isTooltipFieldDef(fieldDef)) {
const filter: TooltipFilter = fieldDef.filter;
const comp_value = filter.literal;
value = `${value} ${filter.operator} ${comp_value} ? ${value} : ${expr + '[""]'}`; // Trigeer 'undefined' when evaluating the tooltip value such that it is filtered out by vega-tooltip.
}

tuples.push({channel, key, value});
}

Expand All @@ -140,7 +155,6 @@ export function tooltipData(
out[key] = value;
}
}

return out;
}

Expand Down