diff --git a/__tests__/integration/api-chart-emit-element-highlight.spec.ts b/__tests__/integration/api-chart-emit-element-highlight.spec.ts new file mode 100644 index 0000000000..98ba3bffbb --- /dev/null +++ b/__tests__/integration/api-chart-emit-element-highlight.spec.ts @@ -0,0 +1,75 @@ +import { chartEmitElementHighlight as render } from '../plots/api/chart-emit-element-highlight'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import { createPromise, dispatchFirstElementEvent } from './utils/event'; +import './utils/useSnapshotMatchers'; +import './utils/useCustomFetch'; + +describe('chart.emit', () => { + const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; + const canvas = createNodeGCanvas(800, 500); + + it('chart.on("element:highlight") should receive expected data.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('element:highlight', options) should trigger slider. + chart.emit('element:highlight', { + data: { data: { population: 5038433 } }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('element:unhighlight', options) should reset. + chart.emit('element:unhighlight', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + // chart.on("element:highlight") should receive expected data. + const [highlight, resolveHighlight] = createPromise(); + chart.on('element:highlight', (event) => { + if (!event.nativeEvent) return; + expect(event.data.data).toEqual({ + age: '<10', + population: 5038433, + state: 'CA', + }); + expect(event.data.group).toEqual([ + { age: '<10', population: 5038433, state: 'CA' }, + { age: '10-19', population: 5170341, state: 'CA' }, + { age: '20-29', population: 5809455, state: 'CA' }, + { age: '30-39', population: 5354112, state: 'CA' }, + { age: '40-49', population: 5179258, state: 'CA' }, + { age: '50-59', population: 5042094, state: 'CA' }, + { age: '60-69', population: 3737461, state: 'CA' }, + { age: '70-79', population: 2011678, state: 'CA' }, + { age: '≥80', population: 1311374, state: 'CA' }, + ]); + resolveHighlight(); + }); + dispatchFirstElementEvent(canvas, 'pointerover'); + await sleep(20); + await highlight; + + // chart.on("element:unhighlight") should be called. + const [unhighlight, resolveUnhighlight] = createPromise(); + chart.on('element:unhighlight', (event) => { + if (!event.nativeEvent) return; + resolveUnhighlight(); + }); + dispatchFirstElementEvent(canvas, 'pointerout'); + await sleep(20); + await unhighlight; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/api-chart-emit-slider-filter.spec.ts b/__tests__/integration/api-chart-emit-slider-filter.spec.ts index 58ccb6c192..bfe664d46e 100644 --- a/__tests__/integration/api-chart-emit-slider-filter.spec.ts +++ b/__tests__/integration/api-chart-emit-slider-filter.spec.ts @@ -1,11 +1,11 @@ import { chartEmitSliderFilter as render } from '../plots/api/chart-emit-slider-filter'; +import { SLIDER_CLASS_NAME } from '../../src/interaction/sliderFilter'; +import { dispatchValueChange } from '../plots/tooltip/appl-line-slider-filter'; import { createNodeGCanvas } from './utils/createNodeGCanvas'; import { sleep } from './utils/sleep'; import { kebabCase } from './utils/kebabCase'; -import './utils/useSnapshotMatchers'; -import { SLIDER_CLASS_NAME } from '../../src/interaction/sliderFilter'; -import { dispatchValueChange } from '../plots/tooltip/appl-line-slider-filter'; import { createPromise } from './utils/event'; +import './utils/useSnapshotMatchers'; describe('chart.emit', () => { const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; @@ -28,7 +28,7 @@ describe('chart.emit', () => { chart.emit('sliderX:filter', { data: { selection: [X, undefined] }, }); - await sleep(100); + await sleep(20); await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); // chart.emit('sliderY:filter', options) should trigger slider. @@ -36,7 +36,7 @@ describe('chart.emit', () => { chart.emit('sliderY:filter', { data: { selection: [undefined, Y] }, }); - await sleep(100); + await sleep(20); await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); chart.off(); diff --git a/__tests__/integration/snapshots/api/chart-emit-element-highlight/step0.png b/__tests__/integration/snapshots/api/chart-emit-element-highlight/step0.png new file mode 100644 index 0000000000..af0a1d0a60 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-element-highlight/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-element-highlight/step1.png b/__tests__/integration/snapshots/api/chart-emit-element-highlight/step1.png new file mode 100644 index 0000000000..8836cdd67d Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-element-highlight/step1.png differ diff --git a/__tests__/plots/api/chart-emit-element-highlight.ts b/__tests__/plots/api/chart-emit-element-highlight.ts new file mode 100644 index 0000000000..9cba667afd --- /dev/null +++ b/__tests__/plots/api/chart-emit-element-highlight.ts @@ -0,0 +1,74 @@ +import { Chart } from '../../../src'; + +export function chartEmitElementHighlight(context) { + const { container, canvas } = context; + + // button + const button = document.createElement('button'); + button.innerText = 'Highlight'; + container.appendChild(button); + + const button1 = document.createElement('button'); + button1.innerText = 'reset'; + container.appendChild(button1); + + // wrapperDiv + const wrapperDiv = document.createElement('div'); + container.appendChild(wrapperDiv); + + const chart = new Chart({ + theme: 'classic', + container: wrapperDiv, + padding: 'auto', + canvas, + }); + + chart.options({ + type: 'interval', + transform: [ + { type: 'sortX', by: 'y', reverse: true, reducer: 'sum', slice: 6 }, + { type: 'dodgeX' }, + ], + data: { + type: 'fetch', + value: 'data/stateages.csv', + }, + encode: { + x: 'state', + y: 'population', + color: 'age', + }, + state: { + active: { fill: 'red' }, + inactive: { opacity: 0.6 }, + }, + interaction: { + elementHighlightByX: { delay: 0 }, + tooltip: false, + }, + }); + + const finished = chart.render(); + + chart.on('element:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('element:highlight', data); + }); + + chart.on('element:unhighlight', (event) => { + const { nativeEvent } = event; + if (nativeEvent) console.log('reset'); + }); + + button.onclick = () => { + chart.emit('element:highlight', { + data: { data: { population: 5038433 } }, + }); + }; + + button1.onclick = () => { + chart.emit('element:unhighlight', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index b32fc5a7fb..fe559df473 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -26,3 +26,4 @@ export { chartEmitBrushHighlightX } from './chart-emit-brush-highlight-x'; export { chartRenderBrushEnd } from './chart-render-brush-end'; export { chartChangeDataEmpty } from './chart-change-data-empty'; export { chartEmitSliderFilter } from './chart-emit-slider-filter'; +export { chartEmitElementHighlight } from './chart-emit-element-highlight'; diff --git a/site/docs/spec/interaction/elementHighlight.zh.md b/site/docs/spec/interaction/elementHighlight.zh.md index ee352bc811..188f320d17 100644 --- a/site/docs/spec/interaction/elementHighlight.zh.md +++ b/site/docs/spec/interaction/elementHighlight.zh.md @@ -41,3 +41,29 @@ chart.render(); | background | 是否高亮背景 | `boolean` | false | | offset | 主方向的偏移量 | `number` | 0 | | `background${StyleAttrs}` | 背景的样式 | `StyleAttrs` | - | + +## 案例 + +### 触发事件 + +```js +chart.emit('element:highlight', { + data: { data: { population: 5038433 } }, +}); + +chart.emit('element:unhighlight', {}); +``` + +### 获得数据 + +```js +chart.on('element:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('element:highlight', data); +}); + +chart.on('element:unhighlight', (event) => { + const { nativeEvent } = event; + if (nativeEvent) console.log('reset'); +}); +``` diff --git a/site/docs/spec/interaction/tooltip.zh.md b/site/docs/spec/interaction/tooltip.zh.md index bc0e3f4d0b..590c0834e5 100644 --- a/site/docs/spec/interaction/tooltip.zh.md +++ b/site/docs/spec/interaction/tooltip.zh.md @@ -102,7 +102,7 @@ chart.interaction('tooltip', { chart.render(); ``` -## 获得提示数据 +### 获得提示数据 ```js chart.on('tooltip:show', (event) => { @@ -114,7 +114,7 @@ chart.on('tooltip:hide', () => { }); ``` -## 手动控制展示/隐藏 +### 手动控制展示/隐藏 对于 Interval、Point 等非系列 Mark,控制展示的方式如下: diff --git a/src/interaction/elementHighlight.ts b/src/interaction/elementHighlight.ts index 871c1e99d9..59ff590303 100644 --- a/src/interaction/elementHighlight.ts +++ b/src/interaction/elementHighlight.ts @@ -9,6 +9,7 @@ import { offsetTransform, renderBackground, renderLink, + selectElementByData, selectG2Elements, selectPlotArea, useState, @@ -28,6 +29,7 @@ export function elementHighlight( delay = 60, // delay to unhighlighted element scale, coordinate, + emitter, state = {}, }: Record, ) { @@ -67,7 +69,7 @@ export function elementHighlight( let out; // Timer for delaying unhighlighted. const pointerover = (event) => { - const { target: element } = event; + const { target: element, nativeEvent = true } = event; if (!elementSet.has(element)) return; if (out) clearTimeout(out); const k = groupKey(element); @@ -84,6 +86,16 @@ export function elementHighlight( } appendBackground(element); appendLink(group); + + // Emit events. + if (!nativeEvent) return; + emitter.emit('element:highlight', { + nativeEvent, + data: { + data: datum(element), + group: group.map(datum), + }, + }); }; const delayUnhighlighted = () => { @@ -94,12 +106,15 @@ export function elementHighlight( }, delay); }; - const unhighlighted = () => { + const unhighlighted = (nativeEvent = true) => { for (const e of elements) { removeState(e, 'active', 'inactive'); removeBackground(e); removeLink(e); } + if (nativeEvent) { + emitter.emit('element:unhighlight', { nativeEvent }); + } }; const pointerout = (event) => { @@ -118,10 +133,30 @@ export function elementHighlight( root.addEventListener('pointerout', pointerout); root.addEventListener('pointerleave', pointerleave); + const onRest = (e) => { + const { nativeEvent } = e; + if (nativeEvent) return; + unhighlighted(false); + }; + + const onHighlight = (e) => { + const { nativeEvent } = e; + if (nativeEvent) return; + const { data } = e.data; + const element = selectElementByData(elements, data, datum); + if (!element) return; + pointerover({ target: element, nativeEvent: false }); + }; + + emitter.on('element:highlight', onHighlight); + emitter.on('element:unhighlight', onRest); + return () => { root.removeEventListener('pointerover', pointerover); root.removeEventListener('pointerout', pointerout); root.removeEventListener('pointerleave', pointerleave); + emitter.off('element:highlight', onHighlight); + emitter.off('element:unhighlight', onRest); for (const e of elements) { removeBackground(e); removeLink(e); @@ -136,7 +171,7 @@ export function ElementHighlight({ link = false, ...rest }) { - return (context) => { + return (context, _, emitter) => { const { container, view, options } = context; const { scale, coordinate } = view; const plotArea = selectPlotArea(container); @@ -153,6 +188,7 @@ export function ElementHighlight({ background, link, delay, + emitter, ...rest, }); }; diff --git a/src/interaction/tooltip.ts b/src/interaction/tooltip.ts index 23ea950950..a735da3ccb 100644 --- a/src/interaction/tooltip.ts +++ b/src/interaction/tooltip.ts @@ -15,6 +15,7 @@ import { mousePosition, selectFacetG2Elements, createDatumof, + selectElementByData, } from './utils'; import { dataOf } from './event'; @@ -683,11 +684,7 @@ export function tooltip( const onTooltipShow = ({ nativeEvent, data }) => { if (nativeEvent) return; - const element = elements.find((d) => - Object.entries(data.data).every( - ([key, value]) => datum(d)[key] === value, - ), - ); + const element = selectElementByData(elements, data.data, datum); if (!element) return; const bbox = element.getBBox(); const { x, y, width, height } = bbox; diff --git a/src/interaction/utils.ts b/src/interaction/utils.ts index 98ad4dd91e..e5359934ce 100644 --- a/src/interaction/utils.ts +++ b/src/interaction/utils.ts @@ -456,3 +456,9 @@ export function setCursor(root, cursor) { export function restoreCursor(root) { setCursor(root, 'default'); } + +export function selectElementByData(elements, data, datum) { + return elements.find((d) => + Object.entries(data).every(([key, value]) => datum(d)[key] === value), + ); +}