Skip to content

Commit

Permalink
feat(brush): add multi axis brushing (#625)
Browse files Browse the repository at this point in the history
This commit allows the consumer to configure the direction used for the brush tool. The direction is, by default, along the X-axis, but can be changed to be along the Y-axis or have a rectangular selection along both axes. 
For each Y-axis defined (usually determined by an associated `groupId`) we return a scaled set of `[min, max]` values.

BREAKING CHANGE: The type used by the `BrushEndListener` is now in the following form `{ x?: [number, number]; y?: Array<{ groupId: GroupId; values: [number,
number]; }> }` where `x` contains an array of `[min, max]` values, and the  `y` property is an optional array of objects, containing the `GroupId` and the values of the brush for that specific axis.

fix #587, fix #620
  • Loading branch information
markov00 authored Apr 28, 2020
1 parent 43c5a59 commit 9e49534
Show file tree
Hide file tree
Showing 25 changed files with 695 additions and 89 deletions.
66 changes: 66 additions & 0 deletions integration/page_objects/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,51 @@ class CommonPage {
await page.mouse.click(element.left + mousePosition.x, element.top + mousePosition.y);
}

/**
* Drag mouse relative to element
*
* @param mousePosition
* @param selector
*/
async dragMouseRelativeToDOMElement(
start: { x: number; y: number },
end: { x: number; y: number },
selector: string,
) {
const element = await this.getBoundingClientRect(selector);
await page.mouse.move(element.left + start.x, element.top + start.y);
await page.mouse.down();
await page.mouse.move(element.left + end.x, element.top + end.y);
}

/**
* Drop mouse
*
* @param mousePosition
* @param selector
*/
async dropMouse() {
await page.mouse.up();
}

/**
* Drag and drop mouse relative to element
*
* @param mousePosition
* @param selector
*/
async dragAndDropMouseRelativeToDOMElement(
start: { x: number; y: number },
end: { x: number; y: number },
selector: string,
) {
const element = await this.getBoundingClientRect(selector);
await page.mouse.move(element.left + start.x, element.top + start.y);
await page.mouse.down();
await page.mouse.move(element.left + end.x, element.top + end.y);
await page.mouse.up();
}

/**
* Expect an element given a url and selector from storybook
*
Expand Down Expand Up @@ -201,6 +246,27 @@ class CommonPage {
});
}

/**
* Expect a chart given a url from storybook with mouse move
*
* @param url Storybook url from knobs section
* @param start - the start postion of mouse relative to chart
* @param end - the end postion of mouse relative to chart
* @param options
*/
async expectChartWithDragAtUrlToMatchScreenshot(
url: string,
start: { x: number; y: number },
end: { x: number; y: number },
options?: Omit<ScreenshotElementAtUrlOptions, 'action'>,
) {
const action = async () => await this.dragMouseRelativeToDOMElement(start, end, this.chartSelector);
await this.expectChartAtUrlToMatchScreenshot(url, {
...options,
action,
});
}

/**
* Loads storybook page from raw url, and waits for element
*
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions integration/tests/interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,49 @@ describe('Tooltips', () => {
},
);
});
it('show rectangular brush selection', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});
it('show y brush selection', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool&knob-brush axis=y&knob-chartRotation=0',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});
it('show x brush selection', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool&knob-brush axis=x&knob-chartRotation=0',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});

it('show rectangular brush selection -90 degree', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool&knob-brush axis=both&knob-chartRotation=-90',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});
it('show y brush selection -90 degree', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool&knob-brush axis=y&knob-chartRotation=-90',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});
it('show x brush selection -90 degree', async () => {
await common.expectChartWithDragAtUrlToMatchScreenshot(
'http://localhost:9001/?path=/story/interactions--brush-tool&knob-brush axis=x&knob-chartRotation=-90',
{ x: 100, y: 100 },
{ x: 250, y: 250 },
);
});
});
it('should render corrent tooltip for split and y accessors', async () => {
await common.expectChartWithMouseAtUrlToMatchScreenshot(
Expand Down
4 changes: 4 additions & 0 deletions src/chart_types/xy_chart/renderer/dom/_brush.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
.echBrushTool {
position: absolute;
top: 0;
left: 0;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow: hidden;
pointer-events: none;
Expand Down
184 changes: 165 additions & 19 deletions src/chart_types/xy_chart/state/chart_state.interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { BarSeriesSpec, BasicSeriesSpec, AxisSpec, SeriesTypes } from '../utils/
import { Position } from '../../../utils/commons';
import { ScaleType } from '../../../scales';
import { chartStoreReducer, GlobalChartState } from '../../../state/chart_state';
import { SettingsSpec, DEFAULT_SETTINGS_SPEC, SpecTypes, TooltipType } from '../../../specs';
import { SettingsSpec, DEFAULT_SETTINGS_SPEC, SpecTypes, TooltipType, XYBrushArea, BrushAxis } from '../../../specs';
import { computeSeriesGeometriesSelector } from './selectors/compute_series_geometries';
import { getProjectedPointerPositionSelector } from './selectors/get_projected_pointer_position';
import {
Expand Down Expand Up @@ -788,7 +788,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
});
describe('brush', () => {
test('can respond to a brush end event', () => {
const brushEndListener = jest.fn<void, [number, number]>((): void => {
const brushEndListener = jest.fn<void, [XYBrushArea]>((): void => {
return;
});
const onBrushCaller = createOnBrushEndCaller();
Expand Down Expand Up @@ -833,8 +833,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[0][0]).toBe(0);
expect(brushEndListener.mock.calls[0][1]).toBe(2.5);
expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 2.5] });
}
const start2 = { x: 75, y: 0 };
const end2 = { x: 100, y: 0 };
Expand All @@ -846,8 +845,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[1][0]).toBe(2.5);
expect(brushEndListener.mock.calls[1][1]).toBe(3);
expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [2.5, 3] });
}

const start3 = { x: 75, y: 0 };
Expand All @@ -859,8 +857,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[2][0]).toBe(2.5);
expect(brushEndListener.mock.calls[2][1]).toBe(3);
expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [2.5, 3] });
}

const start4 = { x: 25, y: 0 };
Expand All @@ -872,12 +869,11 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[3][0]).toBe(0);
expect(brushEndListener.mock.calls[3][1]).toBe(0.5);
expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0.5] });
}
});
test('can respond to a brush end event on rotated chart', () => {
const brushEndListener = jest.fn<void, [number, number]>((): void => {
const brushEndListener = jest.fn<void, [XYBrushArea]>((): void => {
return;
});
const onBrushCaller = createOnBrushEndCaller();
Expand Down Expand Up @@ -912,8 +908,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[0][0]).toBe(0);
expect(brushEndListener.mock.calls[0][1]).toBe(1);
expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 1] });
}
const start2 = { x: 0, y: 75 };
const end2 = { x: 0, y: 100 };
Expand All @@ -925,8 +920,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[1][0]).toBe(1);
expect(brushEndListener.mock.calls[1][1]).toBe(1);
expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [1, 1] });
}

const start3 = { x: 0, y: 75 };
Expand All @@ -938,8 +932,7 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[2][0]).toBe(1);
expect(brushEndListener.mock.calls[2][1]).toBe(1); // max of chart
expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [1, 1] }); // max of chart
}

const start4 = { x: 0, y: 25 };
Expand All @@ -951,8 +944,161 @@ function mouseOverTestSuite(scaleType: ScaleType) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[3][0]).toBe(0);
expect(brushEndListener.mock.calls[3][1]).toBe(0);
expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0] });
}
});
test('can respond to a Y brush', () => {
const brushEndListener = jest.fn<void, [XYBrushArea]>((): void => {
return;
});
const onBrushCaller = createOnBrushEndCaller();
store.subscribe(() => {
onBrushCaller(store.getState());
});
const settings = getSettingsSpecSelector(store.getState());
const updatedSettings: SettingsSpec = {
...settings,
brushAxis: BrushAxis.Y,
theme: {
...settings.theme,
chartMargins: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
onBrushEnd: brushEndListener,
};
store.dispatch(upsertSpec(updatedSettings));
store.dispatch(
upsertSpec({
...spec,
data: [
[0, 1],
[1, 1],
[2, 2],
[3, 3],
],
} as BarSeriesSpec),
);
store.dispatch(specParsed());

const start1 = { x: 0, y: 0 };
const end1 = { x: 0, y: 75 };

store.dispatch(onMouseDown(start1, 0));
store.dispatch(onPointerMove(end1, 1));
store.dispatch(onMouseUp(end1, 3));
if (scaleType === ScaleType.Ordinal) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[0][0]).toEqual({
y: [
{
groupId: spec.groupId,
extent: [0.75, 3],
},
],
});
}
const start2 = { x: 0, y: 75 };
const end2 = { x: 0, y: 100 };

store.dispatch(onMouseDown(start2, 4));
store.dispatch(onPointerMove(end2, 5));
store.dispatch(onMouseUp(end2, 6));
if (scaleType === ScaleType.Ordinal) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[1][0]).toEqual({
y: [
{
groupId: spec.groupId,
extent: [0, 0.75],
},
],
});
}
});
test('can respond to rectangular brush', () => {
const brushEndListener = jest.fn<void, [XYBrushArea]>((): void => {
return;
});
const onBrushCaller = createOnBrushEndCaller();
store.subscribe(() => {
onBrushCaller(store.getState());
});
const settings = getSettingsSpecSelector(store.getState());
const updatedSettings: SettingsSpec = {
...settings,
brushAxis: BrushAxis.Both,
theme: {
...settings.theme,
chartMargins: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
onBrushEnd: brushEndListener,
};
store.dispatch(upsertSpec(updatedSettings));
store.dispatch(
upsertSpec({
...spec,
data: [
[0, 1],
[1, 1],
[2, 2],
[3, 3],
],
} as BarSeriesSpec),
);
store.dispatch(specParsed());

const start1 = { x: 0, y: 0 };
const end1 = { x: 75, y: 75 };

store.dispatch(onMouseDown(start1, 0));
store.dispatch(onPointerMove(end1, 1));
store.dispatch(onMouseUp(end1, 3));
if (scaleType === ScaleType.Ordinal) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[0][0]).toEqual({
x: [0, 2.5],
y: [
{
groupId: spec.groupId,
extent: [0.75, 3],
},
],
});
}
const start2 = { x: 75, y: 75 };
const end2 = { x: 100, y: 100 };

store.dispatch(onMouseDown(start2, 4));
store.dispatch(onPointerMove(end2, 5));
store.dispatch(onMouseUp(end2, 6));
if (scaleType === ScaleType.Ordinal) {
expect(brushEndListener).not.toBeCalled();
} else {
expect(brushEndListener).toBeCalled();
expect(brushEndListener.mock.calls[1][0]).toEqual({
x: [2.5, 3],
y: [
{
groupId: spec.groupId,
extent: [0, 0.75],
},
],
});
}
});
});
Expand Down
Loading

0 comments on commit 9e49534

Please sign in to comment.