From 8c81c28b511dd80de9f3c5fb79ee1877296dca65 Mon Sep 17 00:00:00 2001 From: MiniPear Date: Mon, 27 Feb 2023 14:21:13 +0800 Subject: [PATCH] refactor(tooltip): enhance tooltip (#4691) * refactor(tooltip): enhance tooltip * feat(tooltip): add mark.tooltip --- .../tooltip/alphabet-interval-full/step0.html | 75 ++++++++ .../alphabet-interval-multi-field/step0.html | 75 ++++++++ .../tooltip/morley-box-channel/step0.html | 162 ++++++++++++++++++ __tests__/plots/tooltip/aapl-line.ts | 2 + .../plots/tooltip/alphabet-interval-full.ts | 27 +++ .../tooltip/alphabet-interval-multi-field.ts | 31 ++++ .../plots/tooltip/alphabet-interval-multi.ts | 2 +- .../plots/tooltip/alphabet-interval-object.ts | 8 +- .../plots/tooltip/alphabet-interval-title.ts | 2 + __tests__/plots/tooltip/index.ts | 3 + .../plots/tooltip/indices-line-chart-facet.ts | 2 + __tests__/plots/tooltip/indices-line-items.ts | 9 +- .../tooltip/indices-line-point-reverse.ts | 5 +- .../plots/tooltip/indices-line-reverse.ts | 2 +- __tests__/plots/tooltip/morley-box-channel.ts | 38 ++++ __tests__/plots/tooltip/morley-box.ts | 39 +++-- .../tooltip/stateages-interval-shared.ts | 1 - .../temperatures-line-point-discrete.ts | 7 +- __tests__/unit/api/mark.spec.ts | 3 + __tests__/unit/api/props.spec.ts | 23 +++ scripts/bench/package.json | 1 - site/docs/api/mark/polygon.zh.md | 1 - .../examples/general/.polygon/demo/treemap.ts | 6 +- .../examples/general/.polygon/demo/voronoi.ts | 1 - .../interaction/demo/tooltip-custom.ts | 23 +-- .../interaction/demo/tooltip-series.ts | 14 +- src/api/composition/base.ts | 2 - src/api/mark/mark.ts | 1 + src/api/mark/types.ts | 1 + src/api/props.ts | 23 ++- src/interaction/native/tooltip.ts | 78 ++------- src/mark/utils.ts | 6 +- src/runtime/mark.ts | 8 +- src/runtime/plot.ts | 12 +- src/runtime/transform.ts | 61 ++++++- src/runtime/types/common.ts | 1 + src/runtime/types/options.ts | 1 + src/spec/geometry.ts | 28 ++- src/transform/maybeTitle.ts | 14 +- src/transform/maybeTooltip.ts | 31 ++-- src/utils/helper.ts | 7 +- 41 files changed, 683 insertions(+), 153 deletions(-) create mode 100644 __tests__/integration/snapshots/tooltip/alphabet-interval-full/step0.html create mode 100644 __tests__/integration/snapshots/tooltip/alphabet-interval-multi-field/step0.html create mode 100644 __tests__/integration/snapshots/tooltip/morley-box-channel/step0.html create mode 100644 __tests__/plots/tooltip/alphabet-interval-full.ts create mode 100644 __tests__/plots/tooltip/alphabet-interval-multi-field.ts create mode 100644 __tests__/plots/tooltip/morley-box-channel.ts diff --git a/__tests__/integration/snapshots/tooltip/alphabet-interval-full/step0.html b/__tests__/integration/snapshots/tooltip/alphabet-interval-full/step0.html new file mode 100644 index 0000000000..1c9e7e687b --- /dev/null +++ b/__tests__/integration/snapshots/tooltip/alphabet-interval-full/step0.html @@ -0,0 +1,75 @@ +
+
+ steelblue +
+ +
; diff --git a/__tests__/integration/snapshots/tooltip/alphabet-interval-multi-field/step0.html b/__tests__/integration/snapshots/tooltip/alphabet-interval-multi-field/step0.html new file mode 100644 index 0000000000..c1f0ec9ac2 --- /dev/null +++ b/__tests__/integration/snapshots/tooltip/alphabet-interval-multi-field/step0.html @@ -0,0 +1,75 @@ +
+
+ A +
+ +
; diff --git a/__tests__/integration/snapshots/tooltip/morley-box-channel/step0.html b/__tests__/integration/snapshots/tooltip/morley-box-channel/step0.html new file mode 100644 index 0000000000..530fcba36f --- /dev/null +++ b/__tests__/integration/snapshots/tooltip/morley-box-channel/step0.html @@ -0,0 +1,162 @@ +
+
+ 1 +
+ +
; diff --git a/__tests__/plots/tooltip/aapl-line.ts b/__tests__/plots/tooltip/aapl-line.ts index 8c51323869..d22378eb6d 100644 --- a/__tests__/plots/tooltip/aapl-line.ts +++ b/__tests__/plots/tooltip/aapl-line.ts @@ -14,6 +14,8 @@ export function aaplLine(): G2Spec { encode: { x: 'date', y: 'close', + }, + tooltip: { title: (d) => new Date(d.date).toUTCString(), }, }, diff --git a/__tests__/plots/tooltip/alphabet-interval-full.ts b/__tests__/plots/tooltip/alphabet-interval-full.ts new file mode 100644 index 0000000000..3639ce25bb --- /dev/null +++ b/__tests__/plots/tooltip/alphabet-interval-full.ts @@ -0,0 +1,27 @@ +import { G2Spec } from '../../../src'; +import { tooltipSteps } from './utils'; + +export function alphabetIntervalFull(): G2Spec { + return { + type: 'interval', + padding: 0, + data: { + type: 'fetch', + value: 'data/alphabet.csv', + }, + axis: false, + legend: false, + encode: { + x: 'letter', + y: 'frequency', + color: 'steelblue', + }, + tooltip: { + title: { channel: 'color' }, + items: ['letter', 'frequency'], + }, + interaction: { tooltip: true }, + }; +} + +alphabetIntervalFull.steps = tooltipSteps(0); diff --git a/__tests__/plots/tooltip/alphabet-interval-multi-field.ts b/__tests__/plots/tooltip/alphabet-interval-multi-field.ts new file mode 100644 index 0000000000..a448b8e44b --- /dev/null +++ b/__tests__/plots/tooltip/alphabet-interval-multi-field.ts @@ -0,0 +1,31 @@ +import { G2Spec } from '../../../src'; +import { tooltipSteps } from './utils'; + +export function alphabetIntervalMultiField(): G2Spec { + return { + type: 'view', + children: [ + { + type: 'interval', + padding: 0, + data: { + type: 'fetch', + value: 'data/alphabet.csv', + }, + axis: false, + legend: false, + encode: { + x: 'letter', + y: 'frequency', + color: 'steelblue', + }, + tooltip: [{ field: 'letter' }, { field: 'frequency' }], + }, + ], + interaction: { + tooltip: true, + }, + }; +} + +alphabetIntervalMultiField.steps = tooltipSteps(0); diff --git a/__tests__/plots/tooltip/alphabet-interval-multi.ts b/__tests__/plots/tooltip/alphabet-interval-multi.ts index 4322c7b025..bbeb297ae8 100644 --- a/__tests__/plots/tooltip/alphabet-interval-multi.ts +++ b/__tests__/plots/tooltip/alphabet-interval-multi.ts @@ -18,8 +18,8 @@ export function alphabetIntervalMulti(): G2Spec { x: 'letter', y: 'frequency', color: 'steelblue', - tooltip: ['letter', 'frequency'], }, + tooltip: ['letter', 'frequency'], }, ], interaction: { diff --git a/__tests__/plots/tooltip/alphabet-interval-object.ts b/__tests__/plots/tooltip/alphabet-interval-object.ts index afc6a21901..be769d0e47 100644 --- a/__tests__/plots/tooltip/alphabet-interval-object.ts +++ b/__tests__/plots/tooltip/alphabet-interval-object.ts @@ -18,17 +18,19 @@ export function alphabetIntervalObject(): G2Spec { x: 'letter', y: 'frequency', color: 'steelblue', - tooltip: (d) => ({ + }, + tooltip: [ + (d) => ({ color: 'red', value: d.frequency, name: 'F', }), - tooltip1: (d) => ({ + (d) => ({ color: 'yellow', value: d.letter, name: 'L', }), - }, + ], }, ], interaction: { diff --git a/__tests__/plots/tooltip/alphabet-interval-title.ts b/__tests__/plots/tooltip/alphabet-interval-title.ts index d80b21cfd0..e2e69de11b 100644 --- a/__tests__/plots/tooltip/alphabet-interval-title.ts +++ b/__tests__/plots/tooltip/alphabet-interval-title.ts @@ -18,6 +18,8 @@ export function alphabetIntervalTitle(): G2Spec { x: 'letter', y: 'frequency', color: 'steelblue', + }, + tooltip: { title: 'frequency', }, }, diff --git a/__tests__/plots/tooltip/index.ts b/__tests__/plots/tooltip/index.ts index 0569ea1cfd..a96173190b 100644 --- a/__tests__/plots/tooltip/index.ts +++ b/__tests__/plots/tooltip/index.ts @@ -18,3 +18,6 @@ export { alphabetInterval1dMounted } from './alphabet-interval-1d-mounted'; export { indicesLineItems } from './indices-line-items'; export { flareTreemapPoptip } from './flare-treemap-poptip'; export { flareTreemapPoptipCustom } from './flare-treemap-poptip-custom'; +export { morleyBoxChannel } from './morley-box-channel'; +export { alphabetIntervalMultiField } from './alphabet-interval-multi-field'; +export { alphabetIntervalFull } from './alphabet-interval-full'; diff --git a/__tests__/plots/tooltip/indices-line-chart-facet.ts b/__tests__/plots/tooltip/indices-line-chart-facet.ts index 19298a5ac6..26eeeb21f4 100644 --- a/__tests__/plots/tooltip/indices-line-chart-facet.ts +++ b/__tests__/plots/tooltip/indices-line-chart-facet.ts @@ -26,6 +26,8 @@ export async function indicesLineChartFacet(): Promise { y: 'Close', color: 'Symbol', key: 'Symbol', + }, + tooltip: { title: (d) => new Date(d.Date).toUTCString(), }, }, diff --git a/__tests__/plots/tooltip/indices-line-items.ts b/__tests__/plots/tooltip/indices-line-items.ts index fcfc2bfa2a..6bb10a6cb3 100644 --- a/__tests__/plots/tooltip/indices-line-items.ts +++ b/__tests__/plots/tooltip/indices-line-items.ts @@ -23,14 +23,17 @@ export async function indicesLineItems(): Promise { y: 'Close', color: 'Symbol', key: 'Symbol', + }, + tooltip: { title: (d) => new Date(d.Date).toUTCString(), + items: [ + (d, i, D, V) => ({ name: 'Close', value: V.y.value[i].toFixed(1) }), + ], }, }, ], interaction: { - tooltip: { - item: ({ value }) => ({ value: value.toFixed(1) }), - }, + tooltip: true, }, }; } diff --git a/__tests__/plots/tooltip/indices-line-point-reverse.ts b/__tests__/plots/tooltip/indices-line-point-reverse.ts index a6b1eb1e0f..9ef97a9ebd 100644 --- a/__tests__/plots/tooltip/indices-line-point-reverse.ts +++ b/__tests__/plots/tooltip/indices-line-point-reverse.ts @@ -29,9 +29,8 @@ export async function indicesLinePointReverse(): Promise { y: 'Close', color: 'Symbol', key: 'Symbol', - title: null, - tooltip: null, }, + tooltip: null, }, { type: 'point', @@ -40,8 +39,8 @@ export async function indicesLinePointReverse(): Promise { y: 'Close', color: 'Symbol', key: 'Symbol', - tooltip: (d) => new Date(d.Date).toUTCString(), }, + tooltip: (d) => new Date(d.Date).toUTCString(), }, ], interaction: { diff --git a/__tests__/plots/tooltip/indices-line-reverse.ts b/__tests__/plots/tooltip/indices-line-reverse.ts index 4e79c4ff2e..f816fbf088 100644 --- a/__tests__/plots/tooltip/indices-line-reverse.ts +++ b/__tests__/plots/tooltip/indices-line-reverse.ts @@ -22,8 +22,8 @@ export async function indicesLineReverse(): Promise { y: 'Close', color: 'Symbol', key: 'Symbol', - title: (d) => new Date(d.Date).toUTCString(), }, + tooltip: { title: (d) => new Date(d.Date).toUTCString() }, }, ], interaction: { diff --git a/__tests__/plots/tooltip/morley-box-channel.ts b/__tests__/plots/tooltip/morley-box-channel.ts new file mode 100644 index 0000000000..ec9802c0f0 --- /dev/null +++ b/__tests__/plots/tooltip/morley-box-channel.ts @@ -0,0 +1,38 @@ +import { G2Spec } from '../../../src'; +import { tooltipSteps } from './utils'; + +export function morleyBoxChannel(): G2Spec { + return { + type: 'view', + children: [ + { + type: 'boxplot', + inset: 6, + data: { + type: 'fetch', + value: 'data/morley.csv', + }, + encode: { + x: 'Expt', + y: 'Speed', + }, + style: { + boxFill: '#aaa', + pointStroke: '#000', + }, + tooltip: [ + { name: 'min', channel: 'y' }, + { name: 'q1', channel: 'y1' }, + { name: 'q2', channel: 'y2' }, + { name: 'q3', channel: 'y3' }, + { name: 'max', color: 'red', channel: 'y4' }, + ], + }, + ], + interaction: { + tooltip: true, + }, + }; +} + +morleyBoxChannel.steps = tooltipSteps(0); diff --git a/__tests__/plots/tooltip/morley-box.ts b/__tests__/plots/tooltip/morley-box.ts index ffd4fa07e8..7daf8a0ec0 100644 --- a/__tests__/plots/tooltip/morley-box.ts +++ b/__tests__/plots/tooltip/morley-box.ts @@ -2,13 +2,7 @@ import { G2Spec } from '../../../src'; import { tooltipSteps } from './utils'; export function morleyBox(): G2Spec { - const names = { - tooltip: 'min', - tooltip1: 'q1', - tooltip2: 'q2', - tooltip3: 'q3', - tooltip4: 'max', - }; + const format = (d) => `${d / 1000}k`; return { type: 'view', children: [ @@ -27,16 +21,33 @@ export function morleyBox(): G2Spec { boxFill: '#aaa', pointStroke: '#000', }, + tooltip: [ + (d, i, D, V) => ({ + name: 'min', + value: format(V.y.value[i]), + }), + (d, i, D, V) => ({ + name: 'q1', + value: format(V.y1.value[i]), + }), + (d, i, D, V) => ({ + name: 'q2', + value: format(V.y2.value[i]), + }), + (d, i, D, V) => ({ + name: 'q3', + value: format(V.y3.value[i]), + }), + (d, i, D, V) => ({ + name: 'max', + color: 'red', + value: format(V.y4.value[i]), + }), + ], }, ], interaction: { - tooltip: { - item: ({ channel, value }) => ({ - name: names[channel], - color: channel === 'tooltip4' ? 'red' : undefined, - value: `${value / 1000}k`, - }), - }, + tooltip: true, }, }; } diff --git a/__tests__/plots/tooltip/stateages-interval-shared.ts b/__tests__/plots/tooltip/stateages-interval-shared.ts index e0e3097b38..cf9076c836 100644 --- a/__tests__/plots/tooltip/stateages-interval-shared.ts +++ b/__tests__/plots/tooltip/stateages-interval-shared.ts @@ -16,7 +16,6 @@ export function stateAgesIntervalShared(): G2Spec { type: 'fetch', value: 'data/stateages.csv', }, - axis: false, legend: false, encode: { x: 'state', diff --git a/__tests__/plots/tooltip/temperatures-line-point-discrete.ts b/__tests__/plots/tooltip/temperatures-line-point-discrete.ts index 5f74e98250..3d8da1f807 100644 --- a/__tests__/plots/tooltip/temperatures-line-point-discrete.ts +++ b/__tests__/plots/tooltip/temperatures-line-point-discrete.ts @@ -21,12 +21,9 @@ export function temperaturesLinePointDiscrete(): G2Spec { x: 'month', y: 'temperature', color: 'city', - tooltip: null, - title: null, - }, - style: { - fill: 'white', }, + tooltip: null, + style: { fill: 'white' }, }, ], interaction: { diff --git a/__tests__/unit/api/mark.spec.ts b/__tests__/unit/api/mark.spec.ts index e574cbb68f..b24a33f876 100644 --- a/__tests__/unit/api/mark.spec.ts +++ b/__tests__/unit/api/mark.spec.ts @@ -54,6 +54,8 @@ function setOptions(node) { .attr('insetRight', 40) .axis('x', { tickCount: 10 }) .legend('y', { title: 'hello' }) + .tooltip('a') + .tooltip('b') .slider('x', {}) .scrollbar('x', {}) .label({ text: 'hello' }) @@ -107,6 +109,7 @@ function getOptions() { active: { fill: 'red' }, inactive: { fill: 'blue' }, }, + tooltip: { items: ['a', 'b'] }, }; } diff --git a/__tests__/unit/api/props.spec.ts b/__tests__/unit/api/props.spec.ts index f1e7271117..991faca017 100644 --- a/__tests__/unit/api/props.spec.ts +++ b/__tests__/unit/api/props.spec.ts @@ -96,4 +96,27 @@ describe('defineProps', () => { expect(n1).toBeInstanceOf(Node); expect(n.type).toBeNull(); }); + + it('definedProps([...]) should define mix prop', () => { + const N = defineProps([{ type: 'mix', name: 'mix' }])(Node); + const n = new N(); + n.mix('a'); + n.mix('b'); + expect(n.mix()).toEqual({ items: ['a', 'b'] }); + n.mix(['c', 'd']); + expect(n.mix()).toEqual({ items: ['c', 'd'] }); + n.mix({ items: ['f', 'g'] }); + expect(n.mix()).toEqual({ + items: ['f', 'g'], + }); + n.mix({ title: 'hello' }); + expect(n.mix()).toEqual({ + title: 'hello', + }); + n.mix({ name: 'min', channel: 'y' }); + expect(n.mix()).toEqual({ + title: 'hello', + items: [{ name: 'min', channel: 'y' }], + }); + }); }); diff --git a/scripts/bench/package.json b/scripts/bench/package.json index 447531abf9..c6f9e0c32f 100644 --- a/scripts/bench/package.json +++ b/scripts/bench/package.json @@ -8,7 +8,6 @@ "@antv/g-canvas": "^1.9.8", "@antv/g-old": "npm:@antv/g@3.4.10", "@antv/g-svg": "^1.8.16", - "@antv/g": "^5.11.0", "@antv/g2": "^4.2.8", "@antv/util": "^3.3.2", "@observablehq/plot": "^0.6.0", diff --git a/site/docs/api/mark/polygon.zh.md b/site/docs/api/mark/polygon.zh.md index ff6c773551..749c9562ec 100644 --- a/site/docs/api/mark/polygon.zh.md +++ b/site/docs/api/mark/polygon.zh.md @@ -58,7 +58,6 @@ chart .scale('x', { domain: [0, 800] }) .scale('y', { domain: [0, 600] }) .axis(false) - .scale('tooltip', { field: 'value' }) .style('stroke', '#fff') .style('fillOpacity', 0.65); diff --git a/site/examples/general/.polygon/demo/treemap.ts b/site/examples/general/.polygon/demo/treemap.ts index 58f3bb4c91..bd418c4172 100644 --- a/site/examples/general/.polygon/demo/treemap.ts +++ b/site/examples/general/.polygon/demo/treemap.ts @@ -41,8 +41,10 @@ chart .encode('y', 'y') .encode('size', 'r') .encode('color', (d) => d.parent.data.name) - .encode('tooltip', (d) => d.parent.data.name) - .encode('title', '') + .tooltip({ + title: '', + items: [(d) => d.parent.data.name], + }) .scale('x', { domain: [0, 1] }) .scale('y', { domain: [0, 1], range: [0, 1] }) .scale('size', { type: 'identity' }) diff --git a/site/examples/general/.polygon/demo/voronoi.ts b/site/examples/general/.polygon/demo/voronoi.ts index 74182b2565..f9c42e0fa7 100644 --- a/site/examples/general/.polygon/demo/voronoi.ts +++ b/site/examples/general/.polygon/demo/voronoi.ts @@ -45,7 +45,6 @@ chart .encode('color', (d) => d.data.value) .scale('x', { domain: [0, 800] }) .scale('y', { domain: [0, 600] }) - .scale('tooltip', { field: 'value' }) .axis(false) .style('stroke', '#fff') .style('fillOpacity', 0.65); diff --git a/site/examples/interaction/interaction/demo/tooltip-custom.ts b/site/examples/interaction/interaction/demo/tooltip-custom.ts index 2d9d2b957e..4f25488b10 100644 --- a/site/examples/interaction/interaction/demo/tooltip-custom.ts +++ b/site/examples/interaction/interaction/demo/tooltip-custom.ts @@ -6,14 +6,6 @@ const chart = new Chart({ inset: 6, }); -const names = { - tooltip: 'min', - tooltip1: 'q1', - tooltip2: 'q2', - tooltip3: 'q3', - tooltip4: 'max', -}; - chart .boxplot() .data({ @@ -21,14 +13,13 @@ chart value: 'https://assets.antv.antgroup.com/g2/morley.json', }) .encode('x', 'Expt') - .encode('y', 'Speed'); + .encode('y', 'Speed') + .tooltip({ name: 'min', channel: 'y' }) + .tooltip({ name: 'q1', channel: 'y1' }) + .tooltip({ name: 'q2', channel: 'y2' }) + .tooltip({ name: 'q3', channel: 'y3' }) + .tooltip({ name: 'max', color: 'red', channel: 'y4' }); -chart.interaction('tooltip', { - item: ({ channel, value }) => ({ - name: names[channel], - color: channel === 'tooltip4' ? 'red' : undefined, - value: `${value / 1000}k`, - }), -}); +chart.interaction('tooltip', true); chart.render(); diff --git a/site/examples/interaction/interaction/demo/tooltip-series.ts b/site/examples/interaction/interaction/demo/tooltip-series.ts index 12cd151eec..d7df2fda0e 100644 --- a/site/examples/interaction/interaction/demo/tooltip-series.ts +++ b/site/examples/interaction/interaction/demo/tooltip-series.ts @@ -15,8 +15,16 @@ chart .encode('x', (d) => new Date(d.Date)) .encode('y', 'Close') .encode('color', 'Symbol') - .encode('title', (d) => d.Date.toLocaleString()) .axis('y', { title: '↑ Change in price (%)' }) + .tooltip({ + title: (d) => new Date(d.Date).toUTCString(), + items: [ + (d, i, data, column) => ({ + name: 'Close', + value: column.y.value[i].toFixed(1), + }), + ], + }) .label({ text: 'Symbol', selector: 'last', @@ -25,8 +33,6 @@ chart }, }); -chart.interaction('tooltip', { - item: ({ value }) => ({ value: value.toFixed(1) }), -}); +chart.interaction('tooltip', true); chart.render(); diff --git a/src/api/composition/base.ts b/src/api/composition/base.ts index a226dd5567..412351470a 100644 --- a/src/api/composition/base.ts +++ b/src/api/composition/base.ts @@ -1,10 +1,8 @@ import { DisplayObject } from '@antv/g'; import { Coordinate } from '@antv/coord'; -import { defineProps } from '../props'; import { Node } from '../node'; import { G2Theme, G2ViewDescriptor } from '../../runtime'; -@defineProps([{ name: 'on', type: 'event' }]) export class CompositionNode< Value extends Record = Record, ParentValue extends Record = Record, diff --git a/src/api/mark/mark.ts b/src/api/mark/mark.ts index 83f8dad939..416f2e51bc 100644 --- a/src/api/mark/mark.ts +++ b/src/api/mark/mark.ts @@ -149,6 +149,7 @@ export const props: NodePropertyDescriptor[] = [ { name: 'slider', type: 'object' }, { name: 'scrollbar', type: 'object' }, { name: 'state', type: 'object' }, + { name: 'tooltip', type: 'mix' }, ]; @defineProps(props) diff --git a/src/api/mark/types.ts b/src/api/mark/types.ts index 1dbf78bd3e..8998e58cb7 100644 --- a/src/api/mark/types.ts +++ b/src/api/mark/types.ts @@ -17,4 +17,5 @@ export type API = { scrollbar: ObjectAttribute; legend: ObjectAttribute; layout: ValueAttribute; + tooltip: ValueAttribute; }; diff --git a/src/api/props.ts b/src/api/props.ts index 834fcbe5a8..caf90754b9 100644 --- a/src/api/props.ts +++ b/src/api/props.ts @@ -1,5 +1,7 @@ +import { isStrictObject } from '../utils/helper'; + export type NodePropertyDescriptor = { - type: 'object' | 'value' | 'array' | 'node' | 'container' | 'event'; + type: 'object' | 'value' | 'array' | 'node' | 'container' | 'mix'; name: string; key?: string; ctor?: new (...args: any[]) => any; @@ -36,6 +38,24 @@ function defineObjectProp( }; } +function defineMixProp(Node, { name }: NodePropertyDescriptor) { + Node.prototype[name] = function (key) { + if (arguments.length === 0) return this.attr(name); + if (Array.isArray(key)) return this.attr(name, { items: key }); + if ( + isStrictObject(key) && + (key.title !== undefined || key.items !== undefined) + ) { + return this.attr(name, key); + } + const obj = this.attr(name) || {}; + const { items = [] } = obj; + items.push(key); + obj.items = items; + return this.attr(name, obj); + }; +} + function defineNodeProp(Node, { name, ctor }: NodePropertyDescriptor) { Node.prototype[name] = function () { return this.append(ctor); @@ -62,6 +82,7 @@ export function defineProps(descriptors: NodePropertyDescriptor[]) { else if (type === 'object') defineObjectProp(Node, descriptor); else if (type === 'node') defineNodeProp(Node, descriptor); else if (type === 'container') defineContainerProp(Node, descriptor); + else if (type === 'mix') defineMixProp(Node, descriptor); } return Node; }; diff --git a/src/interaction/native/tooltip.ts b/src/interaction/native/tooltip.ts index 36e14e3d6a..d23af2b233 100644 --- a/src/interaction/native/tooltip.ts +++ b/src/interaction/native/tooltip.ts @@ -86,33 +86,16 @@ function filterDefined(obj) { ); } -function singleItem(element, item, scale) { +function singleItem(element) { const { __data__: datum } = element; - const { title, ...rest } = datum; - const defaultColor = itemColorOf(element); - const items = Object.entries(rest) - .filter(([key]) => key.startsWith('tooltip')) - .map(([key, d]: any) => { - const { field: f, title = f } = scale[key].getOptions(); - const { - field = undefined, - color = defaultColor, - name = field || title, - value, - ...rest - } = normalizeTooltip(d); - return { - ...rest, - color, - name, - value, - ...filterDefined(item({ channel: key, value })), - }; - }) - .filter(({ value }) => value !== undefined); + const { title, items = [] } = datum; + const newItems = items.map(({ color = itemColorOf(element), ...item }) => ({ + ...item, + color, + })); return { ...(title && { title }), - items, + items: newItems, }; } @@ -157,39 +140,22 @@ function uniqueTitles(titles) { function groupItems( elements, - item, scale, data = elements.map((d) => d['__data__']), ) { const T = uniqueTitles(data.map((d) => d.title)).filter(defined); - const items = data.flatMap((datum, i) => { + const newItems = data.flatMap((datum, i) => { const element = elements[i]; - const { title, ...rest } = datum; - const defaultColor = itemColorOf(element); - return Object.entries(rest) - .filter(([key]) => key.startsWith('tooltip')) - .map(([key, d]: any) => { - const { field: f, title = f } = scale[key].getOptions(); - const { - field = undefined, - color = defaultColor, - name = groupNameOf(scale, datum) || field || title, - value, - ...rest - } = normalizeTooltip(d); - return { - ...rest, - name, - color, - value, - ...filterDefined(item({ channel: key, value })), - }; - }) - .filter(({ value }) => defined(value)); + const { items = [], title } = datum; + return items.map(({ color = itemColorOf(element), name, ...item }) => ({ + ...item, + color, + name: groupNameOf(scale, datum) || name || title, + })); }); return { ...(T.length > 0 && { title: T.join(',') }), - items, + items: newItems, }; } @@ -335,7 +301,7 @@ export function seriesTooltip( const { __data__: data } = element; return Object.fromEntries( Object.entries(data) - .filter(([key]) => key.startsWith('series')) + .filter(([key]) => key.startsWith('series') && key !== 'series') .map(([key, V]) => { const d = V[index]; return [lowerFirst(key.replace('series', '')), d]; @@ -371,12 +337,7 @@ export function seriesTooltip( // Get the displayed tooltip data. const selectedElements = [...seriesElements, ...selectedItems]; - const tooltipData = groupItems( - selectedElements, - item, - scale, - selectedData, - ); + const tooltipData = groupItems(selectedElements, scale, selectedData); showTooltip(root, tooltipData, mouse[0] + x, mouse[1] + y); if (crosshairs) { @@ -418,7 +379,6 @@ export function tooltip( leading = true, trailing = false, groupKey = (d) => d, // group elements by specified key - item, }: Record, ) { const elements = elementsof(root); @@ -432,9 +392,7 @@ export function tooltip( const k = groupKey(element); const group = keyGroup.get(k); const data = - group.length === 1 - ? singleItem(group[0], item, scale) - : groupItems(group, item, scale); + group.length === 1 ? singleItem(group[0]) : groupItems(group, scale); const { offsetX, offsetY } = event; showTooltip(root, data, offsetX, offsetY); }, diff --git a/src/mark/utils.ts b/src/mark/utils.ts index e938eb4b35..40a59eb605 100644 --- a/src/mark/utils.ts +++ b/src/mark/utils.ts @@ -26,11 +26,7 @@ export function baseChannels(options: ChannelOptions = {}): Channel[] { } export function baseGeometryChannels(options: ChannelOptions = {}): Channel[] { - return [ - ...baseChannels(options), - { name: 'title', scale: 'identity' }, - { name: 'tooltip', scale: 'identity', independent: true }, - ]; + return [...baseChannels(options), { name: 'title', scale: 'identity' }]; } export function tooltip2d() { diff --git a/src/runtime/mark.ts b/src/runtime/mark.ts index d2fa5ee611..cd50c3c834 100644 --- a/src/runtime/mark.ts +++ b/src/runtime/mark.ts @@ -26,6 +26,8 @@ import { maybeVisualChannel, addGuideToScale, maybeNonAnimate, + normalizeTooltip, + extractTooltip, } from './transform'; export async function initializeMark( @@ -41,7 +43,7 @@ export async function initializeMark( context, ); - const { encode, scale, data } = transformedMark; + const { encode, scale, data, tooltip } = transformedMark; // Skip mark with non-tabular data. Do not skip empty // data, they are useful for facet to display axes. @@ -109,7 +111,7 @@ export async function initializeMark( }); }); - return [transformedMark, { ...partialProps, index: I, channels }]; + return [transformedMark, { ...partialProps, index: I, channels, tooltip }]; } export function createColumnOf(library: G2Library): ColumnOf { @@ -152,9 +154,11 @@ async function applyMarkTransform( maybeArrayField, maybeNonAnimate, addGuideToScale, + normalizeTooltip, ...preInference.map(useTransform), ...transform.map(useTransform), ...postInference.map(useTransform), + extractTooltip, ]; let index = []; let transformedMark = mark; diff --git a/src/runtime/plot.ts b/src/runtime/plot.ts index 14aa416346..1fd7036489 100644 --- a/src/runtime/plot.ts +++ b/src/runtime/plot.ts @@ -520,7 +520,7 @@ function initializeState( modifier, key: markKey, } = mark; - const { index, channels } = state; + const { index, channels, tooltip } = state; const scale = Object.fromEntries( channels.map(({ name, scale }) => [name, scale]), ); @@ -541,6 +541,8 @@ function initializeState( ); const count = dataDomain || I.length; const T = modifier ? modifier(P, count, layout) : []; + const titleOf = (i) => tooltip.title?.[i]?.value; + const itemsOf = (i) => tooltip.items.map((V) => V[i]); const visualData: Record[] = I.map((d, i) => { const datum = { points: P[i], @@ -548,12 +550,20 @@ function initializeState( index: d, markKey, viewKey: key, + ...(tooltip && { + title: titleOf(d), + items: itemsOf(d), + }), }; for (const [k, V] of Object.entries(value)) { datum[k] = V[d]; if (S) datum[`series${upperFirst(k)}`] = S[i].map((i) => V[i]); } if (S) datum['seriesIndex'] = S[i]; + if (S && tooltip) { + datum['seriesItems'] = S[i].map((si) => itemsOf(si)); + datum['seriesTitle'] = S[i].map((si) => titleOf(si)); + } return datum; }); state.data = visualData; diff --git a/src/runtime/transform.ts b/src/runtime/transform.ts index 4df1c0d068..835543cf25 100644 --- a/src/runtime/transform.ts +++ b/src/runtime/transform.ts @@ -1,7 +1,7 @@ import { Primitive } from 'd3-array'; import { deepMix } from '@antv/util'; import { indexOf, mapObject } from '../utils/array'; -import { composeAsync, defined } from '../utils/helper'; +import { composeAsync, defined, isStrictObject } from '../utils/helper'; import { useLibrary } from './library'; import { createColumnOf } from './mark'; import { Data, DataComponent } from './types/data'; @@ -105,6 +105,65 @@ export function extractColumns( return [I, { ...mark, encode: valuedEncode }]; } +/** + * Normalize mark.tooltip to {title, items}. + */ +export function normalizeTooltip( + I: number[], + mark: G2Mark, + context: TransformContext, +): [number[], G2Mark] { + const { tooltip = {} } = mark; + if (tooltip === null) return [I, mark]; + if (Array.isArray(tooltip)) { + return [I, { ...mark, tooltip: { items: tooltip } }]; + } + if (isStrictObject(tooltip)) return [I, { ...mark, tooltip }]; + return [I, { ...mark, tooltip: { items: [tooltip] } }]; +} + +export function extractTooltip( + I: number[], + mark: G2Mark, + context: TransformContext, +): [number[], G2Mark] { + const { data, encode, tooltip = {} } = mark; + if (tooltip === null) return [I, mark]; + const valueOf = (item) => { + if (!item) return item; + if (typeof item === 'string') { + return I.map((i) => ({ name: item, value: data[i][item] })); + } + if (isStrictObject(item)) { + const { field, channel, color, name = field } = item; + return I.map((i) => ({ + name, + color, + value: field + ? data[i][field] + : channel + ? encode[channel].value[i] + : null, + })); + } + if (typeof item === 'function') { + return I.map((i) => { + const v = item(data[i], i, data, encode); + if (isStrictObject(v)) return v; + return { value: v }; + }); + } + return item; + }; + const { title, items = [], ...rest } = tooltip; + const newTooltip = { + title: valueOf(title), + items: items.map(valueOf), + ...rest, + }; + return [I, { ...mark, tooltip: newTooltip }]; +} + export function maybeArrayField( I: number[], mark: G2Mark, diff --git a/src/runtime/types/common.ts b/src/runtime/types/common.ts index bf28086a4f..ccc72aa16e 100644 --- a/src/runtime/types/common.ts +++ b/src/runtime/types/common.ts @@ -68,6 +68,7 @@ export type G2MarkState = { index?: number[]; data?: Record[]; channels?: ChannelGroups[]; + tooltip?: any; // @todo } & Omit; export type MaybeArray = T | T[]; diff --git a/src/runtime/types/options.ts b/src/runtime/types/options.ts index 7b7079d213..6098a80acb 100644 --- a/src/runtime/types/options.ts +++ b/src/runtime/types/options.ts @@ -116,6 +116,7 @@ export type G2Mark = { legend?: boolean | Record; slider?: Record; scrollbar?: Record; + tooltip?: any; // @todo filter?: (i: number) => boolean; children?: G2MarkChildrenCallback; dataDomain?: number; diff --git a/src/spec/geometry.ts b/src/spec/geometry.ts index 46d08e5bc6..328e5ec8a3 100644 --- a/src/spec/geometry.ts +++ b/src/spec/geometry.ts @@ -1,4 +1,4 @@ -import { MarkComponent } from '../runtime'; +import { MarkComponent, Primitive } from '../runtime'; import { Encode } from './encode'; import { Transform } from './transform'; import { Scale } from './scale'; @@ -93,8 +93,7 @@ export type ChannelTypes = | 'groupKey' | 'label' | 'position' - | 'series' - | `tooltip${number}`; + | 'series'; export type BaseGeometry< T extends GeometryTypes, @@ -149,6 +148,7 @@ export type BaseGeometry< frame?: boolean; labels?: Record[]; stack?: boolean; + tooltip?: Tooltip; animate?: | boolean | { @@ -309,3 +309,25 @@ export type WordCloudMark = BaseGeometry< }; export type CustomComponent = BaseGeometry; + +export type Tooltip = + | TooltipItem + | TooltipItem[] + | { title?: Encodeable; items?: TooltipItem[] } + | null; + +export type TooltipTitle = string | { field?: string; channel?: string }; + +export type TooltipItem = + | string + | { name?: string; color?: string; channel?: string; field?: string } + | Encodeable + | Encodeable<{ + name?: string; + color?: string; + value?: Primitive; + }>; + +export type Encodeable = + | T + | ((d: any, index: number, data: any[], column: any) => T); diff --git a/src/transform/maybeTitle.ts b/src/transform/maybeTitle.ts index 6fc142f460..065bc8eaf2 100644 --- a/src/transform/maybeTitle.ts +++ b/src/transform/maybeTitle.ts @@ -1,6 +1,6 @@ import { deepMix } from '@antv/util'; import { TransformComponent as TC } from '../runtime'; -import { column, columnOf } from './utils/helper'; +import { columnOf } from './utils/helper'; export type MaybeTitleOptions = { channel?: string; @@ -13,10 +13,18 @@ export const MaybeTitle: TC = (options = {}) => { const { channel = 'x' } = options; return (I, mark) => { const { encode } = mark; - const { title } = encode; + const { tooltip } = mark; + if (tooltip === null) return [I, mark]; + const { title } = tooltip; if (title !== undefined) return [I, mark]; const [T, ft] = columnOf(encode, channel); - return [I, deepMix({}, mark, { encode: { title: column(T, ft) } })]; + if (!T) return [I, mark]; + return [ + I, + deepMix({}, mark, { + tooltip: { title: T.map((d) => ({ value: d, name: ft })) }, + }), + ]; }; }; diff --git a/src/transform/maybeTooltip.ts b/src/transform/maybeTooltip.ts index f1180bce5e..a71090f485 100644 --- a/src/transform/maybeTooltip.ts +++ b/src/transform/maybeTooltip.ts @@ -12,28 +12,23 @@ export type MaybeTooltipOptions = { export const MaybeTooltip: TC = (options) => { const { channel } = options; return (I, mark) => { - const { encode } = mark; - const { tooltip } = encode; - if (tooltip !== undefined) return [I, mark]; + const { encode, tooltip } = mark; + if (tooltip === null) return [I, mark]; + const { items = [] } = tooltip; + if (items.length > 0) return [I, mark]; const channels = Array.isArray(channel) ? channel : [channel]; - let index = 0; - const entries = channels.flatMap((channel) => - Object.entries(encode) - .filter(([key]) => key.startsWith(channel)) - .flatMap(([key]) => { + const newItems = channels.flatMap((channel) => + Object.keys(encode) + .filter((key) => key.startsWith(channel)) + .map((key) => { const [V, fv] = columnOf(encode, key); - const E = [[key, column(V)]]; // Only show channel with field. - if (V && fv !== null) { - const T = V.map((v) => ({ value: v, field: fv })); - //@ts-ignore - E.push([`tooltip${index === 0 ? '' : index}`, column(T, fv)]); - index++; - } - return E; - }), + if (V && fv !== null) return V.map((v) => ({ value: v, name: fv })); + return null; + }) + .filter((d) => d !== null), ); - return [I, deepMix({}, mark, { encode: Object.fromEntries(entries) })]; + return [I, deepMix({}, mark, { tooltip: { items: newItems } })]; }; }; diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 50c52d47de..aed5a27801 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -112,5 +112,10 @@ export function maybePercentage(x: number | string, size: number) { } export function isStrictObject(d: any): boolean { - return typeof d === 'object' && !(d instanceof Date) && d !== null; + return ( + typeof d === 'object' && + !(d instanceof Date) && + d !== null && + !Array.isArray(d) + ); }