diff --git a/__tests__/integration/api-chart-emit-brush-highlight-axis-cross.spec.ts b/__tests__/integration/api-chart-emit-brush-highlight-axis-cross.spec.ts new file mode 100644 index 0000000000..e070cd4ecb --- /dev/null +++ b/__tests__/integration/api-chart-emit-brush-highlight-axis-cross.spec.ts @@ -0,0 +1,72 @@ +import { chartEmitBrushHighlightAxisCross as render } from '../plots/api/chart-emit-brush-highlight-axis-cross'; +import { dblclick, brush } from '../plots/interaction/penguins-point-brush'; +import { AXIS_HOT_AREA_CLASS_NAME } from '../../src/interaction/brushAxisHighlight'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { createPromise, getElementByClassName } from './utils/event'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import './utils/useCustomFetch'; +import './utils/useSnapshotMatchers'; + +describe('chart.on', () => { + const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; + const canvas = createNodeGCanvas(640, 480); + + it('chart.emit("brushAxis:highlight", callback) should emit events.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('brushAxis:highlight', options) should trigger slider. + chart.emit('brushAxis:highlight', { + data: { + selection: [ + [40, 50], + [14, 18], + ], + }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('brushAxis:remove', options) should reset. + chart.emit('brushAxis:remove', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + const axis = getElementByClassName(canvas, AXIS_HOT_AREA_CLASS_NAME); + + // chart.on("brushAxis:highlight") should receive expected data. + const [highlight, resolveHighlight] = createPromise(); + chart.on('brushAxis:highlight', (event) => { + if (!event.nativeEvent) return; + expect(event.data.selection).toEqual([ + [32.1, 59.6], + [13.1, 21.5], + ]); + resolveHighlight(); + }); + brush(axis, -Infinity, 50, Infinity, 400); + await sleep(20); + await highlight; + + // chart.on("brushAxis:remove") should be called. + const [remove, resolveRemove] = createPromise(); + chart.on('brushAxis:remove', (event) => { + if (!event.nativeEvent) return; + resolveRemove(); + }); + dblclick(axis); + await sleep(20); + await remove; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/api-chart-emit-brush-highlight-axis-horizontal.spec.ts b/__tests__/integration/api-chart-emit-brush-highlight-axis-horizontal.spec.ts new file mode 100644 index 0000000000..46b876a2cc --- /dev/null +++ b/__tests__/integration/api-chart-emit-brush-highlight-axis-horizontal.spec.ts @@ -0,0 +1,72 @@ +import { chartEmitBrushHighlightAxisHorizontal as render } from '../plots/api/chart-emit-brush-highlight-axis-horizontal'; +import { dblclick, brush } from '../plots/interaction/penguins-point-brush'; +import { AXIS_HOT_AREA_CLASS_NAME } from '../../src/interaction/brushAxisHighlight'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { createPromise, getElementByClassName } from './utils/event'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import './utils/useCustomFetch'; +import './utils/useSnapshotMatchers'; + +describe('chart.on', () => { + const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; + const canvas = createNodeGCanvas(640, 800); + + it('chart.emit("brushAxis:highlight", callback) should emit events.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('brushAxis:highlight', options) should trigger slider. + chart.emit('brushAxis:highlight', { + data: { selection: [[20, 30], undefined, [100, 300]] }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('brushAxis:remove', options) should reset. + chart.emit('brushAxis:remove', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + const axis = getElementByClassName(canvas, AXIS_HOT_AREA_CLASS_NAME); + + // chart.on("brushAxis:highlight") should receive expected data. + const [highlight, resolveHighlight] = createPromise(); + chart.on('brushAxis:highlight', (event) => { + if (!event.nativeEvent) return; + expect(event.data.selection).toEqual([ + [11, 33], + [3, 8], + [68, 455], + [46, 230], + [1613, 5140], + [8, 24.8], + [70, 82], + ]); + resolveHighlight(); + }); + brush(axis, 50, -Infinity, 400, Infinity); + await sleep(20); + await highlight; + + // chart.on("brushAxis:remove") should be called. + const [remove, resolveRemove] = createPromise(); + chart.on('brushAxis:remove', (event) => { + if (!event.nativeEvent) return; + resolveRemove(); + }); + dblclick(axis); + await sleep(20); + await remove; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/api-chart-emit-brush-highlight-axis-vertical.spec.ts b/__tests__/integration/api-chart-emit-brush-highlight-axis-vertical.spec.ts new file mode 100644 index 0000000000..f17d06bc29 --- /dev/null +++ b/__tests__/integration/api-chart-emit-brush-highlight-axis-vertical.spec.ts @@ -0,0 +1,72 @@ +import { chartEmitBrushHighlightAxisVertical as render } from '../plots/api/chart-emit-brush-highlight-axis-vertical'; +import { dblclick, brush } from '../plots/interaction/penguins-point-brush'; +import { AXIS_HOT_AREA_CLASS_NAME } from '../../src/interaction/brushAxisHighlight'; +import { createNodeGCanvas } from './utils/createNodeGCanvas'; +import { createPromise, getElementByClassName } from './utils/event'; +import { sleep } from './utils/sleep'; +import { kebabCase } from './utils/kebabCase'; +import './utils/useCustomFetch'; +import './utils/useSnapshotMatchers'; + +describe('chart.on', () => { + const dir = `${__dirname}/snapshots/api/${kebabCase(render.name)}`; + const canvas = createNodeGCanvas(640, 480); + + it('chart.emit("brushAxis:highlight", callback) should emit events.', async () => { + const { chart, finished } = render({ + canvas, + container: document.createElement('div'), + }); + await finished; + await sleep(20); + + // chart.emit('brushAxis:highlight', options) should trigger slider. + chart.emit('brushAxis:highlight', { + data: { selection: [[20, 30], undefined, [100, 300]] }, + }); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step0'); + + // chart.emit('brushAxis:remove', options) should reset. + chart.emit('brushAxis:remove', {}); + await sleep(20); + await expect(canvas).toMatchCanvasSnapshot(dir, 'step1'); + + chart.off(); + + const axis = getElementByClassName(canvas, AXIS_HOT_AREA_CLASS_NAME); + + // chart.on("brushAxis:highlight") should receive expected data. + const [highlight, resolveHighlight] = createPromise(); + chart.on('brushAxis:highlight', (event) => { + if (!event.nativeEvent) return; + expect(event.data.selection).toEqual([ + [14, 44.6], + [3, 8], + [68, 455], + [46, 230], + [1613, 5140], + [8, 24.8], + [70, 82], + ]); + resolveHighlight(); + }); + brush(axis, -Infinity, 50, Infinity, 400); + await sleep(20); + await highlight; + + // chart.on("brushAxis:remove") should be called. + const [remove, resolveRemove] = createPromise(); + chart.on('brushAxis:remove', (event) => { + if (!event.nativeEvent) return; + resolveRemove(); + }); + dblclick(axis); + await sleep(20); + await remove; + }); + + afterAll(() => { + canvas?.destroy(); + }); +}); diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step0.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step0.png new file mode 100644 index 0000000000..7799be9881 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step1.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step1.png new file mode 100644 index 0000000000..9fe6636cc4 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-cross/step1.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step0.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step0.png new file mode 100644 index 0000000000..d8733542ea Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step1.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step1.png new file mode 100644 index 0000000000..184f8ae1dc Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-horizontal/step1.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step0.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step0.png new file mode 100644 index 0000000000..d4a6de0ee6 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step0.png differ diff --git a/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step1.png b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step1.png new file mode 100644 index 0000000000..8b0da50d73 Binary files /dev/null and b/__tests__/integration/snapshots/api/chart-emit-brush-highlight-axis-vertical/step1.png differ diff --git a/__tests__/integration/utils/event.ts b/__tests__/integration/utils/event.ts index 33bc925fdc..167d55f70d 100644 --- a/__tests__/integration/utils/event.ts +++ b/__tests__/integration/utils/event.ts @@ -44,3 +44,7 @@ export function dispatchFirstShapeEvent(canvas, className, event, params?) { const [shape] = canvas.document.getElementsByClassName(className); shape.dispatchEvent(new CustomEvent(event, params)); } + +export function getElementByClassName(canvas, className) { + return canvas.document.getElementsByClassName(className)[0]; +} diff --git a/__tests__/plots/api/chart-emit-brush-highlight-axis-cross.ts b/__tests__/plots/api/chart-emit-brush-highlight-axis-cross.ts new file mode 100644 index 0000000000..0de09773c3 --- /dev/null +++ b/__tests__/plots/api/chart-emit-brush-highlight-axis-cross.ts @@ -0,0 +1,70 @@ +import { Chart } from '../../../src'; + +export function chartEmitBrushHighlightAxisCross(context) { + const { container, canvas } = context; + + // button + const button1 = document.createElement('button'); + button1.innerText = 'Highlight'; + container.appendChild(button1); + + const button2 = document.createElement('button'); + button2.innerText = 'Reset'; + container.appendChild(button2); + + // wrapperDiv + const wrapperDiv = document.createElement('div'); + container.appendChild(wrapperDiv); + + const chart = new Chart({ + theme: 'classic', + container: wrapperDiv, + canvas, + }); + + chart.options({ + type: 'point', + data: { + type: 'fetch', + value: 'data/penguins.csv', + }, + encode: { + color: 'species', + x: 'culmen_length_mm', + y: 'culmen_depth_mm', + }, + state: { inactive: { stroke: 'gray', opacity: 0.5 } }, + interaction: { + brushAxisHighlight: true, + }, + }); + + const finished = chart.render(); + + chart.on('brushAxis:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:highlight', data); + }); + + chart.on('brushAxis:remove', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:remove', data); + }); + + button1.onclick = () => { + chart.emit('brushAxis:highlight', { + data: { + selection: [ + [40, 50], + [14, 18], + ], + }, + }); + }; + + button2.onclick = () => { + chart.emit('brushAxis:remove', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/chart-emit-brush-highlight-axis-horizontal.ts b/__tests__/plots/api/chart-emit-brush-highlight-axis-horizontal.ts new file mode 100644 index 0000000000..88635a55e7 --- /dev/null +++ b/__tests__/plots/api/chart-emit-brush-highlight-axis-horizontal.ts @@ -0,0 +1,129 @@ +import { Chart } from '../../../src'; + +export function chartEmitBrushHighlightAxisHorizontal(context) { + const { container, canvas } = context; + + // button + const button1 = document.createElement('button'); + button1.innerText = 'Highlight'; + container.appendChild(button1); + + const button2 = document.createElement('button'); + button2.innerText = 'Reset'; + container.appendChild(button2); + + // wrapperDiv + const wrapperDiv = document.createElement('div'); + container.appendChild(wrapperDiv); + + const chart = new Chart({ + theme: 'classic', + container: wrapperDiv, + canvas, + }); + + const position = [ + 'economy (mpg)', + 'cylinders', + 'displacement (cc)', + 'power (hp)', + 'weight (lb)', + '0-60 mph (s)', + 'year', + ]; + + chart.options({ + type: 'line', + height: 800, + data: { + type: 'fetch', + value: 'data/cars3.csv', + }, + interaction: { + tooltip: { series: false }, + brushAxisHighlight: { + maskFill: 'red', + maskOpacity: 0.8, + }, + }, + coordinate: { type: 'parallel', transform: [{ type: 'transpose' }] }, + encode: { + position, + color: 'cylinders', + }, + style: { + strokeWidth: 1.5, + strokeOpacity: 0.4, + }, + layout: { padding: 5 }, + scale: { + color: { + palette: 'brBG', + offset: (t) => 1 - t, + }, + }, + legend: { + color: { + position: 'top', + layout: { justifyContent: 'center' }, + size: 50, + length: 300, + style: { + labelSpacing: 0, + }, + }, + }, + state: { + active: { strokeWidth: 5 }, + inactive: { stroke: 'grey', opacity: 0.5 }, + }, + axis: Object.fromEntries( + Array.from({ length: position.length }, (_, i) => [ + `position${i === 0 ? '' : i}`, + { + zIndex: 1, + line: true, + tick: true, + style: { + labelFontSize: 10, + labelStroke: '#fff', + labelStrokeLineJoin: 'round', + labelStrokeWidth: 5, + lineStroke: 'black', + lineStrokeOpacity: 1, + lineStrokeWidth: 1, + tickStroke: 'black', + titleFontSize: 10, + titleStroke: '#fff', + titleStrokeLineJoin: 'round', + titleStrokeWidth: 5, + }, + }, + ]), + ), + }); + + const finished = chart.render(); + + chart.on('brushAxis:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:highlight', data); + }); + + chart.on('brushAxis:remove', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:remove', data); + }); + + button1.onclick = () => { + chart.emit('brushAxis:highlight', { + data: { selection: [[20, 30], undefined, [100, 300]] }, + }); + }; + + button2.onclick = () => { + chart.emit('brushAxis:remove', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/chart-emit-brush-highlight-axis-vertical.ts b/__tests__/plots/api/chart-emit-brush-highlight-axis-vertical.ts new file mode 100644 index 0000000000..33efcb8016 --- /dev/null +++ b/__tests__/plots/api/chart-emit-brush-highlight-axis-vertical.ts @@ -0,0 +1,120 @@ +import { Chart } from '../../../src'; + +export function chartEmitBrushHighlightAxisVertical(context) { + const { container, canvas } = context; + + // button + const button1 = document.createElement('button'); + button1.innerText = 'Highlight'; + container.appendChild(button1); + + const button2 = document.createElement('button'); + button2.innerText = 'Reset'; + container.appendChild(button2); + + // wrapperDiv + const wrapperDiv = document.createElement('div'); + container.appendChild(wrapperDiv); + + const chart = new Chart({ + theme: 'classic', + container: wrapperDiv, + canvas, + }); + + const position = [ + 'economy (mpg)', + 'cylinders', + 'displacement (cc)', + 'power (hp)', + 'weight (lb)', + '0-60 mph (s)', + 'year', + ]; + + chart.options({ + type: 'view', + coordinate: { type: 'parallel' }, + children: [ + { + type: 'line', + data: { + type: 'fetch', + value: 'data/cars3.csv', + }, + encode: { + position, + color: 'cylinders', + }, + style: { + strokeWidth: 1.5, + strokeOpacity: 0.4, + }, + scale: { + color: { palette: 'brBG', offset: (t) => 1 - t }, + }, + state: { + active: { strokeWidth: 5 }, + inactive: { stroke: 'grey', opacity: 0.5 }, + }, + legend: false, + axis: Object.fromEntries( + Array.from({ length: position.length }, (_, i) => [ + `position${i === 0 ? '' : i}`, + { + zIndex: 1, + line: true, + tick: true, + titlePosition: 'r', + style: { + labelStroke: '#fff', + labelStrokeWidth: 5, + labelFontSize: 10, + labelStrokeLineJoin: 'round', + titleStroke: '#fff', + titleFontSize: 10, + titleStrokeWidth: 5, + titleStrokeLineJoin: 'round', + titleTransform: 'translate(-50%, 0) rotate(-90)', + lineStroke: 'black', + tickStroke: 'black', + lineStrokeWidth: 1, + }, + }, + ]), + ), + }, + ], + interaction: { + brushAxisHighlight: { + maskFill: 'red', + maskOpacity: 0.8, + }, + tooltip: { series: false }, + }, + }); + + const finished = chart.render(); + + chart.on('brushAxis:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:highlight', data); + }); + + chart.on('brushAxis:remove', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:remove', data); + }); + + button1.onclick = () => { + chart.emit('brushAxis:highlight', { + data: { selection: [[20, 30], undefined, [100, 300]] }, + }); + }; + + button2.onclick = () => { + chart.emit('brushAxis:remove', {}); + }; + + return { chart, finished }; +} diff --git a/__tests__/plots/api/chart-on-focus-context.ts b/__tests__/plots/api/chart-on-focus-context.ts index e595bc0c81..60875a7eb0 100644 --- a/__tests__/plots/api/chart-on-focus-context.ts +++ b/__tests__/plots/api/chart-on-focus-context.ts @@ -70,7 +70,9 @@ export function chartOnFocusContext(context) { focusView.emit('brush:filter', { data: { selection } }); }); - contextView.on('brush:end', () => { + contextView.on('brush:remove', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; const { x: scaleX, y: scaleY } = contextView.getScale(); const selection = [scaleX.getOptions().domain, scaleY.getOptions().domain]; focusView.emit('brush:filter', { data: { selection } }); diff --git a/__tests__/plots/api/index.ts b/__tests__/plots/api/index.ts index 3ec42d1be1..a34eb6f577 100644 --- a/__tests__/plots/api/index.ts +++ b/__tests__/plots/api/index.ts @@ -30,3 +30,6 @@ 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'; +export { chartEmitBrushHighlightAxisVertical } from './chart-emit-brush-highlight-axis-vertical'; +export { chartEmitBrushHighlightAxisHorizontal } from './chart-emit-brush-highlight-axis-horizontal'; +export { chartEmitBrushHighlightAxisCross } from './chart-emit-brush-highlight-axis-cross'; diff --git a/__tests__/plots/interaction/cars3-line-vertical-brush-axis.ts b/__tests__/plots/interaction/cars3-line-vertical-brush-axis.ts index 91055e1eee..662d2c180e 100644 --- a/__tests__/plots/interaction/cars3-line-vertical-brush-axis.ts +++ b/__tests__/plots/interaction/cars3-line-vertical-brush-axis.ts @@ -69,6 +69,7 @@ export function cars3LineVerticalBrushAxis(): G2Spec { maskFill: 'red', maskOpacity: 0.8, }, + tooltip: { series: false }, }, }; } diff --git a/site/docs/spec/interaction/brushAxisHighlight.zh.md b/site/docs/spec/interaction/brushAxisHighlight.zh.md index ab060328b8..889da793db 100644 --- a/site/docs/spec/interaction/brushAxisHighlight.zh.md +++ b/site/docs/spec/interaction/brushAxisHighlight.zh.md @@ -90,3 +90,29 @@ chart.render(); | ------------------- | -------------- | ------------------------------ | ------ | | reverse | brush 是否反转 | `boolean` | false | | `mask${StyleAttrs}` | brush 的样式 | `number \| string` | - | + +## 案例 + +### 获得数据 + +```js +chart.on('brushAxis:highlight', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:highlight', data); +}); + +chart.on('brushAxis:remove', (event) => { + const { data, nativeEvent } = event; + if (nativeEvent) console.log('brushAxis:remove', data); +}); +``` + +### 触发交互 + +```js +chart.emit('brushAxis:highlight', { + data: { selection: [[20, 30], undefined, [100, 300]] }, +}); + +chart.emit('brushAxis:remove', {}); +``` diff --git a/site/examples/interaction/interaction/demo/focus-context.ts b/site/examples/interaction/interaction/demo/focus-context.ts index 11e92f0f15..f9b73f9966 100644 --- a/site/examples/interaction/interaction/demo/focus-context.ts +++ b/site/examples/interaction/interaction/demo/focus-context.ts @@ -106,7 +106,9 @@ context.on('brush:highlight', (e) => { focus.emit('brush:filter', { data: { selection } }); }); -context.on('brush:end', () => { +context.on('brush:remove', (e) => { + const { nativeEvent } = e; + if (!nativeEvent) return; const { x: scaleX, y: scaleY } = context.getScale(); const selection = [scaleX.getOptions().domain, scaleY.getOptions().domain]; focus.emit('brush:filter', { data: { selection } }); diff --git a/src/component/axis.ts b/src/component/axis.ts index c9650a9aba..c20df46198 100644 --- a/src/component/axis.ts +++ b/src/component/axis.ts @@ -590,6 +590,7 @@ const axisFactor: ( ...options, labelFormatter, labelFilter, + scale, }; return axis(normalizedOptions)(context); }; diff --git a/src/interaction/brushAxisHighlight.ts b/src/interaction/brushAxisHighlight.ts index 474c1f7b0f..6cbb603f87 100644 --- a/src/interaction/brushAxisHighlight.ts +++ b/src/interaction/brushAxisHighlight.ts @@ -1,5 +1,6 @@ import { Rect } from '@antv/g'; import { subObject } from '../utils/helper'; +import { domainOf, pixelsOf } from '../utils/scale'; import { brush } from './brushHighlight'; import { brushXRegion } from './brushXHighlight'; import { brushYRegion } from './brushYHighlight'; @@ -118,13 +119,15 @@ export function brushAxisHighlight( offsetX, // offsetX for shape area reverse = false, state = {}, + emitter, + coordinate, ...rest // style }, ) { const elements = elementsOf(root); const axes = axesOf(root); const valueof = createValueof(elements, datum); - const { setState } = useState(state, valueof); + const { setState, removeState } = useState(state, valueof); const axisExtent = new Map(); const brushStyle = subObject(rest, 'mask'); @@ -136,18 +139,80 @@ export function brushAxisHighlight( }), ); + const scales = axes.map((d) => d.attributes.scale); + + const extentOf = (D) => (D.length > 2 ? [D[0], D[D.length - 1]] : D); + + const indexDomain = new Map(); + + const initIndexDomain = () => { + indexDomain.clear(); + for (let i = 0; i < axes.length; i++) { + const scale = scales[i]; + const { domain } = scale.getOptions(); + indexDomain.set(i, extentOf(domain)); + } + }; + + initIndexDomain(); + // Update element when brush changed. - const updateElement = () => { + const updateElement = (i, emit) => { + const selectedElements = []; for (const element of elements) { const points = pointsOf(element); - if (brushed(points)) setState(element, 'active'); - else setState(element, 'inactive'); + if (brushed(points)) { + setState(element, 'active'); + selectedElements.push(element); + } else setState(element, 'inactive'); } + + indexDomain.set(i, selectionOf(selectedElements, i)); + + if (!emit) return; + + // Emit events. + const selection = () => { + if (!cross) return Array.from(indexDomain.values()); + const S = []; + for (const [index, domain] of indexDomain) { + const scale = scales[index]; + const { name } = scale.getOptions(); + if (name === 'x') S[0] = domain; + else S[1] = domain; + } + return S; + }; + emitter.emit('brushAxis:highlight', { + nativeEvent: true, + data: { + selection: selection(), + }, + }); + }; + + const clearElement = (emit) => { + for (const element of elements) removeState(element, 'active', 'inactive'); + initIndexDomain(); + if (!emit) return; + emitter.emit('brushAxis:remove', { nativeEvent: true }); + }; + + const selectionOf = (selected, i) => { + const scale = scales[i]; + const { name } = scale.getOptions(); + const domain = selected.map((d) => { + const data = d.__data__; + return scale.invert(data[name]); + }); + return extentOf(domainOf(scale, domain)); }; // Distinguish between parallel coordinates and normal charts. const cross = axes.some(isHorizontal) && axes.some((d) => !isHorizontal(d)); - for (const axis of axes) { + const handlers = []; + for (let i = 0; i < axes.length; i++) { + const axis = axes[i]; const createBrush = isHorizontal(axis) ? horizontalBrush : verticalBrush; const { hotZone, brushRegion, extent } = createBrush(axis, { offsetY, @@ -157,30 +222,82 @@ export function brushAxisHighlight( fill: 'transparent', // Make it interactive. }); axis.parentNode.appendChild(hotZone); - brush(hotZone, { + const brushHandler = brush(hotZone, { ...brushStyle, reverse, brushRegion, - brushended() { + brushended(emit) { axisExtent.delete(axis); - updateElement(); + if (Array.from(axisExtent.entries()).length === 0) clearElement(emit); + else updateElement(i, emit); }, - brushed(x, y, x1, y1) { + brushed(x, y, x1, y1, emit) { axisExtent.set(axis, extent(x, y, x1, y1)); - updateElement(); + updateElement(i, emit); }, }); + handlers.push(brushHandler); } + + const onRemove = (event: any = {}) => { + const { nativeEvent } = event; + if (nativeEvent) return; + handlers.forEach((d) => d.remove()); + }; + + const rangeOf = (domain, scale, axis) => { + const [d0, d1] = domain; + const maybeStep = (scale) => (scale.getStep ? scale.getStep() : 0); + const x = abstractOf(d0, scale, axis); + const x1 = abstractOf(d1, scale, axis) + maybeStep(scale); + if (isHorizontal(axis)) return [x, -Infinity, x1, Infinity]; + return [-Infinity, x, Infinity, x1]; + }; + + const abstractOf = (x, scale, axis) => { + const { height, width } = coordinate.getOptions(); + const scale1 = scale.clone(); + if (isHorizontal(axis)) scale1.update({ range: [0, width] }); + else scale1.update({ range: [height, 0] }); + return scale1.map(x); + }; + + const onHighlight = (event) => { + const { nativeEvent } = event; + if (nativeEvent) return; + const { selection } = event.data; + for (let i = 0; i < handlers.length; i++) { + const domain = selection[i]; + const handler = handlers[i]; + const axis = axes[i]; + if (domain) { + const scale = scales[i]; + handler.move(...rangeOf(domain, scale, axis), false); + } else { + handler.remove(); + } + } + }; + + emitter.on('brushAxis:remove', onRemove); + emitter.on('brushAxis:highlight', onHighlight); + + return () => { + handlers.forEach((d) => d.destroy()); + emitter.off('brushAxis:remove', onRemove); + emitter.off('brushAxis:highlight', onHighlight); + }; } /** * @todo Support mask size. */ export function BrushAxisHighlight(options) { - return (target) => { + return (target, _, emitter) => { const { container, view, options: viewOptions } = target; const plotArea = selectPlotArea(container); const { x: x0, y: y0 } = plotArea.getBBox(); + const { coordinate } = view; return brushAxisHighlight(container, { elements: selectG2Elements, axes: axesOf, @@ -201,6 +318,8 @@ export function BrushAxisHighlight(options) { 'active', ['inactive', { opacity: 0.5 }], ]), + coordinate, + emitter, ...options, }); }; diff --git a/src/interaction/brushHighlight.ts b/src/interaction/brushHighlight.ts index 38e37e1311..7e1e0b2ce5 100644 --- a/src/interaction/brushHighlight.ts +++ b/src/interaction/brushHighlight.ts @@ -311,7 +311,7 @@ export function brush( creating = false; mask = null; background = null; - if (emit) brushended(); + brushended(emit); }; // Update mask and invoke brushended callback. @@ -477,7 +477,7 @@ export function brush( updateMask([x, y], [x1, y1], emit); }, remove() { - if (mask) removeMask(); + if (mask) removeMask(false); }, destroy() { // Do not emit brush:end event. @@ -627,9 +627,11 @@ export function brushHighlight( brushRegion, reverse, selectedHandles, - brushended: () => { + brushended: (emit) => { const handler = series ? seriesBrushend : brushended; - emitter.emit('brush:end', { nativeEvent: true }); + if (emit) { + emitter.emit('brush:remove', { nativeEvent: true }); + } handler(); }, brushed: (x, y, x1, y1, emit) => { diff --git a/src/utils/scale.ts b/src/utils/scale.ts index b12f3735a9..88fec812f1 100644 --- a/src/utils/scale.ts +++ b/src/utils/scale.ts @@ -25,7 +25,8 @@ export function domainOf(scale, values) { if (!values) return scale.getOptions().domain; if (!isOrdinalScale(scale)) return sort(values); const { domain } = scale.getOptions(); - const [v1, v2] = values; + const v1 = values[0]; + const v2 = values[values.length - 1]; const start = domain.indexOf(v1); const end = domain.indexOf(v2); return domain.slice(start, end + 1);