diff --git a/__tests__/integration/api-chart-emit-legend-filter.spec.ts b/__tests__/integration/api-chart-emit-legend-filter.spec.ts index cdc38fde84..9b5b6a1f61 100644 --- a/__tests__/integration/api-chart-emit-legend-filter.spec.ts +++ b/__tests__/integration/api-chart-emit-legend-filter.spec.ts @@ -2,27 +2,67 @@ import { chartEmitLegendFilter as render } from '../plots/api/chart-emit-legend- 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'; +import { LEGEND_ITEMS_CLASS_NAME } from '../../src/interaction/legendFilter'; describe('chart.emit', () => { const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; const canvas = createNodeGCanvas(800, 500); - let chart; - it('chart.emit("legend:filter", options) should filter channel', async () => { - const values = render({ + it('chart.on("legend:filter") should receive expected data.', async () => { + const { chart, finished } = render({ canvas, container: document.createElement('div'), }); - chart = values.chart; - await values.finished; + await finished; await sleep(20); - // Click legend item. - const [item] = values.items; - item.dispatchEvent(new CustomEvent('click')); + // chart.emit('legend:filter', options) should trigger slider. + chart.emit('legend:filter', { + data: { channel: 'color', values: ['Sports', 'Strategy'] }, + }); await sleep(20); await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('legend:end', options) should reset. + chart.emit('legend:end', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + // chart.on("legend:end") should be called. + const [end, resolveEnd] = createPromise(); + chart.on('legend:end', (event) => { + if (!event.nativeEvent) return; + resolveEnd(); + }); + dispatchFirstShapeEvent(canvas, LEGEND_ITEMS_CLASS_NAME, 'click', { + nativeEvent: true, + }); + dispatchFirstShapeEvent(canvas, LEGEND_ITEMS_CLASS_NAME, 'click', { + nativeEvent: true, + }); + await sleep(20); + await end; + + // chart.on("legend:filter") should receive expected data. + const [filter, resolveHighlight] = createPromise(); + chart.on('legend:filter', (event) => { + if (!event.nativeEvent) return; + expect(event.data).toEqual({ + channel: 'color', + values: ['Strategy', 'Action', 'Shooter', 'Other'], + }); + resolveHighlight(); + }); + dispatchFirstShapeEvent(canvas, LEGEND_ITEMS_CLASS_NAME, 'click', { + nativeEvent: true, + }); + await sleep(20); + await filter; }); afterAll(() => { diff --git a/__tests__/integration/snapshots/api/chart-emit-legend-filter/step0.png b/__tests__/integration/snapshots/api/chart-emit-legend-filter/step0.png index aa0752c1ae..2c261813da 100644 Binary files a/__tests__/integration/snapshots/api/chart-emit-legend-filter/step0.png and b/__tests__/integration/snapshots/api/chart-emit-legend-filter/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-legend-filter/step1.png b/__tests__/integration/snapshots/api/chart-emit-legend-filter/step1.png new file mode 100644 index 0000000000..f95abfcd57 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-legend-filter/step1.png differ diff --git a/__tests__/plots/api/chart-emit-legend-filter.ts b/__tests__/plots/api/chart-emit-legend-filter.ts index 65b116eaca..45092998a1 100644 --- a/__tests__/plots/api/chart-emit-legend-filter.ts +++ b/__tests__/plots/api/chart-emit-legend-filter.ts @@ -4,8 +4,13 @@ export function chartEmitLegendFilter(context) { const { container, canvas } = context; // button - const legend = document.createElement('div'); - container.appendChild(legend); + const button = document.createElement('button'); + button.innerText = 'filter'; + container.appendChild(button); + + const button1 = document.createElement('button'); + button1.innerText = 'end'; + container.appendChild(button1); // wrapperDiv const wrapperDiv = document.createElement('div'); @@ -29,59 +34,31 @@ export function chartEmitLegendFilter(context) { .encode('x', 'genre') .encode('y', 'sold') .encode('color', 'genre') - .animate(false) - .legend(false); + .animate(false); const finished = chart.render(); - const assetValues: any = { - chart, - finished, - }; - - finished.then(() => { - const scale = chart.getScaleByChannel('color'); - const { domain, range } = scale.getOptions(); - const excludedValues: any[] = []; - assetValues.items = domain.map((text, i) => { - const span = document.createElement('span'); - const color = range[i]; - - // Items' style. - span.innerText = text; - span.style.display = 'inline-block'; - span.style.padding = '0.5em'; - span.style.color = color; - span.style.cursor = 'pointer'; + chart.on('legend:filter', (e) => { + const { nativeEvent, data } = e; + if (!nativeEvent) return; + console.log(data); + }); - span.onclick = () => { - const index = excludedValues.findIndex((d) => d === text); - if (index === -1) { - excludedValues.push(text); - span.style.color = '#aaa'; - } else { - excludedValues.splice(index, 1); - span.style.color = color; - } - onChange(excludedValues); - }; + chart.on('legend:end', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; + console.log('end'); + }); - return span; + button.onclick = () => { + chart.emit('legend:filter', { + data: { channel: 'color', values: ['Sports', 'Strategy'] }, }); + }; - // Mount items. - for (const item of assetValues.items) legend.append(item); - - function onChange(values: any[]) { - const selectedValues = domain.filter((d) => !values.includes(d)); - - // Emit Event. - chart.emit('legend:filter', { - channel: 'color', - values: selectedValues, - }); - } - }); + button1.onclick = () => { + chart.emit('legend:end', {}); + }; - return assetValues; + return { chart, finished }; } diff --git a/site/docs/spec/interaction/legendFilter.zh.md b/site/docs/spec/interaction/legendFilter.zh.md index 2db26c965b..561b8355d5 100644 --- a/site/docs/spec/interaction/legendFilter.zh.md +++ b/site/docs/spec/interaction/legendFilter.zh.md @@ -28,3 +28,31 @@ chart.interaction('legendFilter', true); chart.render(); ``` + +## 案例 + +### 触发交互 + +```js +chart.emit('legend:filter', { + data: { channel: 'color', values: ['Sports', 'Strategy'] }, +}); + +chart.emit('legend:end', {}); +``` + +### 获得数据 + +```js +chart.on('legend:filter', (e) => { + const { nativeEvent, data } = e; + if (!nativeEvent) return; + console.log(data); +}); + +chart.on('legend:end', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; + console.log('end'); +}); +``` diff --git a/site/examples/component/legend/demo/custom.ts b/site/examples/component/legend/demo/custom.ts index cb44372b56..aa5997ea6d 100644 --- a/site/examples/component/legend/demo/custom.ts +++ b/site/examples/component/legend/demo/custom.ts @@ -5,18 +5,22 @@ const chart = new Chart({ container: 'container', }); +const data = [ + { genre: 'Sports', sold: 275 }, + { genre: 'Strategy', sold: 115 }, + { genre: 'Action', sold: 120 }, + { genre: 'Shooter', sold: 350 }, + { genre: 'Other', sold: 150 }, +]; + +const colorField = 'genre'; + chart .interval() - .data([ - { genre: 'Sports', sold: 275 }, - { genre: 'Strategy', sold: 115 }, - { genre: 'Action', sold: 120 }, - { genre: 'Shooter', sold: 350 }, - { genre: 'Other', sold: 150 }, - ]) + .data(data) .encode('x', 'genre') .encode('y', 'sold') - .encode('color', 'genre') + .encode('color', colorField) .legend(false); // Hide built-in legends. chart.render().then(renderCustomLegend); @@ -62,11 +66,11 @@ function renderCustomLegend(chart) { for (const item of items) legend.append(item); // Emit legendFilter event. - function onChange(values: any[]) { + function onChange(values) { const selectedValues = domain.filter((d) => !values.includes(d)); - chart.emit('legend:filter', { - channel: 'color', - values: selectedValues, - }); + const selectedData = data.filter((d) => + selectedValues.includes(d[colorField]), + ); + chart.changeData(selectedData); } } diff --git a/src/interaction/legendFilter.ts b/src/interaction/legendFilter.ts index 7bf39060fa..8de206b702 100644 --- a/src/interaction/legendFilter.ts +++ b/src/interaction/legendFilter.ts @@ -52,6 +52,8 @@ function legendFilter( label: labelOf, // given the legend returns the label datum, // given the legend returns the value filter, // invoke when dispatch filter event, + emitter, + channel, state = {} as Record, // state options }, ) { @@ -79,7 +81,7 @@ function legendFilter( ); const items: DisplayObject[] = Array.from(legends(root)); - const selectedValues = items.map(datum); + let selectedValues = items.map(datum); const updateLegendState = () => { for (const item of items) { const value = datum(item); @@ -105,7 +107,7 @@ function legendFilter( restoreCursor(root); }; - const click = async () => { + const click = async (event) => { const value = datum(item); const index = selectedValues.indexOf(value); if (index === -1) selectedValues.push(value); @@ -113,6 +115,22 @@ function legendFilter( if (selectedValues.length === 0) selectedValues.push(...items.map(datum)); await filter(selectedValues); updateLegendState(); + + const { nativeEvent = true } = event; + if (!nativeEvent) return; + if (selectedValues.length === items.length) { + emitter.emit('legend:end', { nativeEvent }); + } else { + // Emit events. + emitter.emit('legend:filter', { + ...event, + nativeEvent, + data: { + channel, + values: selectedValues, + }, + }); + } }; // Bind and store handlers. @@ -124,11 +142,35 @@ function legendFilter( itemPointerout.set(item, pointerout); } + const onFilter = async (event) => { + const { nativeEvent } = event; + if (nativeEvent) return; + const { data } = event; + const { channel: specifiedChannel, values } = data; + if (specifiedChannel !== channel) return; + selectedValues = values; + await filter(selectedValues); + updateLegendState(); + }; + + const onEnd = async (event) => { + const { nativeEvent } = event; + if (nativeEvent) return; + selectedValues = items.map(datum); + await filter(selectedValues); + updateLegendState(); + }; + + emitter.on('legend:filter', onFilter); + emitter.on('legend:end', onEnd); + return () => { for (const item of items) { item.removeEventListener('click', itemClick.get(item)); item.removeEventListener('pointerenter', itemPointerenter.get(item)); item.removeEventListener('pointerout', itemPointerout.get(item)); + emitter.off('legend:filter', onFilter); + emitter.off('legend:end', onEnd); } }; } @@ -168,17 +210,6 @@ export function LegendFilter() { return update(newOptions); }; - if (!legends.length) { - const onFilter = (options) => { - const { values, channel } = options; - filter(channel, values); - }; - emitter.on('legend:filter', onFilter); - return () => { - emitter.off('legend:filter', onFilter); - }; - } - const removes = legends.map((legend) => { const { name: channel, domain } = dataOf(legend).scales[0]; return legendFilter(container, { @@ -192,6 +223,8 @@ export function LegendFilter() { }, filter: (value) => filter(channel, value), state: legend.attributes.state, + channel, + emitter, }); }); return () => {