diff --git a/__tests__/integration/api-chart-emit-element-select-single.spec.ts b/__tests__/integration/api-chart-emit-element-select-single.spec.ts new file mode 100644 index 0000000000..68585b942a --- /dev/null +++ b/__tests__/integration/api-chart-emit-element-select-single.spec.ts @@ -0,0 +1,31 @@ +import { chartEmitElementSelectSingle as render } from '../plots/api/chart-emit-element-select-single'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +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:select", {single: true}) should receive expected data.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('element:select', options) should trigger slider. + chart.emit('element:select', { + data: { data: [{ population: 5038433 }, { population: 3983091 }] }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/api-chart-emit-element-select.spec.ts b/__tests__/integration/api-chart-emit-element-select.spec.ts new file mode 100644 index 0000000000..f187a553a6 --- /dev/null +++ b/__tests__/integration/api-chart-emit-element-select.spec.ts @@ -0,0 +1,74 @@ +import { chartEmitElementSelect as render } from '../plots/api/chart-emit-element-select'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import { + createPromise, + dispatchFirstElementEvent, + dispatchPlotEvent, +} 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:select") should receive expected data.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('element:select', options) should trigger slider. + chart.emit('element:select', { + data: { data: [{ population: 5038433 }, { population: 3983091 }] }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('element:unselect', options) should reset. + chart.emit('element:unselect', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + // chart.on("element:unselect") should be called. + const [unselect, resolveUnselect] = createPromise(); + chart.on('element:unselect', (event) => { + if (!event.nativeEvent) return; + resolveUnselect(); + }); + dispatchPlotEvent(canvas, 'click'); + await sleep(20); + await unselect; + + // chart.on("element:select") should receive expected data. + const [select, resolveHighlight] = createPromise(); + chart.on('element:select', (event) => { + if (!event.nativeEvent) return; + expect(event.data.data).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, 'click'); + await sleep(20); + await select; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/snapshots/api/chart-emit-element-select-single/step0.png b/__tests__/integration/snapshots/api/chart-emit-element-select-single/step0.png new file mode 100644 index 0000000000..af0a1d0a60 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-element-select-single/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-element-select/step0.png b/__tests__/integration/snapshots/api/chart-emit-element-select/step0.png new file mode 100644 index 0000000000..e3ec55a02a Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-element-select/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-element-select/step1.png b/__tests__/integration/snapshots/api/chart-emit-element-select/step1.png new file mode 100644 index 0000000000..8836cdd67d Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-element-select/step1.png differ diff --git a/__tests__/plots/api/chart-emit-element-select-single.ts b/__tests__/plots/api/chart-emit-element-select-single.ts new file mode 100644 index 0000000000..a395d613ff --- /dev/null +++ b/__tests__/plots/api/chart-emit-element-select-single.ts @@ -0,0 +1,74 @@ +import { Chart } from '../../../src'; + +export function chartEmitElementSelectSingle(context) { + const { container, canvas } = context; + + // button + const button = document.createElement('button'); + button.innerText = 'Select'; + 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: { + selected: { fill: 'red' }, + unselected: { opacity: 0.6 }, + }, + interaction: { + elementSelectByX: { delay: 0, single: true }, + tooltip: false, + }, + }); + + const finished = chart.render(); + + chart.on('element:select', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('element:select', data); + }); + + chart.on('element:unselect', (event) => { + const { nativeEvent } = event; + if (nativeEvent) console.log('reset'); + }); + + button.onclick = () => { + chart.emit('element:select', { + data: { data: [{ population: 5038433 }, { population: 3983091 }] }, + }); + }; + + button1.onclick = () => { + chart.emit('element:unselect', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/chart-emit-element-select.ts b/__tests__/plots/api/chart-emit-element-select.ts new file mode 100644 index 0000000000..2e285a2a55 --- /dev/null +++ b/__tests__/plots/api/chart-emit-element-select.ts @@ -0,0 +1,74 @@ +import { Chart } from '../../../src'; + +export function chartEmitElementSelect(context) { + const { container, canvas } = context; + + // button + const button = document.createElement('button'); + button.innerText = 'Select'; + 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: { + selected: { fill: 'red' }, + unselected: { opacity: 0.6 }, + }, + interaction: { + elementSelectByX: { delay: 0 }, + tooltip: false, + }, + }); + + const finished = chart.render(); + + chart.on('element:select', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('element:select', data); + }); + + chart.on('element:unselect', (event) => { + const { nativeEvent } = event; + if (nativeEvent) console.log('reset'); + }); + + button.onclick = () => { + chart.emit('element:select', { + data: { data: [{ population: 5038433 }, { population: 3983091 }] }, + }); + }; + + button1.onclick = () => { + chart.emit('element:unselect', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index fe559df473..2084a0ee08 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -27,3 +27,5 @@ 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'; +export { chartEmitElementSelect } from './chart-emit-element-select'; +export { chartEmitElementSelectSingle } from './chart-emit-element-select-single'; diff --git a/site/docs/spec/interaction/elementSelect.zh.md b/site/docs/spec/interaction/elementSelect.zh.md index 6e76c81130..6d4b0005f2 100644 --- a/site/docs/spec/interaction/elementSelect.zh.md +++ b/site/docs/spec/interaction/elementSelect.zh.md @@ -42,3 +42,29 @@ chart.render(); | offset | 主方向的偏移量 | `number` | 0 | | `background${StyleAttrs}` | 背景的样式 | `StyleAttrs` | - | | single | 是否单选 | `boolean` | false | + +## 案例 + +### 获得数据 + +```js +chart.on('element:select', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('element:select', data); +}); + +chart.on('element:unselect', (event) => { + const { nativeEvent } = event; + if (nativeEvent) console.log('reset'); +}); +``` + +### 触发交互 + +```js +chart.emit('element:select', { + data: { data: [{ population: 5038433 }, { population: 3983091 }] }, +}); + +chart.emit('element:unselect', {}); +``` diff --git a/src/interaction/elementSelect.ts b/src/interaction/elementSelect.ts index 15c8640bde..a037d6c834 100644 --- a/src/interaction/elementSelect.ts +++ b/src/interaction/elementSelect.ts @@ -12,6 +12,7 @@ import { selectPlotArea, offsetTransform, mergeState, + selectElementByData, } from './utils'; /** @@ -28,6 +29,7 @@ export function elementSelect( coordinate, background = false, scale, + emitter, state = {}, }: Record, ) { @@ -66,16 +68,17 @@ export function elementSelect( const { setState, removeState, hasState } = useState(elementStyle, valueof); - const clear = () => { + const clear = (nativeEvent = true) => { for (const e of elements) { removeState(e, 'selected', 'unselected'); removeLink(e); removeBackground(e); } + if (nativeEvent) emitter.emit('element:unselect', { nativeEvent: true }); return; }; - const singleSelect = (element) => { + const singleSelect = (event, element, nativeEvent = true) => { // Clear states if clicked selected element. if (hasState(element, 'selected')) clear(); else { @@ -92,10 +95,19 @@ export function elementSelect( } appendLink(group); appendBackground(element); + + if (!nativeEvent) return; + emitter.emit('element:select', { + ...event, + nativeEvent, + data: { + data: [datum(element), ...group.map(datum)], + }, + }); } }; - const multipleSelect = (element) => { + const multipleSelect = (event, element, nativeEvent = true) => { const k = groupKey(element); const group = keyGroup.get(k); const groupSet = new Set(group); @@ -123,24 +135,49 @@ export function elementSelect( removeBackground(e); } } + if (!nativeEvent) return; + emitter.emit('element:select', { + ...event, + nativeEvent, + data: { + data: elements.filter((e) => hasState(e, 'selected')).map(datum), + }, + }); }; const click = (event) => { - const { target: element } = event; + const { target: element, nativeEvent = true } = event; // Click non-element shape, reset. // Such as the rest of content area(background). if (!elementSet.has(element)) return clear(); - if (single) return singleSelect(element); - return multipleSelect(element); + if (single) return singleSelect(event, element, nativeEvent); + return multipleSelect(event, element, nativeEvent); }; root.addEventListener('click', click); + const onSelect = (e) => { + const { nativeEvent, data } = e; + if (nativeEvent) return; + const selectedData = single ? data.data.slice(0, 1) : data.data; + for (const d of selectedData) { + const element = selectElementByData(elements, d, datum); + click({ target: element, nativeEvent: false }); + } + }; + + const onUnSelect = () => { + clear(false); + }; + + emitter.on('element:select', onSelect); + emitter.on('element:unselect', onUnSelect); + return () => { + for (const e of elements) removeLink(e); root.removeEventListener('click', click); - for (const e of elements) { - removeLink(e); - } + emitter.off('element:select', onSelect); + emitter.off('element:unselect', onUnSelect); }; } @@ -150,7 +187,7 @@ export function ElementSelect({ link = false, ...rest }) { - return (context) => { + return (context, _, emitter) => { const { container, view, options } = context; const { coordinate, scale } = view; const plotArea = selectPlotArea(container); @@ -166,6 +203,7 @@ export function ElementSelect({ ]), background, link, + emitter, ...rest, }); };