diff --git a/__tests__/integration/api-chart-emit-legend-highlight.spec.ts b/__tests__/integration/api-chart-emit-legend-highlight.spec.ts new file mode 100644 index 0000000000..52ea2205c3 --- /dev/null +++ b/__tests__/integration/api-chart-emit-legend-highlight.spec.ts @@ -0,0 +1,71 @@ +import { chartEmitLegendHighlight as render } from '../plots/api/chart-emit-legend-highlight'; +import { + LEGEND_ITEMS_CLASS_NAME, + CATEGORY_LEGEND_CLASS_NAME, +} from '../../src/interaction/legendFilter'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import { createPromise, dispatchFirstShapeEvent } 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("legend:highlight") should receive expected data.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('legend:highlight', options) should trigger slider. + chart.emit('legend:highlight', { + data: { channel: 'color', value: 'Increase' }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('legend:unhighlight', options) should reset. + chart.emit('legend:unhighlight', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + // chart.on("legend:unhighlight") should be called. + const [unhighlight, resolveUnhighlight] = createPromise(); + chart.on('legend:unhighlight', (event) => { + if (!event.nativeEvent) return; + resolveUnhighlight(); + }); + dispatchFirstShapeEvent( + canvas, + CATEGORY_LEGEND_CLASS_NAME, + 'pointerleave', + { nativeEvent: true }, + ); + await sleep(20); + await unhighlight; + + // chart.on("legend:highlight") should receive expected data. + const [highlight, resolveHighlight] = createPromise(); + chart.on('legend:highlight', (event) => { + if (!event.nativeEvent) return; + expect(event.data).toEqual({ channel: 'color', value: 'Increase' }); + resolveHighlight(); + }); + dispatchFirstShapeEvent(canvas, LEGEND_ITEMS_CLASS_NAME, 'pointerover', { + nativeEvent: true, + }); + await sleep(20); + await highlight; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step0.png b/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step0.png new file mode 100644 index 0000000000..6e0482bf88 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step1.png b/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step1.png new file mode 100644 index 0000000000..2e70900461 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-legend-highlight/step1.png differ diff --git a/__tests__/integration/utils/event.ts b/__tests__/integration/utils/event.ts index d2cd94d972..33bc925fdc 100644 --- a/__tests__/integration/utils/event.ts +++ b/__tests__/integration/utils/event.ts @@ -39,3 +39,8 @@ export function dispatchPlotEvent(canvas, event, params?) { const [plot] = canvas.document.getElementsByClassName('plot'); plot.dispatchEvent(new CustomEvent(event, params)); } + +export function dispatchFirstShapeEvent(canvas, className, event, params?) { + const [shape] = canvas.document.getElementsByClassName(className); + shape.dispatchEvent(new CustomEvent(event, params)); +} diff --git a/__tests__/plots/api/chart-emit-legend-highlight.ts b/__tests__/plots/api/chart-emit-legend-highlight.ts new file mode 100644 index 0000000000..adb6dcaba8 --- /dev/null +++ b/__tests__/plots/api/chart-emit-legend-highlight.ts @@ -0,0 +1,72 @@ +import { Chart } from '../../../src'; +import { profit } from '../../data/profit'; + +export function chartEmitLegendHighlight(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, + canvas, + }); + + chart.options({ + paddingLeft: 60, + type: 'interval', + data: profit, + axis: { y: { labelFormatter: '~s' } }, + encode: { + x: 'month', + y: ['end', 'start'], + color: (d) => + d.month === 'Total' ? 'Total' : d.profit > 0 ? 'Increase' : 'Decrease', + }, + state: { inactive: { opacity: 0.5 } }, + legend: { + color: { state: { inactive: { labelOpacity: 0.5, markerOpacity: 0.5 } } }, + }, + interaction: { + legendHighlight: true, + tooltip: false, + }, + }); + + chart.on('legend:highlight', (e) => { + const { nativeEvent, data } = e; + if (!nativeEvent) return; + console.log(data); + }); + + chart.on('legend:unhighlight', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; + console.log('unhighlight'); + }); + + button.onclick = () => { + chart.emit('legend:highlight', { + data: { channel: 'color', value: 'Increase' }, + }); + }; + + button1.onclick = () => { + chart.emit('legend:unhighlight', {}); + }; + + const finished = chart.render(); + + return { chart, finished }; +} diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index 2084a0ee08..3ec42d1be1 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -29,3 +29,4 @@ export { chartEmitSliderFilter } from './chart-emit-slider-filter'; export { chartEmitElementHighlight } from './chart-emit-element-highlight'; export { chartEmitElementSelect } from './chart-emit-element-select'; export { chartEmitElementSelectSingle } from './chart-emit-element-select-single'; +export { chartEmitLegendHighlight } from './chart-emit-legend-highlight'; diff --git a/site/docs/spec/interaction/legendHighlight.zh.md b/site/docs/spec/interaction/legendHighlight.zh.md index 9a813c081c..c09c70fc90 100644 --- a/site/docs/spec/interaction/legendHighlight.zh.md +++ b/site/docs/spec/interaction/legendHighlight.zh.md @@ -35,3 +35,31 @@ chart.interaction('legendHighlight', true); chart.render(); ``` + +## 案例 + +### 获得数据 + +```js +chart.on('legend:highlight', (e) => { + const { nativeEvent, data } = e; + if (!nativeEvent) return; + console.log(data); +}); + +chart.on('legend:unhighlight', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; + console.log('unhighlight'); +}); +``` + +### 触发交互 + +```js +chart.emit('legend:highlight', { + data: { channel: 'color', value: 'Increase' }, +}); + +chart.emit('legend:unhighlight', {}); +``` diff --git a/src/interaction/legendHighlight.ts b/src/interaction/legendHighlight.ts index e88efa5fc2..e322ef6e39 100644 --- a/src/interaction/legendHighlight.ts +++ b/src/interaction/legendHighlight.ts @@ -10,7 +10,7 @@ import { import { markerOf, labelOf, itemsOf, legendsOf, dataOf } from './legendFilter'; export function LegendHighlight() { - return (context) => { + return (context, _, emitter) => { const { container, view, options } = context; const legends = legendsOf(container); const elements = selectG2Elements(container); @@ -25,6 +25,7 @@ export function LegendHighlight() { }; const markState = mergeState(options, ['active', 'inactive']); const valueof = createValueof(elements, createDatumof(view)); + const destroys = []; // Bind events for each legend. for (const legend of legends) { @@ -62,39 +63,79 @@ export function LegendHighlight() { } } }; + const highlightItem = (event, item) => { + // Update UI. + const value = datumOf(item); + const elementSet = new Set(elementGroup.get(value)); + for (const e of elements) { + if (elementSet.has(e)) setState(e, 'active'); + else setState(e, 'inactive'); + } + updateLegendState(item); + + // Emit events. + const { nativeEvent = true } = event; + if (!nativeEvent) return; + emitter.emit('legend:highlight', { + ...event, + nativeEvent, + data: { channel, value }, + }); + }; const itemPointerover = new Map(); // Add listener for the legend items. for (const item of items) { - const pointerover = () => { - const value = datumOf(item); - const elementSet = new Set(elementGroup.get(value)); - for (const e of elements) { - if (elementSet.has(e)) setState(e, 'active'); - else setState(e, 'inactive'); - } - updateLegendState(item); + const pointerover = (event) => { + highlightItem(event, item); }; item.addEventListener('pointerover', pointerover); itemPointerover.set(item, pointerover); } // Add listener for the legend group. - const pointerleave = () => { - for (const e of elements) { - removeState(e, 'inactive', 'active'); - } + const pointerleave = (event) => { + for (const e of elements) removeState(e, 'inactive', 'active'); updateLegendState(null); + + // Emit events. + const { nativeEvent = true } = event; + if (!nativeEvent) return; + emitter.emit('legend:unhighlight', { nativeEvent }); + }; + + const onHighlight = (event) => { + const { nativeEvent, data } = event; + if (nativeEvent) return; + const { channel: specifiedChannel, value } = data; + if (specifiedChannel !== channel) return; + const item = items.find((d) => datumOf(d) === value); + if (!item) return; + highlightItem({ nativeEvent: false }, item); }; + + const onUnHighlight = (event) => { + const { nativeEvent } = event; + if (nativeEvent) return; + pointerleave({ nativeEvent: false }); + }; + legend.addEventListener('pointerleave', pointerleave); + emitter.on('legend:highlight', onHighlight); + emitter.on('legend:unhighlight', onUnHighlight); - return () => { + const destroy = () => { legend.removeEventListener(pointerleave); + emitter.off('legend:highlight', onHighlight); + emitter.off('legend:unhighlight', onUnHighlight); for (const [item, pointerover] of itemPointerover) { item.removeEventListener(pointerover); } }; + destroys.push(destroy); } + + return () => destroys.forEach((d) => d()); }; }