diff --git a/src/chart_types/xy_chart/state/selectors/get_brush_area.test.ts b/src/chart_types/xy_chart/state/selectors/get_brush_area.test.ts index ea177df54d..c4b8ad9e52 100644 --- a/src/chart_types/xy_chart/state/selectors/get_brush_area.test.ts +++ b/src/chart_types/xy_chart/state/selectors/get_brush_area.test.ts @@ -17,62 +17,301 @@ * under the License. */ -import { Dimensions } from '../../../../utils/dimensions'; -import { getBrushForXAxis, getBrushForYAxis, getBrushForBothAxis } from './get_brush_area'; +import { MockGlobalSpec, MockSeriesSpec } from '../../../../mocks/specs/specs'; +import { MockStore } from '../../../../mocks/store/store'; +import { ScaleType } from '../../../../scales/constants'; +import { BrushAxis } from '../../../../specs/constants'; +import { onMouseDown, onMouseUp, onPointerMove } from '../../../../state/actions/mouse'; +import { getBrushAreaSelector } from './get_brush_area'; -describe('getBrushArea Selector', () => { - it('should return the brush area', () => { - const chartDimensions: Dimensions = { left: 0, top: 0, width: 100, height: 110 }; +describe('getBrushArea selector', () => { + describe('compute brush', () => { + it('along the X axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const xBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); - const xBrushArea = getBrushForXAxis(chartDimensions, 0, { x: 10, y: 10 }, { x: 30, y: 30 }); - expect(xBrushArea).toEqual({ - top: 0, - left: 10, - width: 20, - height: 110, + expect(onBrushEnd).toHaveBeenCalled(); + expect(onBrushEnd.mock.calls[0][0].x[0]).toBeCloseTo(0.1); + expect(onBrushEnd.mock.calls[0][0].x[1]).toBeCloseTo(0.3); + + expect(xBrushArea).toEqual({ + top: 0, + left: 10, + width: 20, + height: 100, + }); }); - const yBrushArea = getBrushForYAxis(chartDimensions, 0, { x: 10, y: 10 }, { x: 30, y: 30 }); - expect(yBrushArea).toEqual({ - top: 10, - left: 0, - width: 100, - height: 20, + + it('along the Y axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd, brushAxis: BrushAxis.Y }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const yBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); + + expect(onBrushEnd).toHaveBeenCalled(); + const brushData = onBrushEnd.mock.calls[0][0].y; + + expect(brushData[0].extent[0]).toBeCloseTo(7); + expect(brushData[0].extent[1]).toBeCloseTo(9); + + expect(yBrushArea).toEqual({ + top: 10, + left: 0, + width: 100, + height: 20, + }); }); - const bothBrushArea = getBrushForBothAxis(chartDimensions, { x: 10, y: 10 }, { x: 30, y: 30 }); - expect(bothBrushArea).toEqual({ - top: 10, - left: 10, - width: 20, - height: 20, + it('along both axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd, brushAxis: BrushAxis.Both }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const bothBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); + + expect(onBrushEnd).toHaveBeenCalled(); + + expect(onBrushEnd.mock.calls[0][0].x[0]).toBeCloseTo(0.1); + expect(onBrushEnd.mock.calls[0][0].x[1]).toBeCloseTo(0.3); + expect(onBrushEnd.mock.calls[0][0].y[0].extent[0]).toBeCloseTo(7); + expect(onBrushEnd.mock.calls[0][0].y[0].extent[1]).toBeCloseTo(9); + + expect(bothBrushArea).toEqual({ + top: 10, + left: 10, + width: 20, + height: 20, + }); }); }); - it('should return the brush area on rotated chart', () => { - const chartDimensions: Dimensions = { left: 0, top: 0, width: 100, height: 110 }; + describe('compute brush on a rotated chart', () => { + it('along the X axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd, rotation: 90 }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const xBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); - const brushArea = getBrushForXAxis(chartDimensions, 90, { x: 10, y: 10 }, { x: 30, y: 30 }); - expect(brushArea).toEqual({ - top: 10, - left: 0, - width: 100, - height: 20, + expect(onBrushEnd).toHaveBeenCalled(); + expect(onBrushEnd.mock.calls[0][0].x[0]).toBeCloseTo(0.1); + expect(onBrushEnd.mock.calls[0][0].x[1]).toBeCloseTo(0.3); + + expect(xBrushArea).toEqual({ + top: 10, + left: 0, + width: 100, + height: 20, + }); }); - const yBrushArea = getBrushForYAxis(chartDimensions, 90, { x: 10, y: 10 }, { x: 30, y: 30 }); - expect(yBrushArea).toEqual({ - top: 0, - left: 10, - width: 20, - height: 110, + it('along the Y axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd, brushAxis: BrushAxis.Y, rotation: 90 }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const yBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); + + expect(onBrushEnd).toHaveBeenCalled(); + const brushData = onBrushEnd.mock.calls[0][0].y; + + expect(brushData[0].extent[0]).toBeCloseTo(1); + expect(brushData[0].extent[1]).toBeCloseTo(3); + + expect(yBrushArea).toEqual({ + top: 0, + left: 10, + width: 20, + height: 100, + }); }); - const bothBrushArea = getBrushForBothAxis(chartDimensions, { x: 10, y: 10 }, { x: 30, y: 30 }); + it('along both axis', () => { + const store = MockStore.default({ left: 0, top: 0, width: 100, height: 100 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd, brushAxis: BrushAxis.Both, rotation: 90 }), + MockSeriesSpec.line({ + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + ], + store, + ); + store.dispatch(onMouseDown({ x: 10, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 30, y: 30 }, 1000)); + const bothBrushArea = getBrushAreaSelector(store.getState()); + store.dispatch(onMouseUp({ x: 30, y: 30 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); + + expect(onBrushEnd).toHaveBeenCalled(); + + expect(onBrushEnd.mock.calls[0][0].x[0]).toBeCloseTo(0.1); + expect(onBrushEnd.mock.calls[0][0].x[1]).toBeCloseTo(0.3); + expect(onBrushEnd.mock.calls[0][0].y[0].extent[0]).toBeCloseTo(1); + expect(onBrushEnd.mock.calls[0][0].y[0].extent[1]).toBeCloseTo(3); + + expect(bothBrushArea).toEqual({ + top: 10, + left: 10, + width: 20, + height: 20, + }); + }); + }); + + describe('limit brush to single panel w/small multiples', () => { + const store = MockStore.default({ left: 0, top: 0, width: 200, height: 200 }); + const onBrushEnd = jest.fn(); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins({ onBrushEnd }), + MockGlobalSpec.groupBy({ id: 'hSplit' }), + MockGlobalSpec.groupBy({ id: 'vSplit' }), + MockGlobalSpec.smallMultiple({ splitHorizontally: 'hSplit', splitVertically: 'vSplit' }), + MockSeriesSpec.line({ + id: '1', + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 10], + [0.5, 5], + [1, 3], + ], + }), + MockSeriesSpec.line({ + id: '2', + xAccessor: 0, + yAccessors: [1], + xScaleType: ScaleType.Linear, + data: [ + [0, 5], + [0.5, 2], + [1, 6], + ], + }), + ], + store, + ); + + store.dispatch(onMouseDown({ x: 150, y: 10 }, 0)); + store.dispatch(onPointerMove({ x: 10, y: 150 }, 1000)); + const bothBrushArea = getBrushAreaSelector(store.getState()); expect(bothBrushArea).toEqual({ - top: 10, - left: 10, - width: 20, - height: 20, + top: 0, + left: 150, + width: -50, + height: 100, }); + + store.dispatch(onMouseUp({ x: 10, y: 150 }, 1100)); + store.getState().internalChartState?.eventCallbacks(store.getState()); + + expect(onBrushEnd).toHaveBeenCalled(); + + expect(onBrushEnd.mock.calls[0][0].x[0]).toBeCloseTo(0); + expect(onBrushEnd.mock.calls[0][0].x[1]).toBeCloseTo(0.5); }); }); diff --git a/src/chart_types/xy_chart/state/selectors/get_brush_area.ts b/src/chart_types/xy_chart/state/selectors/get_brush_area.ts index ddde97975f..3de1d554df 100644 --- a/src/chart_types/xy_chart/state/selectors/get_brush_area.ts +++ b/src/chart_types/xy_chart/state/selectors/get_brush_area.ts @@ -24,15 +24,16 @@ import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getChartRotationSelector } from '../../../../state/selectors/get_chart_rotation'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { Rotation } from '../../../../utils/common'; +import { clamp, Rotation } from '../../../../utils/common'; import { Dimensions } from '../../../../utils/dimensions'; import { Point } from '../../../../utils/point'; import { isVerticalRotation } from '../utils/common'; import { computeChartDimensionsSelector } from './compute_chart_dimensions'; +import { computeSmallMultipleScalesSelector, SmallMultipleScales } from './compute_small_multiple_scales'; const MIN_AREA_SIZE = 1; -const getMouseDownPosition = (state: GlobalChartState) => state.interactions.pointer.down; +const getMouseDownPosition = (state: GlobalChartState) => state.interactions.pointer.down?.position; const getCurrentPointerPosition = (state: GlobalChartState) => state.interactions.pointer.current.position; /** @internal */ @@ -43,81 +44,124 @@ export const getBrushAreaSelector = createCachedSelector( getChartRotationSelector, computeChartDimensionsSelector, getSettingsSpecSelector, + computeSmallMultipleScalesSelector, ], - (mouseDownPosition, end, chartRotation, { chartDimensions }, { brushAxis }): Dimensions | null => { - if (!mouseDownPosition) { + (start, end, chartRotation, { chartDimensions }, { brushAxis }, smallMultipleScales): Dimensions | null => { + if (!start) { return null; } - const start = { - x: mouseDownPosition.position.x, - y: mouseDownPosition.position.y, - }; + const plotStartPointPx = getPlotAreaRestrictedPoint(start, chartDimensions); + const plotEndPointPx = getPlotAreaRestrictedPoint(end, chartDimensions); + const panelPoints = getPointsConstraintToSinglePanel(plotStartPointPx, plotEndPointPx, smallMultipleScales); + switch (brushAxis) { case BrushAxis.Y: - return getBrushForYAxis(chartDimensions, chartRotation, start, end); + return getBrushForYAxis(chartRotation, panelPoints); case BrushAxis.Both: - return getBrushForBothAxis(chartDimensions, start, end); + return getBrushForBothAxis(panelPoints); case BrushAxis.X: default: - return getBrushForXAxis(chartDimensions, chartRotation, start, end); + return getBrushForXAxis(chartRotation, panelPoints); } }, )(getChartIdSelector); /** @internal */ -export function getBrushForXAxis(chartDimensions: Dimensions, chartRotation: Rotation, start: Point, end: Point) { - const rotated = isVerticalRotation(chartRotation); +export type PanelPoints = { + start: Point; + end: Point; + hPanelStart: number; + hPanelWidth: number; + vPanelStart: number; + vPanelHeight: number; +}; + +/** @internal */ +export function getPointsConstraintToSinglePanel( + startPlotPoint: Point, + endPlotPoint: Point, + { horizontal, vertical }: SmallMultipleScales, +): PanelPoints { + const hPanel = horizontal.invert(startPlotPoint.x); + const vPanel = vertical.invert(startPlotPoint.y); + + const hPanelStart = horizontal.scale(hPanel) ?? 0; + const hPanelEnd = hPanelStart + horizontal.bandwidth; + + const vPanelStart = vertical.scale(vPanel) ?? 0; + const vPanelEnd = vPanelStart + vertical.bandwidth; + + const start = { + x: clamp(startPlotPoint.x, hPanelStart, hPanelEnd), + y: clamp(startPlotPoint.y, vPanelStart, vPanelEnd), + }; + const end = { + x: clamp(endPlotPoint.x, hPanelStart, hPanelEnd), + y: clamp(endPlotPoint.y, vPanelStart, vPanelEnd), + }; + return { - left: rotated ? 0 : getLeftPoint(chartDimensions, start), - top: rotated ? getTopPoint(chartDimensions, start) : 0, - height: rotated ? getMinimalHeight(start, end) : chartDimensions.height, - width: rotated ? chartDimensions.width : getMinimalWidth(start, end), + start, + end, + hPanelStart, + hPanelWidth: horizontal.bandwidth, + vPanelStart, + vPanelHeight: vertical.bandwidth, }; } /** @internal */ -export function getBrushForYAxis(chartDimensions: Dimensions, chartRotation: Rotation, start: Point, end: Point) { - const rotated = isVerticalRotation(chartRotation); +export function getPlotAreaRestrictedPoint({ x, y }: Point, { left, top }: Dimensions) { return { - left: rotated ? getLeftPoint(chartDimensions, start) : 0, - top: rotated ? 0 : getTopPoint(chartDimensions, start), - height: rotated ? chartDimensions.height : getMinimalHeight(start, end), - width: rotated ? getMinimalWidth(start, end) : chartDimensions.width, + x: x - left, + y: y - top, }; } /** @internal */ -export function getBrushForBothAxis(chartDimensions: Dimensions, start: Point, end: Point) { +export function getBrushForXAxis( + chartRotation: Rotation, + { hPanelStart, vPanelStart, hPanelWidth, vPanelHeight, start, end }: PanelPoints, +) { + const rotated = isVerticalRotation(chartRotation); + return { - left: getLeftPoint(chartDimensions, start), - top: getTopPoint(chartDimensions, start), - height: getMinimalHeight(start, end), - width: getMinimalWidth(start, end), + left: rotated ? hPanelStart : start.x, + top: rotated ? start.y : vPanelStart, + height: rotated ? getMinSize(start.y, end.y) : vPanelHeight, + width: rotated ? hPanelWidth : getMinSize(start.x, end.x), }; } /** @internal */ -export function getLeftPoint({ left }: Dimensions, { x }: Point) { - return x - left; -} +export function getBrushForYAxis( + chartRotation: Rotation, + { hPanelStart, vPanelStart, hPanelWidth, vPanelHeight, start, end }: PanelPoints, +) { + const rotated = isVerticalRotation(chartRotation); -/** @internal */ -export function getTopPoint({ top }: Dimensions, { y }: Point) { - return y - top; + return { + left: rotated ? start.x : hPanelStart, + top: rotated ? vPanelStart : start.y, + height: rotated ? vPanelHeight : getMinSize(start.y, end.y), + width: rotated ? getMinSize(start.x, end.x) : hPanelWidth, + }; } -function getMinimalHeight(start: Point, end: Point, min = MIN_AREA_SIZE) { - const height = end.y - start.y; - if (Math.abs(height) < min) { - return min; - } - return height; +/** @internal */ +export function getBrushForBothAxis({ start, end }: PanelPoints) { + return { + left: start.x, + top: start.y, + height: getMinSize(start.y, end.y), + width: getMinSize(start.x, end.x), + }; } -function getMinimalWidth(start: Point, end: Point, min = MIN_AREA_SIZE) { - const width = end.x - start.x; - if (Math.abs(width) < min) { - return min; +function getMinSize(a: number, b: number, minSize = MIN_AREA_SIZE) { + const size = b - a; + if (Math.abs(size) < minSize) { + return minSize; } - return width; + return size; } diff --git a/src/chart_types/xy_chart/state/selectors/on_brush_end_caller.ts b/src/chart_types/xy_chart/state/selectors/on_brush_end_caller.ts index 89011c1bac..08fea67e8f 100644 --- a/src/chart_types/xy_chart/state/selectors/on_brush_end_caller.ts +++ b/src/chart_types/xy_chart/state/selectors/on_brush_end_caller.ts @@ -25,6 +25,7 @@ import { Scale } from '../../../../scales'; import { GroupBrushExtent, XYBrushArea } from '../../../../specs'; import { BrushAxis } from '../../../../specs/constants'; import { DragState, GlobalChartState } from '../../../../state/chart_state'; +import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; import { maxValueWithUpperLimit, minValueWithLowerLimit, Rotation } from '../../../../utils/common'; import { Dimensions } from '../../../../utils/dimensions'; @@ -32,7 +33,8 @@ import { hasDragged, DragCheckProps } from '../../../../utils/events'; import { GroupId } from '../../../../utils/ids'; import { isVerticalRotation } from '../utils/common'; import { computeChartDimensionsSelector } from './compute_chart_dimensions'; -import { getLeftPoint, getTopPoint } from './get_brush_area'; +import { computeSmallMultipleScalesSelector, SmallMultipleScales } from './compute_small_multiple_scales'; +import { getPlotAreaRestrictedPoint, getPointsConstraintToSinglePanel, PanelPoints } from './get_brush_area'; import { getComputedScalesSelector } from './get_computed_scales'; import { isBrushAvailableSelector } from './is_brush_available'; import { isHistogramModeEnabledSelector } from './is_histogram_mode_enabled'; @@ -62,6 +64,7 @@ export function createOnBrushEndCaller(): (state: GlobalChartState) => void { getComputedScalesSelector, computeChartDimensionsSelector, isHistogramModeEnabledSelector, + computeSmallMultipleScalesSelector, ], ( lastDrag, @@ -76,12 +79,12 @@ export function createOnBrushEndCaller(): (state: GlobalChartState) => void { computedScales, { chartDimensions }, histogramMode, + smallMultipleScales, ): void => { const nextProps = { lastDrag, onBrushEnd, }; - if (lastDrag !== null && hasDragged(prevProps, nextProps) && onBrushEnd) { const brushArea: XYBrushArea = {}; const { yScales, xScale } = computedScales; @@ -93,13 +96,21 @@ export function createOnBrushEndCaller(): (state: GlobalChartState) => void { rotation, histogramMode, xScale, + smallMultipleScales, minBrushDelta, roundHistogramBrushValues, allowBrushingLastHistogramBucket, ); } if (brushAxis === BrushAxis.Y || brushAxis === BrushAxis.Both) { - brushArea.y = getYBrushExtents(chartDimensions, lastDrag, rotation, yScales, minBrushDelta); + brushArea.y = getYBrushExtents( + chartDimensions, + lastDrag, + rotation, + yScales, + smallMultipleScales, + minBrushDelta, + ); } if (brushArea.x !== undefined || brushArea.y !== undefined) { onBrushEnd(brushArea); @@ -107,9 +118,7 @@ export function createOnBrushEndCaller(): (state: GlobalChartState) => void { } prevProps = nextProps; }, - )({ - keySelector: (state: GlobalChartState) => state.chartId, - }); + )(getChartIdSelector); } if (selector) { selector(state); @@ -117,31 +126,40 @@ export function createOnBrushEndCaller(): (state: GlobalChartState) => void { }; } +function scalePanelPointsToPanelCoordinates( + scaleXPoint: boolean, + { start, end, vPanelStart, hPanelStart, vPanelHeight, hPanelWidth }: PanelPoints, +) { + // scale screen coordinates down to panel scale + const startPos = scaleXPoint ? start.x - hPanelStart : start.y - vPanelStart; + const endPos = scaleXPoint ? end.x - hPanelStart : end.y - vPanelStart; + const panelMax = scaleXPoint ? hPanelWidth : vPanelHeight; + return { + minPos: Math.min(startPos, endPos), + maxPos: Math.max(startPos, endPos), + panelMax, + }; +} + function getXBrushExtent( chartDimensions: Dimensions, lastDrag: DragState, rotation: Rotation, histogramMode: boolean, xScale: Scale, + smallMultipleScales: SmallMultipleScales, minBrushDelta?: number, roundHistogramBrushValues?: boolean, allowBrushingLastHistogramBucket?: boolean, ): [number, number] | undefined { - let startPos = getLeftPoint(chartDimensions, lastDrag.start.position); - let endPos = getLeftPoint(chartDimensions, lastDrag.end.position); - let chartMax = chartDimensions.width; - - if (isVerticalRotation(rotation)) { - startPos = getTopPoint(chartDimensions, lastDrag.start.position); - endPos = getTopPoint(chartDimensions, lastDrag.end.position); - chartMax = chartDimensions.height; - } - - let minPos = minValueWithLowerLimit(startPos, endPos, 0); - let maxPos = maxValueWithUpperLimit(startPos, endPos, chartMax); + const isXHorizontal = !isVerticalRotation(rotation); + // scale screen coordinates down to panel scale + const scaledPanelPoints = getMinMaxPos(chartDimensions, lastDrag, smallMultipleScales, isXHorizontal); + let { minPos, maxPos } = scaledPanelPoints; + // reverse the positions if chart is mirrored if (rotation === -90 || rotation === 180) { - minPos = chartMax - minPos; - maxPos = chartMax - maxPos; + minPos = scaledPanelPoints.panelMax - minPos; + maxPos = scaledPanelPoints.panelMax - maxPos; } if (minBrushDelta !== undefined ? Math.abs(maxPos - minPos) < minBrushDelta : maxPos === minPos) { // if 0 size brush, avoid computing the value @@ -163,28 +181,41 @@ function getXBrushExtent( return [minValue, maxValue]; } +function getMinMaxPos( + chartDimensions: Dimensions, + lastDrag: DragState, + smallMultipleScales: SmallMultipleScales, + scaleXPoint: boolean, +) { + const panelPoints = getPanelPoints(chartDimensions, lastDrag, smallMultipleScales); + // scale screen coordinates down to panel scale + return scalePanelPointsToPanelCoordinates(scaleXPoint, panelPoints); +} + +function getPanelPoints(chartDimensions: Dimensions, lastDrag: DragState, smallMultipleScales: SmallMultipleScales) { + const plotStartPointPx = getPlotAreaRestrictedPoint(lastDrag.start.position, chartDimensions); + const plotEndPointPx = getPlotAreaRestrictedPoint(lastDrag.end.position, chartDimensions); + return getPointsConstraintToSinglePanel(plotStartPointPx, plotEndPointPx, smallMultipleScales); +} + function getYBrushExtents( chartDimensions: Dimensions, lastDrag: DragState, rotation: Rotation, yScales: Map, + smallMultipleScales: SmallMultipleScales, minBrushDelta?: number, ): GroupBrushExtent[] | undefined { const yValues: GroupBrushExtent[] = []; yScales.forEach((yScale, groupId) => { - let startPos = getTopPoint(chartDimensions, lastDrag.start.position); - let endPos = getTopPoint(chartDimensions, lastDrag.end.position); - let chartMax = chartDimensions.height; - if (isVerticalRotation(rotation)) { - startPos = getLeftPoint(chartDimensions, lastDrag.start.position); - endPos = getLeftPoint(chartDimensions, lastDrag.end.position); - chartMax = chartDimensions.width; - } - let minPos = minValueWithLowerLimit(startPos, endPos, 0); - let maxPos = maxValueWithUpperLimit(startPos, endPos, chartMax); - if (rotation === -90 || rotation === 180) { - minPos = chartMax - minPos; - maxPos = chartMax - maxPos; + const isXVertical = isVerticalRotation(rotation); + // scale screen coordinates down to panel scale + const scaledPanelPoints = getMinMaxPos(chartDimensions, lastDrag, smallMultipleScales, isXVertical); + let { minPos, maxPos } = scaledPanelPoints; + + if (rotation === 90 || rotation === 180) { + minPos = scaledPanelPoints.panelMax - minPos; + maxPos = scaledPanelPoints.panelMax - maxPos; } if (minBrushDelta !== undefined ? Math.abs(maxPos - minPos) < minBrushDelta : maxPos === minPos) { // if 0 size brush, avoid computing the value diff --git a/src/mocks/specs/specs.ts b/src/mocks/specs/specs.ts index e99d5815af..7fc196e497 100644 --- a/src/mocks/specs/specs.ts +++ b/src/mocks/specs/specs.ts @@ -18,6 +18,7 @@ */ import { ChartTypes } from '../../chart_types'; +import { Predicate } from '../../chart_types/heatmap/utils/common'; import { config, percentFormatter } from '../../chart_types/partition_chart/layout/config'; import { PartitionLayout } from '../../chart_types/partition_chart/layout/types/config_types'; import { ShapeTreeNode } from '../../chart_types/partition_chart/layout/types/viewmodel_types'; @@ -41,7 +42,7 @@ import { AxisSpec, } from '../../chart_types/xy_chart/utils/specs'; import { ScaleType } from '../../scales/constants'; -import { SettingsSpec, SpecTypes, DEFAULT_SETTINGS_SPEC } from '../../specs'; +import { SettingsSpec, SpecTypes, DEFAULT_SETTINGS_SPEC, SmallMultiplesSpec, GroupBySpec, Spec } from '../../specs'; import { Datum, mergePartial, Position, RecursivePartial } from '../../utils/common'; import { LIGHT_THEME } from '../../utils/themes/light_theme'; @@ -293,6 +294,24 @@ export class MockGlobalSpec { }, }; + private static readonly smallMultipleBase: SmallMultiplesSpec = { + id: 'smallMultiple', + chartType: ChartTypes.Global, + specType: SpecTypes.SmallMultiples, + style: { + verticalPanelPadding: [0, 0], + horizontalPanelPadding: [0, 0], + }, + }; + + private static readonly groupByBase: GroupBySpec = { + id: 'groupBy', + chartType: ChartTypes.Global, + specType: SpecTypes.IndexOrder, + by: ({ id }: Spec) => id, + sort: Predicate.DataIndex, + }; + static settings(partial?: Partial): SettingsSpec { return mergePartial(MockGlobalSpec.settingsBase, partial, { mergeOptionalPartialValues: true }); } @@ -306,6 +325,18 @@ export class MockGlobalSpec { static axis(partial?: Partial): AxisSpec { return mergePartial(MockGlobalSpec.axisBase, partial, { mergeOptionalPartialValues: true }); } + + static smallMultiple(partial?: Partial): SmallMultiplesSpec { + return mergePartial(MockGlobalSpec.smallMultipleBase, partial, { + mergeOptionalPartialValues: true, + }); + } + + static groupBy(partial?: Partial): GroupBySpec { + return mergePartial(MockGlobalSpec.groupByBase, partial, { + mergeOptionalPartialValues: true, + }); + } } /** @internal */ diff --git a/src/utils/common.ts b/src/utils/common.ts index 710cfeaed7..6f4d3caaa6 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -126,6 +126,11 @@ export function compareByValueAsc(a: number | string, b: number | string): numbe return a > b ? 1 : -1; } +/** @internal */ +export function clamp(value: number, lowerBound: number, upperBound: number) { + return minValueWithLowerLimit(value, upperBound, lowerBound); +} + /** * Return the minimum value between val1 and val2. The value is bounded from below by lowerLimit * @param val1 a numeric value diff --git a/stories/interactions/9a_brush_selection_linear.tsx b/stories/interactions/9a_brush_selection_linear.tsx index 58e6f7775e..dbbe87b669 100644 --- a/stories/interactions/9a_brush_selection_linear.tsx +++ b/stories/interactions/9a_brush_selection_linear.tsx @@ -36,7 +36,7 @@ export const Example = () => { ); return ( - + Number(d).toFixed(2)} /> diff --git a/stories/small_multiples/1_grid.tsx b/stories/small_multiples/1_grid.tsx index ba83f43ba4..9bad74fd46 100644 --- a/stories/small_multiples/1_grid.tsx +++ b/stories/small_multiples/1_grid.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { action } from '@storybook/addon-actions'; import { boolean } from '@storybook/addon-knobs'; import React, { useState } from 'react'; @@ -91,6 +92,7 @@ export const Example = () => { }, }, }} + onBrushEnd={action('brushEvent')} /> { export const Example = () => { const showLegend = boolean('Show Legend', true); const onElementClick = action('onElementClick'); + const tickTimeFormatter = timeFormatter(niceTimeFormatByDay(numOfDays)); return ( - + { + if (d.x) { + action('brushEventX')(tickTimeFormatter(d.x[0] ?? 0), tickTimeFormatter(d.x[1] ?? 0), d.y); + } + }} + brushAxis={BrushAxis.X} + /> ({ visible: false, }, }); +const tickTimeFormatter = timeFormatter(niceTimeFormatByDay(numOfDays)); const getAxisOptions = ( position: Position, @@ -77,7 +78,7 @@ const getAxisOptions = ( id: position, position, ticks: isVertical ? 2 : undefined, - tickFormat: isVertical ? (d) => d.toFixed(2) : timeFormatter(niceTimeFormatByDay(numOfDays)), + tickFormat: isVertical ? (d) => d.toFixed(2) : tickTimeFormatter, domain: isVertical ? { max: 10, @@ -114,6 +115,11 @@ export const Example = () => { }, }, }} + onBrushEnd={(d) => { + if (d.x) { + action('brushEvent')(tickTimeFormatter(d.x[0] ?? 0), tickTimeFormatter(d.x[1] ?? 0)); + } + }} />