From aa068f6ea6046ab2fec8aa76373b0249f3668a29 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Mon, 28 Jun 2021 23:18:30 -0500 Subject: [PATCH] feat(xy): add onPointerUpdate debounce and trigger options (#1194) adds `pointerUpdateDebounce` and `pointerUpdateTrigger` to `Settings` spec to control debounce timing and what value changes trigger the listener. Adds projected values to all `onPointerUpdate` events. BREAKING CHANGE: the `PointerOverEvent` type now extends `ProjectedValues` and drops value. This effectively replaces value with `x`, `y`, `smVerticalValue` and `smHorizontalValue`. --- .../integration/tests/interactions.test.ts | 6 +- packages/osd-charts/package.json | 2 +- .../packages/charts/api/charts.api.md | 21 +- .../osd-charts/packages/charts/package.json | 2 +- .../charts/src/__mocks__/ts-debounce.ts | 27 + .../state/chart_state.interactions.test.ts | 1666 +++++++++-------- .../state/selectors/get_cursor_band.ts | 4 +- .../selectors/get_elements_at_cursor_pos.ts | 4 +- .../get_projected_pointer_position.ts | 4 +- .../selectors/get_projected_scaled_values.ts | 2 +- .../get_tooltip_values_highlighted_geoms.ts | 2 +- .../state/selectors/on_pointer_move_caller.ts | 47 +- .../__snapshots__/chart.test.tsx.snap | 2 +- .../packages/charts/src/mocks/store/store.ts | 32 +- .../packages/charts/src/specs/constants.ts | 13 + .../charts/src/specs/settings.test.tsx | 18 +- .../packages/charts/src/specs/settings.tsx | 42 +- .../src/state/selectors/get_settings_specs.ts | 14 +- .../selectors/is_external_tooltip_visible.ts | 2 +- .../interactions/16_cursor_update_action.tsx | 16 +- packages/osd-charts/yarn.lock | 8 +- 21 files changed, 1080 insertions(+), 854 deletions(-) create mode 100644 packages/osd-charts/packages/charts/src/__mocks__/ts-debounce.ts diff --git a/packages/osd-charts/integration/tests/interactions.test.ts b/packages/osd-charts/integration/tests/interactions.test.ts index 7cfb4abda83a..bc18fe3f670b 100644 --- a/packages/osd-charts/integration/tests/interactions.test.ts +++ b/packages/osd-charts/integration/tests/interactions.test.ts @@ -240,7 +240,7 @@ describe('Interactions', () => { describe('Tooltip sync', () => { it('show synced tooltips', async () => { await common.expectChartWithMouseAtUrlToMatchScreenshot( - 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-local%20tooltip%20type_Top%20Chart=vertical&knob-local%20tooltip%20type_Bottom%20Chart=vertical&knob-enable%20external%20tooltip_Top%20Chart=true&knob-enable%20external%20tooltip_Bottom%20Chart=true&knob-external%20tooltip%20placement_Top%20Chart=left&knob-external%20tooltip%20placement_Bottom%20Chart=left', + 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-local%20tooltip%20type_Top%20Chart=vertical&knob-local%20tooltip%20type_Bottom%20Chart=vertical&knob-enable%20external%20tooltip_Top%20Chart=true&knob-enable%20external%20tooltip_Bottom%20Chart=true&knob-external%20tooltip%20placement_Top%20Chart=left&knob-external%20tooltip%20placement_Bottom%20Chart=left&knob-pointer update debounce=0', { right: 200, top: 80 }, { screenshotSelector: '#story-root', @@ -250,7 +250,7 @@ describe('Interactions', () => { it('show synced crosshairs', async () => { await common.expectChartWithMouseAtUrlToMatchScreenshot( - 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-local%20tooltip%20type_Top%20Chart=vertical&knob-local%20tooltip%20type_Bottom%20Chart=vertical&knob-enable%20external%20tooltip_Top%20Chart=true&knob-enable%20external%20tooltip_Bottom%20Chart=false&knob-external%20tooltip%20placement_Top%20Chart=left&knob-external%20tooltip%20placement_Bottom%20Chart=left', + 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-local%20tooltip%20type_Top%20Chart=vertical&knob-local%20tooltip%20type_Bottom%20Chart=vertical&knob-enable%20external%20tooltip_Top%20Chart=true&knob-enable%20external%20tooltip_Bottom%20Chart=false&knob-external%20tooltip%20placement_Top%20Chart=left&knob-external%20tooltip%20placement_Bottom%20Chart=left&knob-pointer update debounce=0', { right: 200, top: 80 }, { screenshotSelector: '#story-root', @@ -260,7 +260,7 @@ describe('Interactions', () => { it('show synced extra values in legend', async () => { await common.expectChartWithMouseAtUrlToMatchScreenshot( - 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-Series type_Top Chart=line&knob-enable external tooltip_Top Chart=true&knob-Series type_Bottom Chart=line&knob-enable external tooltip_Bottom Chart=false', + 'http://localhost:9001/?path=/story/interactions--cursor-update-action&knob-Series type_Top Chart=line&knob-enable external tooltip_Top Chart=true&knob-Series type_Bottom Chart=line&knob-enable external tooltip_Bottom Chart=false&knob-pointer update debounce=0', { right: 200, top: 80 }, { screenshotSelector: '#story-root', diff --git a/packages/osd-charts/package.json b/packages/osd-charts/package.json index b51fc3ec6db1..6e9a98e80577 100644 --- a/packages/osd-charts/package.json +++ b/packages/osd-charts/package.json @@ -72,7 +72,7 @@ "redux": "^4.0.4", "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", - "ts-debounce": "^1.0.0", + "ts-debounce": "^3.0.0", "utility-types": "^3.10.0", "uuid": "^3.3.2" }, diff --git a/packages/osd-charts/packages/charts/api/charts.api.md b/packages/osd-charts/packages/charts/api/charts.api.md index 5ef0f39bfc7d..a50731c97399 100644 --- a/packages/osd-charts/packages/charts/api/charts.api.md +++ b/packages/osd-charts/packages/charts/api/charts.api.md @@ -626,7 +626,7 @@ export const DEFAULT_TOOLTIP_SNAP = true; export const DEFAULT_TOOLTIP_TYPE: "vertical"; // @public (undocumented) -export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'animateData' | 'debug' | 'tooltip' | 'theme' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents' | 'showLegend' | 'showLegendExtra' | 'legendPosition' | 'legendMaxDepth' | 'ariaUseDefaultSummary' | 'ariaLabelHeadingLevel' | 'ariaTableCaption'; +export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'pointerUpdateDebounce' | 'pointerUpdateTrigger' | 'animateData' | 'debug' | 'tooltip' | 'theme' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents' | 'showLegend' | 'showLegendExtra' | 'legendPosition' | 'legendMaxDepth' | 'ariaUseDefaultSummary' | 'ariaLabelHeadingLevel' | 'ariaTableCaption'; // @public (undocumented) export const DEPTH_KEY = "depth"; @@ -1473,20 +1473,28 @@ export interface PointerOutEvent extends BasePointerEvent { } // @public -export interface PointerOverEvent extends BasePointerEvent { +export interface PointerOverEvent extends BasePointerEvent, ProjectedValues { // (undocumented) scale: ScaleContinuousType | ScaleOrdinalType; // (undocumented) type: typeof PointerEventType.Over; // @alpha unit?: string; - // (undocumented) - value: number | string | null; } -// @public (undocumented) +// @public export type PointerUpdateListener = (event: PointerEvent_2) => void; +// @public +export const PointerUpdateTrigger: Readonly<{ + X: "x"; + Y: "y"; + Both: "both"; +}>; + +// @public (undocumented) +export type PointerUpdateTrigger = $Values; + // @public (undocumented) export const PointShape: Readonly<{ Circle: "circle"; @@ -1798,9 +1806,10 @@ export interface SettingsSpec extends Spec, LegendSpec { orderOrdinalBinsBy?: OrderBy; // (undocumented) pointBuffer?: MarkBuffer; + pointerUpdateDebounce?: number; + pointerUpdateTrigger: PointerUpdateTrigger; // (undocumented) rendering: Rendering; - // (undocumented) resizeDebounce?: number; // (undocumented) rotation: Rotation; diff --git a/packages/osd-charts/packages/charts/package.json b/packages/osd-charts/packages/charts/package.json index a984e98fb992..9582aecbb6d9 100644 --- a/packages/osd-charts/packages/charts/package.json +++ b/packages/osd-charts/packages/charts/package.json @@ -50,7 +50,7 @@ "redux": "^4.0.4", "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", - "ts-debounce": "^1.0.0", + "ts-debounce": "^3.0.0", "utility-types": "^3.10.0", "uuid": "^3.3.2" }, diff --git a/packages/osd-charts/packages/charts/src/__mocks__/ts-debounce.ts b/packages/osd-charts/packages/charts/src/__mocks__/ts-debounce.ts new file mode 100644 index 000000000000..603e8c99a139 --- /dev/null +++ b/packages/osd-charts/packages/charts/src/__mocks__/ts-debounce.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable unicorn/filename-case */ + +import { debounce as debounceLodash } from 'lodash'; + +// Need ability to flush debouncer in unit tests. Otherwise functions the same +export const debounce = debounceLodash; + +/* eslint-enable unicorn/filename-case */ diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/chart_state.interactions.test.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/chart_state.interactions.test.ts index f0b3134c2738..cddc65a4af78 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/chart_state.interactions.test.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/chart_state.interactions.test.ts @@ -27,7 +27,7 @@ import { Rect } from '../../../geoms/types'; import { MockGlobalSpec, MockSeriesSpec } from '../../../mocks/specs/specs'; import { MockStore } from '../../../mocks/store'; import { ScaleType } from '../../../scales/constants'; -import { SettingsSpec, XScaleType, XYBrushArea } from '../../../specs'; +import { SettingsSpec, XYBrushArea } from '../../../specs'; import { SpecType, TooltipType, BrushAxis } from '../../../specs/constants'; import { onExternalPointerEvent } from '../../../state/actions/events'; import { onPointerMove, onMouseDown, onMouseUp } from '../../../state/actions/mouse'; @@ -204,867 +204,937 @@ describe('Chart state pointer interactions', () => { expect(isTooltipVisible.visible).toBe(true); }); - describe('mouse over with Ordinal scale', () => { - mouseOverTestSuite(ScaleType.Ordinal); - }); - describe('mouse over with Linear scale', () => { - mouseOverTestSuite(ScaleType.Linear); - }); - - it.todo('add test for point series'); - it.todo('add test for mixed series'); - it.todo('add test for clicks'); -}); + describe.each([ScaleType.Ordinal, ScaleType.Linear])('mouse over with %s scale', (scaleType) => { + let store: Store; + let onOverListener: jest.Mock; + let onOutListener: jest.Mock; + let onPointerUpdateListener: jest.Mock; + const spec = scaleType === ScaleType.Ordinal ? ordinalBarSeries : linearBarSeries; -function mouseOverTestSuite(scaleType: XScaleType) { - let store: Store; - let onOverListener: jest.Mock; - let onOutListener: jest.Mock; - let onPointerUpdateListener: jest.Mock; - const spec = scaleType === ScaleType.Ordinal ? ordinalBarSeries : linearBarSeries; - beforeEach(() => { - store = initStore(spec); - onOverListener = jest.fn((): undefined => undefined); - onOutListener = jest.fn((): undefined => undefined); - onPointerUpdateListener = jest.fn((): undefined => undefined); - const settingsWithListeners: SettingsSpec = { - ...settingSpec, - onElementOver: onOverListener, - onElementOut: onOutListener, - onPointerUpdate: onPointerUpdateListener, - }; - MockStore.addSpecs([spec, settingsWithListeners], store); - const onElementOutCaller = createOnElementOutCaller(); - const onElementOverCaller = createOnElementOverCaller(); - const onPointerMoveCaller = createOnPointerMoveCaller(); - store.subscribe(() => { - const state = store.getState(); - onElementOutCaller(state); - onElementOverCaller(state); - onPointerMoveCaller(state); + beforeEach(() => { + store = initStore(spec); + onOverListener = jest.fn(); + onOutListener = jest.fn(); + onPointerUpdateListener = jest.fn(); + + const settingsWithListeners: SettingsSpec = { + ...settingSpec, + onElementOver: onOverListener, + onElementOut: onOutListener, + onPointerUpdate: onPointerUpdateListener, + }; + MockStore.addSpecs([spec, settingsWithListeners], store); + const onElementOutCaller = createOnElementOutCaller(); + const onElementOverCaller = createOnElementOverCaller(); + const onPointerMoveCaller = createOnPointerMoveCaller(); + store.subscribe(() => { + const state = store.getState(); + onElementOutCaller(state); + onElementOverCaller(state); + onPointerMoveCaller(state); + }); + const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values).toEqual([]); }); - const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values).toEqual([]); - }); - - test('store is correctly configured', () => { - // checking this to avoid broken tests due to nested describe and before - const seriesGeoms = computeSeriesGeometriesSelector(store.getState()); - expect(seriesGeoms.scales.xScale).not.toBeUndefined(); - expect(seriesGeoms.scales.yScales).not.toBeUndefined(); - }); - - test('avoid call pointer update listener if moving over the same element', () => { - store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); - expect(onPointerUpdateListener).toBeCalledTimes(1); - const tooltipInfo1 = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo1.tooltip.values.length).toBe(1); - // avoid calls - store.dispatch(onPointerMove({ x: chartLeft + 12, y: chartTop + 12 }, 1)); - expect(onPointerUpdateListener).toBeCalledTimes(1); - - const tooltipInfo2 = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo2.tooltip.values.length).toBe(1); - expect(tooltipInfo1).toEqual(tooltipInfo2); - }); + test('store is correctly configured', () => { + // checking this to avoid broken tests due to nested describe and before + const seriesGeoms = computeSeriesGeometriesSelector(store.getState()); + expect(seriesGeoms.scales.xScale).not.toBeUndefined(); + expect(seriesGeoms.scales.yScales).not.toBeUndefined(); + }); - test('call pointer update listener on move', () => { - store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); - expect(onPointerUpdateListener).toBeCalledTimes(1); - expect(onPointerUpdateListener.mock.calls[0][0]).toEqual({ - chartId: 'chartId', - scale: scaleType, - type: 'Over', - unit: undefined, - value: 0, + it('should avoid calling pointer update listener if moving over the same element', () => { + store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + + const tooltipInfo1 = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo1.tooltip.values.length).toBe(1); + // avoid calls + store.dispatch(onPointerMove({ x: chartLeft + 12, y: chartTop + 12 }, 1)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + + const tooltipInfo2 = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo2.tooltip.values.length).toBe(1); + expect(tooltipInfo1).toEqual(tooltipInfo2); }); - // avoid multiple calls for the same value - store.dispatch(onPointerMove({ x: chartLeft + 50, y: chartTop + 10 }, 1)); - expect(onPointerUpdateListener).toBeCalledTimes(2); - expect(onPointerUpdateListener.mock.calls[1][0]).toEqual({ - chartId: 'chartId', - scale: scaleType, - type: 'Over', - unit: undefined, - value: 1, + it.skip('should avoid calling projection update listener if moving over the same element with same y', () => { + MockStore.updateSettings(store, { pointerUpdateTrigger: 'y' }); + store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + + const tooltipInfo1 = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo1.tooltip.values.length).toBe(1); + // avoid calls + store.dispatch(onPointerMove({ x: chartLeft + 12, y: chartTop + 10 }, 1)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + + const tooltipInfo2 = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo2.tooltip.values.length).toBe(1); + expect(tooltipInfo1).toEqual(tooltipInfo2); }); - store.dispatch(onPointerMove({ x: chartLeft + 200, y: chartTop + 10 }, 1)); - expect(onPointerUpdateListener).toBeCalledTimes(3); - expect(onPointerUpdateListener.mock.calls[2][0]).toEqual({ - chartId: 'chartId', - type: 'Out', + it.skip('should call projection update listener if moving over the same element with differnt y', () => { + store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + expect(onPointerUpdateListener.mock.calls[0][0]).toMatchObject({ + x: 0, + y: [ + { + groupId: 'group_1', + value: 8.88888888888889, + }, + ], + }); + + // avoid calls + store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 11 }, 1)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(2); + expect(onPointerUpdateListener.mock.calls[1][0]).toMatchObject({ + x: 0, + y: [ + { + groupId: 'group_1', + value: 8.777777777777779, + }, + ], + }); }); - }); - test('handle only external pointer update', () => { - store.dispatch( - onExternalPointerEvent({ + it('should call pointer update listeners on move', () => { + store.dispatch(onPointerMove({ x: chartLeft + 10, y: chartTop + 10 }, 0)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(1); + expect(onPointerUpdateListener.mock.calls[0][0]).toEqual({ chartId: 'chartId', scale: scaleType, type: 'Over', unit: undefined, - value: 0, - }), - ); - let cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeUndefined(); + x: 0, + y: [ + { + groupId: 'group_1', + value: 8.88888888888889, + }, + ], + smVerticalValue: null, + smHorizontalValue: null, + }); + + // avoid multiple calls for the same value + store.dispatch(onPointerMove({ x: chartLeft + 50, y: chartTop + 11 }, 1)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(2); - store.dispatch( - onExternalPointerEvent({ - chartId: 'differentChart', + expect(onPointerUpdateListener.mock.calls[1][0]).toEqual({ + chartId: 'chartId', scale: scaleType, type: 'Over', unit: undefined, - value: 0, - }), - ); - cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - }); - - test.skip('can determine which tooltip to display if chart & annotation tooltips possible', () => { - // const annotationDimensions = [{ rect: { x: 49, y: -1, width: 3, height: 99 } }]; - // const rectAnnotationSpec: RectAnnotationSpec = { - // id: 'rect', - // groupId: GROUP_ID, - // annotationType: 'rectangle', - // dataValues: [{ coordinates: { x0: 1, x1: 1.5, y0: 0.5, y1: 10 } }], - // }; - // store.annotationSpecs.set(rectAnnotationSpec.annotationId, rectAnnotationSpec); - // store.annotationDimensions.set(rectAnnotationSpec.annotationId, annotationDimensions); - // debugger; - // // isHighlighted false, chart tooltip true; should show annotationTooltip only - // store.setCursorPosition(chartLeft + 51, chartTop + 1); - // expect(store.isTooltipVisible.get()).toBe(false); - }); - - test('can hover top-left corner of the first bar', () => { - let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values).toEqual([]); - store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 0 }, 0)); - let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 0, y: 0 }); - const cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); - expect((cursorBandPosition as Rect).width).toBe(45); - let isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(0); - expect(onOverListener.mock.calls[0][0]).toEqual([ - [ - { - x: 0, - y: 10, - accessor: 'y1', - mark: null, - datum: [0, 10], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); + x: 1, + y: [ + { + groupId: 'group_1', + value: 8.777777777777779, + }, + ], + smVerticalValue: null, + smHorizontalValue: null, + }); - store.dispatch(onPointerMove({ x: chartLeft - 1, y: chartTop - 1 }, 1)); - projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: -1, y: -1 }); - isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(false); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values.length).toBe(0); - expect(tooltipInfo.highlightedGeometries.length).toBe(0); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(1); - }); + store.dispatch(onPointerMove({ x: chartLeft + 200, y: chartTop + 12 }, 1)); + MockStore.flush(store); + expect(onPointerUpdateListener).toBeCalledTimes(3); + expect(onPointerUpdateListener.mock.calls[2][0]).toEqual({ + chartId: 'chartId', + type: 'Out', + }); + }); - test('can hover bottom-left corner of the first bar', () => { - store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 89 }, 0)); - let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 0, y: 89 }); - const cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); - expect((cursorBandPosition as Rect).width).toBe(45); - let isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(0); - expect(onOverListener.mock.calls[0][0]).toEqual([ - [ - { + test('handle only external pointer update', () => { + store.dispatch( + onExternalPointerEvent({ + chartId: 'chartId', + scale: scaleType, + type: 'Over', + unit: undefined, x: 0, - y: 10, - accessor: 'y1', - mark: null, - datum: [0, 10], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); - store.dispatch(onPointerMove({ x: chartLeft - 1, y: chartTop + 89 }, 1)); - projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: -1, y: 89 }); - isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(false); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values.length).toBe(0); - expect(tooltipInfo.highlightedGeometries.length).toBe(0); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(1); - }); - - test('can hover top-right corner of the first bar', () => { - let scaleOffset = 0; - if (scaleType !== ScaleType.Ordinal) { - scaleOffset = 1; - } - store.dispatch(onPointerMove({ x: chartLeft + 44 + scaleOffset, y: chartTop + 0 }, 0)); - let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 44 + scaleOffset, y: 0 }); - let cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); - expect((cursorBandPosition as Rect).width).toBe(45); - let isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(0); - expect(onOverListener.mock.calls[0][0]).toEqual([ - [ - { + y: [], + smVerticalValue: null, + smHorizontalValue: null, + }), + ); + let cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeUndefined(); + + store.dispatch( + onExternalPointerEvent({ + chartId: 'differentChart', + scale: scaleType, + type: 'Over', + unit: undefined, x: 0, - y: 10, - accessor: 'y1', - mark: null, - datum: [0, 10], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); - - store.dispatch(onPointerMove({ x: chartLeft + 45 + scaleOffset, y: chartTop + 0 }, 1)); - projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 45 + scaleOffset, y: 0 }); - cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); - expect((cursorBandPosition as Rect).width).toBe(45); - isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(tooltipInfo.highlightedGeometries.length).toBe(0); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(1); - }); - - test('can hover bottom-right corner of the first bar', () => { - let scaleOffset = 0; - if (scaleType !== ScaleType.Ordinal) { - scaleOffset = 1; - } - store.dispatch(onPointerMove({ x: chartLeft + 44 + scaleOffset, y: chartTop + 89 }, 0)); - let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 44 + scaleOffset, y: 89 }); - let cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); - expect((cursorBandPosition as Rect).width).toBe(45); - let isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(onOverListener).toBeCalledTimes(1); - expect(onOutListener).toBeCalledTimes(0); - expect(onOverListener.mock.calls[0][0]).toEqual([ - [ - { - x: (spec.data[0] as Array)[0], - y: (spec.data[0] as Array)[1], - accessor: 'y1', - mark: null, - datum: [(spec.data[0] as Array)[0], (spec.data[0] as Array)[1]], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); - - store.dispatch(onPointerMove({ x: chartLeft + 45 + scaleOffset, y: chartTop + 89 }, 1)); - projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 45 + scaleOffset, y: 89 }); - cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); - expect((cursorBandPosition as Rect).width).toBe(45); - isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.values.length).toBe(1); - // we are over the second bar here - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(onOverListener).toBeCalledTimes(2); - expect(onOverListener.mock.calls[1][0]).toEqual([ - [ - { - x: (spec.data[1] as Array)[0], - y: (spec.data[1] as Array)[1], - accessor: 'y1', - mark: null, - datum: [(spec.data[1] as Array)[0], (spec.data[1] as Array)[1]], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); - - expect(onOutListener).toBeCalledTimes(0); - - store.dispatch(onPointerMove({ x: chartLeft + 47 + scaleOffset, y: chartTop + 89 }, 2)); - }); - - test('can hover top-right corner of the chart', () => { - expect(onOverListener).toBeCalledTimes(0); - expect(onOutListener).toBeCalledTimes(0); - let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(0); - expect(tooltipInfo.tooltip.values.length).toBe(0); - - store.dispatch(onPointerMove({ x: chartLeft + 89, y: chartTop + 0 }, 0)); - const projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - expect(projectedPointerPosition).toMatchObject({ x: 89, y: 0 }); - const cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); - expect((cursorBandPosition as Rect).width).toBe(45); + y: [], + smVerticalValue: null, + smHorizontalValue: null, + }), + ); + cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + }); - const isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(0); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(onOverListener).toBeCalledTimes(0); - expect(onOutListener).toBeCalledTimes(0); - }); + test.skip('can determine which tooltip to display if chart & annotation tooltips possible', () => { + // const annotationDimensions = [{ rect: { x: 49, y: -1, width: 3, height: 99 } }]; + // const rectAnnotationSpec: RectAnnotationSpec = { + // id: 'rect', + // groupId: GROUP_ID, + // annotationType: 'rectangle', + // dataValues: [{ coordinates: { x0: 1, x1: 1.5, y0: 0.5, y1: 10 } }], + // }; + // store.annotationSpecs.set(rectAnnotationSpec.annotationId, rectAnnotationSpec); + // store.annotationDimensions.set(rectAnnotationSpec.annotationId, annotationDimensions); + // debugger; + // // isHighlighted false, chart tooltip true; should show annotationTooltip only + // store.setCursorPosition(chartLeft + 51, chartTop + 1); + // expect(store.isTooltipVisible.get()).toBe(false); + }); - test('will call only one time the listener with the same values', () => { - expect(onOverListener).toBeCalledTimes(0); - expect(onOutListener).toBeCalledTimes(0); - let halfWidth = 45; - if (scaleType !== ScaleType.Ordinal) { - halfWidth = 46; - } - let timeCounter = 0; - for (let i = 0; i < halfWidth; i++) { - store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 89 }, timeCounter)); + test('can hover top-left corner of the first bar', () => { + let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values).toEqual([]); + store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 0 }, 0)); + let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 0, y: 0 }); + const cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); + expect((cursorBandPosition as Rect).width).toBe(45); + let isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(tooltipInfo.highlightedGeometries.length).toBe(1); expect(onOverListener).toBeCalledTimes(1); expect(onOutListener).toBeCalledTimes(0); - timeCounter++; - } - for (let i = halfWidth; i < 90; i++) { - store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 89 }, timeCounter)); - expect(onOverListener).toBeCalledTimes(2); - expect(onOutListener).toBeCalledTimes(0); - timeCounter++; - } - for (let i = 0; i < halfWidth; i++) { - store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 0 }, timeCounter)); - expect(onOverListener).toBeCalledTimes(3); - expect(onOutListener).toBeCalledTimes(0); - timeCounter++; - } - for (let i = halfWidth; i < 90; i++) { - store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 0 }, timeCounter)); - expect(onOverListener).toBeCalledTimes(3); + expect(onOverListener.mock.calls[0][0]).toEqual([ + [ + { + x: 0, + y: 10, + accessor: 'y1', + mark: null, + datum: [0, 10], + }, + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, + ], + ]); + + store.dispatch(onPointerMove({ x: chartLeft - 1, y: chartTop - 1 }, 1)); + projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: -1, y: -1 }); + isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(false); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values.length).toBe(0); + expect(tooltipInfo.highlightedGeometries.length).toBe(0); + expect(onOverListener).toBeCalledTimes(1); expect(onOutListener).toBeCalledTimes(1); - timeCounter++; - } - }); - - test('can hover bottom-right corner of the chart', () => { - store.dispatch(onPointerMove({ x: chartLeft + 89, y: chartTop + 89 }, 0)); - const projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); - // store.setCursorPosition(chartLeft + 99, chartTop + 99); - expect(projectedPointerPosition).toMatchObject({ x: 89, y: 89 }); - const cursorBandPosition = getCursorBandPositionSelector(store.getState()); - expect(cursorBandPosition).toBeDefined(); - expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); - expect((cursorBandPosition as Rect).width).toBe(45); - const isTooltipVisible = isTooltipVisibleSelector(store.getState()); - expect(isTooltipVisible.visible).toBe(true); - const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.highlightedGeometries.length).toBe(1); - expect(tooltipInfo.tooltip.values.length).toBe(1); - expect(onOverListener).toBeCalledTimes(1); - expect(onOverListener.mock.calls[0][0]).toEqual([ - [ - { - x: 1, - y: 5, - accessor: 'y1', - mark: null, - datum: [1, 5], - }, - { - key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', - seriesKeys: [1], - specId: 'spec_1', - splitAccessors: new Map(), - yAccessor: 1, - }, - ], - ]); - expect(onOutListener).toBeCalledTimes(0); - }); - - describe.skip('can position tooltip within chart when xScale is a single value scale', () => { - beforeEach(() => { - // const singleValueScale = - // store.xScale!.type === ScaleType.Ordinal - // ? new ScaleBand(['a'], [0, 0]) - // : new ScaleContinuous({ type: ScaleType.Linear, domain: [1, 1], range: [0, 0] }); - // store.xScale = singleValueScale; - }); - test.skip('horizontal chart rotation', () => { - // store.setCursorPosition(chartLeft + 99, chartTop + 99); - // const expectedTransform = `translateX(${chartLeft}px) translateX(-0%) translateY(109px) translateY(-100%)`; - // expect(store.tooltipPosition.transform).toBe(expectedTransform); }); - test.skip('vertical chart rotation', () => { - // store.chartRotation = 90; - // store.setCursorPosition(chartLeft + 99, chartTop + 99); - // const expectedTransform = `translateX(109px) translateX(-100%) translateY(${chartTop}px) translateY(-0%)`; - // expect(store.tooltipPosition.transform).toBe(expectedTransform); - }); - }); - describe('can format tooltip values on rotated chart', () => { - let leftAxis: AxisSpec; - let bottomAxis: AxisSpec; - let currentSettingSpec: SettingsSpec; - const style: RecursivePartial = { - tickLine: { - size: 0, - padding: 0, - }, - }; - beforeEach(() => { - leftAxis = { - chartType: ChartType.XYAxis, - specType: SpecType.Axis, - hide: true, - id: 'yaxis', - groupId: GROUP_ID, - position: Position.Left, - tickFormat: (value) => `left ${Number(value)}`, - showOverlappingLabels: false, - showOverlappingTicks: false, - style, - }; - bottomAxis = { - chartType: ChartType.XYAxis, - specType: SpecType.Axis, - hide: true, - id: 'xaxis', - groupId: GROUP_ID, - position: Position.Bottom, - tickFormat: (value) => `bottom ${Number(value)}`, - showOverlappingLabels: false, - showOverlappingTicks: false, - style, - }; - currentSettingSpec = getSettingsSpecSelector(store.getState()); - }); - - test('chart 0 rotation', () => { - MockStore.addSpecs([spec, leftAxis, bottomAxis, currentSettingSpec], store); + test('can hover bottom-left corner of the first bar', () => { store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 89 }, 0)); - const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.header?.value).toBe(0); - expect(tooltipInfo.tooltip.header?.formattedValue).toBe('bottom 0'); - expect(tooltipInfo.tooltip.values[0].value).toBe(10); - expect(tooltipInfo.tooltip.values[0].formattedValue).toBe('left 10'); + let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 0, y: 89 }); + const cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); + expect((cursorBandPosition as Rect).width).toBe(45); + let isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(1); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(0); + expect(onOverListener.mock.calls[0][0]).toEqual([ + [ + { + x: 0, + y: 10, + accessor: 'y1', + mark: null, + datum: [0, 10], + }, + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, + ], + ]); + store.dispatch(onPointerMove({ x: chartLeft - 1, y: chartTop + 89 }, 1)); + projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: -1, y: 89 }); + isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(false); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values.length).toBe(0); + expect(tooltipInfo.highlightedGeometries.length).toBe(0); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(1); }); - test('chart 90 deg rotated', () => { - const updatedSettings: SettingsSpec = { - ...currentSettingSpec, - rotation: 90, - }; - MockStore.addSpecs([spec, leftAxis, bottomAxis, updatedSettings], store); - - store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 89 }, 0)); - const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); - expect(tooltipInfo.tooltip.header?.value).toBe(1); - expect(tooltipInfo.tooltip.header?.formattedValue).toBe('left 1'); - expect(tooltipInfo.tooltip.values[0].value).toBe(5); - expect(tooltipInfo.tooltip.values[0].formattedValue).toBe('bottom 5'); + test('can hover top-right corner of the first bar', () => { + let scaleOffset = 0; + if (scaleType !== ScaleType.Ordinal) { + scaleOffset = 1; + } + store.dispatch(onPointerMove({ x: chartLeft + 44 + scaleOffset, y: chartTop + 0 }, 0)); + let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 44 + scaleOffset, y: 0 }); + let cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); + expect((cursorBandPosition as Rect).width).toBe(45); + let isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(1); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(0); + expect(onOverListener.mock.calls[0][0]).toEqual([ + [ + { + x: 0, + y: 10, + accessor: 'y1', + mark: null, + datum: [0, 10], + }, + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, + ], + ]); + + store.dispatch(onPointerMove({ x: chartLeft + 45 + scaleOffset, y: chartTop + 0 }, 1)); + projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 45 + scaleOffset, y: 0 }); + cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); + expect((cursorBandPosition as Rect).width).toBe(45); + isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(tooltipInfo.highlightedGeometries.length).toBe(0); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(1); }); - }); - describe('brush', () => { - test('can respond to a brush end event', () => { - const brushEndListener = jest.fn((): void => undefined); - const onBrushCaller = createOnBrushEndCaller(); - store.subscribe(() => { - onBrushCaller(store.getState()); - }); - const settings = getSettingsSpecSelector(store.getState()); - const updatedSettings: SettingsSpec = { - ...settings, - theme: { - ...settings.theme, - chartMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, + + test('can hover bottom-right corner of the first bar', () => { + let scaleOffset = 0; + if (scaleType !== ScaleType.Ordinal) { + scaleOffset = 1; + } + store.dispatch(onPointerMove({ x: chartLeft + 44 + scaleOffset, y: chartTop + 89 }, 0)); + let projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 44 + scaleOffset, y: 89 }); + let cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 0); + expect((cursorBandPosition as Rect).width).toBe(45); + let isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(1); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(0); + expect(onOverListener.mock.calls[0][0]).toEqual([ + [ + { + x: (spec.data[0] as Array)[0], + y: (spec.data[0] as Array)[1], + accessor: 'y1', + mark: null, + datum: [(spec.data[0] as Array)[0], (spec.data[0] as Array)[1]], }, - }, - onBrushEnd: brushEndListener, - }; - MockStore.addSpecs( + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, + ], + ]); + + store.dispatch(onPointerMove({ x: chartLeft + 45 + scaleOffset, y: chartTop + 89 }, 1)); + projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 45 + scaleOffset, y: 89 }); + cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); + expect((cursorBandPosition as Rect).width).toBe(45); + isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.values.length).toBe(1); + // we are over the second bar here + expect(tooltipInfo.highlightedGeometries.length).toBe(1); + expect(onOverListener).toBeCalledTimes(2); + expect(onOverListener.mock.calls[1][0]).toEqual([ [ { - ...spec, - data: [ - [0, 1], - [1, 1], - [2, 2], - [3, 3], - ], - } as BarSeriesSpec, - updatedSettings, + x: (spec.data[1] as Array)[0], + y: (spec.data[1] as Array)[1], + accessor: 'y1', + mark: null, + datum: [(spec.data[1] as Array)[0], (spec.data[1] as Array)[1]], + }, + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, ], - store, - ); + ]); - const start1 = { x: 0, y: 0 }; - const end1 = { x: 75, y: 0 }; - - store.dispatch(onMouseDown(start1, 0)); - store.dispatch(onPointerMove(end1, 200)); - store.dispatch(onMouseUp(end1, 300)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 2.5] }); - } - const start2 = { x: 75, y: 0 }; - const end2 = { x: 100, y: 0 }; - - store.dispatch(onMouseDown(start2, 400)); - store.dispatch(onPointerMove(end2, 500)); - store.dispatch(onMouseUp(end2, 600)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [2.5, 3] }); - } + expect(onOutListener).toBeCalledTimes(0); - const start3 = { x: 75, y: 0 }; - const end3 = { x: 250, y: 0 }; - store.dispatch(onMouseDown(start3, 700)); - store.dispatch(onPointerMove(end3, 800)); - store.dispatch(onMouseUp(end3, 900)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [2.5, 3] }); - } + store.dispatch(onPointerMove({ x: chartLeft + 47 + scaleOffset, y: chartTop + 89 }, 2)); + }); - const start4 = { x: 25, y: 0 }; - const end4 = { x: -20, y: 0 }; - store.dispatch(onMouseDown(start4, 1000)); - store.dispatch(onPointerMove(end4, 1100)); - store.dispatch(onMouseUp(end4, 1200)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0.5] }); - } + test('can hover top-right corner of the chart', () => { + expect(onOverListener).toBeCalledTimes(0); + expect(onOutListener).toBeCalledTimes(0); + let tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(0); + expect(tooltipInfo.tooltip.values.length).toBe(0); + + store.dispatch(onPointerMove({ x: chartLeft + 89, y: chartTop + 0 }, 0)); + const projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + expect(projectedPointerPosition).toMatchObject({ x: 89, y: 0 }); + const cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); + expect((cursorBandPosition as Rect).width).toBe(45); + + const isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(0); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(onOverListener).toBeCalledTimes(0); + expect(onOutListener).toBeCalledTimes(0); + }); - store.dispatch(onMouseDown({ x: 25, y: 0 }, 1300)); - store.dispatch(onPointerMove({ x: 28, y: 0 }, 1390)); - store.dispatch(onMouseUp({ x: 28, y: 0 }, 1400)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener.mock.calls[4]).toBeUndefined(); + test('will call only one time the listener with the same values', () => { + expect(onOverListener).toBeCalledTimes(0); + expect(onOutListener).toBeCalledTimes(0); + let halfWidth = 45; + if (scaleType !== ScaleType.Ordinal) { + halfWidth = 46; } - }); - test('can respond to a brush end event on rotated chart', () => { - const brushEndListener = jest.fn((): void => undefined); - const onBrushCaller = createOnBrushEndCaller(); - store.subscribe(() => { - onBrushCaller(store.getState()); - }); - const settings = getSettingsSpecSelector(store.getState()); - const updatedSettings: SettingsSpec = { - ...settings, - rotation: 90, - theme: { - ...settings.theme, - chartMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - }, - onBrushEnd: brushEndListener, - }; - MockStore.addSpecs([spec, updatedSettings], store); - - const start1 = { x: 0, y: 25 }; - const end1 = { x: 0, y: 75 }; - - store.dispatch(onMouseDown(start1, 0)); - store.dispatch(onPointerMove(end1, 100)); - store.dispatch(onMouseUp(end1, 200)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 1] }); + let timeCounter = 0; + for (let i = 0; i < halfWidth; i++) { + store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 89 }, timeCounter)); + expect(onOverListener).toBeCalledTimes(1); + expect(onOutListener).toBeCalledTimes(0); + timeCounter++; } - const start2 = { x: 0, y: 75 }; - const end2 = { x: 0, y: 100 }; - - store.dispatch(onMouseDown(start2, 400)); - store.dispatch(onPointerMove(end2, 500)); - store.dispatch(onMouseUp(end2, 600)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [1, 1] }); + for (let i = halfWidth; i < 90; i++) { + store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 89 }, timeCounter)); + expect(onOverListener).toBeCalledTimes(2); + expect(onOutListener).toBeCalledTimes(0); + timeCounter++; } - - const start3 = { x: 0, y: 75 }; - const end3 = { x: 0, y: 200 }; - store.dispatch(onMouseDown(start3, 700)); - store.dispatch(onPointerMove(end3, 800)); - store.dispatch(onMouseUp(end3, 900)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [1, 1] }); // max of chart + for (let i = 0; i < halfWidth; i++) { + store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 0 }, timeCounter)); + expect(onOverListener).toBeCalledTimes(3); + expect(onOutListener).toBeCalledTimes(0); + timeCounter++; } - - const start4 = { x: 0, y: 25 }; - const end4 = { x: 0, y: -20 }; - store.dispatch(onMouseDown(start4, 1000)); - store.dispatch(onPointerMove(end4, 1100)); - store.dispatch(onMouseUp(end4, 1200)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0] }); + for (let i = halfWidth; i < 90; i++) { + store.dispatch(onPointerMove({ x: chartLeft + i, y: chartTop + 0 }, timeCounter)); + expect(onOverListener).toBeCalledTimes(3); + expect(onOutListener).toBeCalledTimes(1); + timeCounter++; } }); - test('can respond to a Y brush', () => { - const brushEndListener = jest.fn((): void => undefined); - 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, - }; - MockStore.addSpecs( + + test('can hover bottom-right corner of the chart', () => { + store.dispatch(onPointerMove({ x: chartLeft + 89, y: chartTop + 89 }, 0)); + const projectedPointerPosition = getProjectedPointerPositionSelector(store.getState()); + // store.setCursorPosition(chartLeft + 99, chartTop + 99); + expect(projectedPointerPosition).toMatchObject({ x: 89, y: 89 }); + const cursorBandPosition = getCursorBandPositionSelector(store.getState()); + expect(cursorBandPosition).toBeDefined(); + expect((cursorBandPosition as Rect).x).toBe(chartLeft + 45); + expect((cursorBandPosition as Rect).width).toBe(45); + const isTooltipVisible = isTooltipVisibleSelector(store.getState()); + expect(isTooltipVisible.visible).toBe(true); + const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.highlightedGeometries.length).toBe(1); + expect(tooltipInfo.tooltip.values.length).toBe(1); + expect(onOverListener).toBeCalledTimes(1); + expect(onOverListener.mock.calls[0][0]).toEqual([ [ { - ...spec, - data: [ - [0, 1], - [1, 1], - [2, 2], - [3, 3], - ], - } as BarSeriesSpec, - updatedSettings, + x: 1, + y: 5, + accessor: 'y1', + mark: null, + datum: [1, 5], + }, + { + key: 'groupId{group_1}spec{spec_1}yAccessor{1}splitAccessors{}', + seriesKeys: [1], + specId: 'spec_1', + splitAccessors: new Map(), + yAccessor: 1, + }, ], - store, - ); + ]); + expect(onOutListener).toBeCalledTimes(0); + }); - const start1 = { x: 0, y: 0 }; - const end1 = { x: 0, y: 75 }; - - store.dispatch(onMouseDown(start1, 0)); - store.dispatch(onPointerMove(end1, 100)); - store.dispatch(onMouseUp(end1, 200)); - 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], - }, - ], + describe.skip('can position tooltip within chart when xScale is a single value scale', () => { + beforeEach(() => { + // const singleValueScale = + // store.xScale!.type === ScaleType.Ordinal + // ? new ScaleBand(['a'], [0, 0]) + // : new ScaleContinuous({ type: ScaleType.Linear, domain: [1, 1], range: [0, 0] }); + // store.xScale = singleValueScale; + }); + test.skip('horizontal chart rotation', () => { + // store.setCursorPosition(chartLeft + 99, chartTop + 99); + // const expectedTransform = `translateX(${chartLeft}px) translateX(-0%) translateY(109px) translateY(-100%)`; + // expect(store.tooltipPosition.transform).toBe(expectedTransform); + }); + + test.skip('vertical chart rotation', () => { + // store.chartRotation = 90; + // store.setCursorPosition(chartLeft + 99, chartTop + 99); + // const expectedTransform = `translateX(109px) translateX(-100%) translateY(${chartTop}px) translateY(-0%)`; + // expect(store.tooltipPosition.transform).toBe(expectedTransform); + }); + }); + describe('can format tooltip values on rotated chart', () => { + let leftAxis: AxisSpec; + let bottomAxis: AxisSpec; + let currentSettingSpec: SettingsSpec; + const style: RecursivePartial = { + tickLine: { + size: 0, + padding: 0, + }, + }; + beforeEach(() => { + leftAxis = { + chartType: ChartType.XYAxis, + specType: SpecType.Axis, + hide: true, + id: 'yaxis', + groupId: GROUP_ID, + position: Position.Left, + tickFormat: (value) => `left ${Number(value)}`, + showOverlappingLabels: false, + showOverlappingTicks: false, + style, + }; + bottomAxis = { + chartType: ChartType.XYAxis, + specType: SpecType.Axis, + hide: true, + id: 'xaxis', + groupId: GROUP_ID, + position: Position.Bottom, + tickFormat: (value) => `bottom ${Number(value)}`, + showOverlappingLabels: false, + showOverlappingTicks: false, + style, + }; + currentSettingSpec = getSettingsSpecSelector(store.getState()); + }); + + test('chart 0 rotation', () => { + MockStore.addSpecs([spec, leftAxis, bottomAxis, currentSettingSpec], store); + store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 89 }, 0)); + const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.header?.value).toBe(0); + expect(tooltipInfo.tooltip.header?.formattedValue).toBe('bottom 0'); + expect(tooltipInfo.tooltip.values[0].value).toBe(10); + expect(tooltipInfo.tooltip.values[0].formattedValue).toBe('left 10'); + }); + + test('chart 90 deg rotated', () => { + const updatedSettings: SettingsSpec = { + ...currentSettingSpec, + rotation: 90, + }; + MockStore.addSpecs([spec, leftAxis, bottomAxis, updatedSettings], store); + + store.dispatch(onPointerMove({ x: chartLeft + 0, y: chartTop + 89 }, 0)); + const tooltipInfo = getTooltipInfoAndGeometriesSelector(store.getState()); + expect(tooltipInfo.tooltip.header?.value).toBe(1); + expect(tooltipInfo.tooltip.header?.formattedValue).toBe('left 1'); + expect(tooltipInfo.tooltip.values[0].value).toBe(5); + expect(tooltipInfo.tooltip.values[0].formattedValue).toBe('bottom 5'); + }); + }); + describe('brush', () => { + test('can respond to a brush end event', () => { + const brushEndListener = jest.fn((): void => undefined); + const onBrushCaller = createOnBrushEndCaller(); + store.subscribe(() => { + onBrushCaller(store.getState()); }); - } - const start2 = { x: 0, y: 75 }; - const end2 = { x: 0, y: 100 }; - - store.dispatch(onMouseDown(start2, 400)); - store.dispatch(onPointerMove(end2, 500)); - store.dispatch(onMouseUp(end2, 600)); - 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], + const settings = getSettingsSpecSelector(store.getState()); + const updatedSettings: SettingsSpec = { + ...settings, + theme: { + ...settings.theme, + chartMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, }, + }, + onBrushEnd: brushEndListener, + }; + MockStore.addSpecs( + [ + { + ...spec, + data: [ + [0, 1], + [1, 1], + [2, 2], + [3, 3], + ], + } as BarSeriesSpec, + updatedSettings, ], + store, + ); + + const start1 = { x: 0, y: 0 }; + const end1 = { x: 75, y: 0 }; + + store.dispatch(onMouseDown(start1, 0)); + store.dispatch(onPointerMove(end1, 200)); + store.dispatch(onMouseUp(end1, 300)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 2.5] }); + } + const start2 = { x: 75, y: 0 }; + const end2 = { x: 100, y: 0 }; + + store.dispatch(onMouseDown(start2, 400)); + store.dispatch(onPointerMove(end2, 500)); + store.dispatch(onMouseUp(end2, 600)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [2.5, 3] }); + } + + const start3 = { x: 75, y: 0 }; + const end3 = { x: 250, y: 0 }; + store.dispatch(onMouseDown(start3, 700)); + store.dispatch(onPointerMove(end3, 800)); + store.dispatch(onMouseUp(end3, 900)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [2.5, 3] }); + } + + const start4 = { x: 25, y: 0 }; + const end4 = { x: -20, y: 0 }; + store.dispatch(onMouseDown(start4, 1000)); + store.dispatch(onPointerMove(end4, 1100)); + store.dispatch(onMouseUp(end4, 1200)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0.5] }); + } + + store.dispatch(onMouseDown({ x: 25, y: 0 }, 1300)); + store.dispatch(onPointerMove({ x: 28, y: 0 }, 1390)); + store.dispatch(onMouseUp({ x: 28, y: 0 }, 1400)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener.mock.calls[4]).toBeUndefined(); + } + }); + test('can respond to a brush end event on rotated chart', () => { + const brushEndListener = jest.fn((): void => undefined); + const onBrushCaller = createOnBrushEndCaller(); + store.subscribe(() => { + onBrushCaller(store.getState()); }); - } - }); - test('can respond to rectangular brush', () => { - const brushEndListener = jest.fn((): void => undefined); - const onBrushCaller = createOnBrushEndCaller(); - store.subscribe(() => { - onBrushCaller(store.getState()); + const settings = getSettingsSpecSelector(store.getState()); + const updatedSettings: SettingsSpec = { + ...settings, + rotation: 90, + theme: { + ...settings.theme, + chartMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + onBrushEnd: brushEndListener, + }; + MockStore.addSpecs([spec, updatedSettings], store); + + const start1 = { x: 0, y: 25 }; + const end1 = { x: 0, y: 75 }; + + store.dispatch(onMouseDown(start1, 0)); + store.dispatch(onPointerMove(end1, 100)); + store.dispatch(onMouseUp(end1, 200)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[0][0]).toEqual({ x: [0, 1] }); + } + const start2 = { x: 0, y: 75 }; + const end2 = { x: 0, y: 100 }; + + store.dispatch(onMouseDown(start2, 400)); + store.dispatch(onPointerMove(end2, 500)); + store.dispatch(onMouseUp(end2, 600)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[1][0]).toEqual({ x: [1, 1] }); + } + + const start3 = { x: 0, y: 75 }; + const end3 = { x: 0, y: 200 }; + store.dispatch(onMouseDown(start3, 700)); + store.dispatch(onPointerMove(end3, 800)); + store.dispatch(onMouseUp(end3, 900)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[2][0]).toEqual({ x: [1, 1] }); // max of chart + } + + const start4 = { x: 0, y: 25 }; + const end4 = { x: 0, y: -20 }; + store.dispatch(onMouseDown(start4, 1000)); + store.dispatch(onPointerMove(end4, 1100)); + store.dispatch(onMouseUp(end4, 1200)); + if (scaleType === ScaleType.Ordinal) { + expect(brushEndListener).not.toBeCalled(); + } else { + expect(brushEndListener).toBeCalled(); + expect(brushEndListener.mock.calls[3][0]).toEqual({ x: [0, 0] }); + } }); - const settings = getSettingsSpecSelector(store.getState()); - const updatedSettings: SettingsSpec = { - ...settings, - brushAxis: BrushAxis.Both, - theme: { - ...settings.theme, - chartMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, + test('can respond to a Y brush', () => { + const brushEndListener = jest.fn((): void => undefined); + 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, - }; - MockStore.addSpecs( - [ - { - ...spec, - data: [ - [0, 1], - [1, 1], - [2, 2], - [3, 3], - ], - } as BarSeriesSpec, - updatedSettings, - ], - store, - ); - - const start1 = { x: 0, y: 0 }; - const end1 = { x: 75, y: 75 }; - - store.dispatch(onMouseDown(start1, 0)); - store.dispatch(onPointerMove(end1, 100)); - store.dispatch(onMouseUp(end1, 300)); - if (scaleType === ScaleType.Ordinal) { - expect(brushEndListener).not.toBeCalled(); - } else { - expect(brushEndListener).toBeCalled(); - expect(brushEndListener.mock.calls[0][0]).toEqual({ - x: [0, 2.5], - y: [ + onBrushEnd: brushEndListener, + }; + MockStore.addSpecs( + [ { - groupId: spec.groupId, - extent: [0.75, 3], - }, + ...spec, + data: [ + [0, 1], + [1, 1], + [2, 2], + [3, 3], + ], + } as BarSeriesSpec, + updatedSettings, ], + store, + ); + + const start1 = { x: 0, y: 0 }; + const end1 = { x: 0, y: 75 }; + + store.dispatch(onMouseDown(start1, 0)); + store.dispatch(onPointerMove(end1, 100)); + store.dispatch(onMouseUp(end1, 200)); + 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, 400)); + store.dispatch(onPointerMove(end2, 500)); + store.dispatch(onMouseUp(end2, 600)); + 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 => undefined); + const onBrushCaller = createOnBrushEndCaller(); + store.subscribe(() => { + onBrushCaller(store.getState()); }); - } - const start2 = { x: 75, y: 75 }; - const end2 = { x: 100, y: 100 }; - - store.dispatch(onMouseDown(start2, 400)); - store.dispatch(onPointerMove(end2, 500)); - store.dispatch(onMouseUp(end2, 600)); - 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], + 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, + }; + MockStore.addSpecs( + [ + { + ...spec, + data: [ + [0, 1], + [1, 1], + [2, 2], + [3, 3], + ], + } as BarSeriesSpec, + updatedSettings, ], - }); - } + store, + ); + + const start1 = { x: 0, y: 0 }; + const end1 = { x: 75, y: 75 }; + + store.dispatch(onMouseDown(start1, 0)); + store.dispatch(onPointerMove(end1, 100)); + store.dispatch(onMouseUp(end1, 300)); + 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, 400)); + store.dispatch(onPointerMove(end2, 500)); + store.dispatch(onMouseUp(end2, 600)); + 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], + }, + ], + }); + } + }); }); }); -} + + it.todo('add test for point series'); + it.todo('add test for mixed series'); + it.todo('add test for clicks'); +}); describe('Negative bars click and hover', () => { let store: Store; diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_cursor_band.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_cursor_band.ts index 281ef6eebd5e..d9e0a0faf140 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_cursor_band.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_cursor_band.ts @@ -105,7 +105,7 @@ function getCursorBand( // external pointer events takes precedence over the current mouse pointer if (isValidPointerOverEvent(xScale, externalPointerEvent)) { fromExternalEvent = true; - const x = xScale.pureScale(externalPointerEvent.value); + const x = xScale.pureScale(externalPointerEvent.x); if (x == null || x > chartDimensions.width || x < 0) { return; } @@ -116,7 +116,7 @@ function getCursorBand( horizontalPanelValue: null, }; xValue = { - value: externalPointerEvent.value, + value: externalPointerEvent.x, withinBandwidth: true, }; } else { diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts index 9771aae086ad..285cd9a5979f 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts @@ -57,13 +57,13 @@ function getElementAtCursorPosition( { chartDimensions }: ChartDimensions, ): IndexedGeometry[] { if (isValidPointerOverEvent(scales.xScale, externalPointerEvent)) { - const x = scales.xScale.pureScale(externalPointerEvent.value); + const x = scales.xScale.pureScale(externalPointerEvent.x); if (x == null || x > chartDimensions.width + chartDimensions.left || x < 0) { return []; } // TODO: Handle external event with spatial points - return geometriesIndex.find(externalPointerEvent.value, { x: -1, y: -1 }); + return geometriesIndex.find(externalPointerEvent.x, { x: -1, y: -1 }); } const xValue = scales.xScale.invertWithStep(orientedProjectedPointerPosition.x, geometriesIndexKeys); if (!xValue) { diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_pointer_position.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_pointer_position.ts index 3b61cad6e4bc..ae4156278855 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_pointer_position.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_pointer_position.ts @@ -92,10 +92,10 @@ function getPosRelativeToPanel(panelScale: ScaleBand, pos: number): { pos: numbe if (relativePos > panelScale.bandwidth) { return { pos: -1, value: null }; } - return { pos: relativePos, value: panelScale.domain[relativePosIndex] }; + return { pos: relativePos, value: panelScale.domain[relativePosIndex] ?? null }; } return { pos: posWOInitialOuterPadding - panelScale.step * (numOfDomainSteps - 1), - value: panelScale.domain[numOfDomainSteps - 1], + value: panelScale.domain[numOfDomainSteps - 1] ?? null, }; } diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts index d6eda4e01672..027c3a29d198 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts @@ -31,7 +31,7 @@ export const getProjectedScaledValues = createCustomCachedSelector( { scales: { xScale, yScales } }, geometriesIndexKeys, ): ProjectedValues | undefined => { - if (!xScale) { + if (!xScale || x === -1) { return; } diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts index 04d04f4dedfe..1f8e32e737d4 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts @@ -110,7 +110,7 @@ function getTooltipAndHighlightFromValue( let tooltipType = getTooltipType(settings); if (isValidPointerOverEvent(scales.xScale, externalPointerEvent)) { tooltipType = getTooltipType(settings, true); - const scaledX = scales.xScale.pureScale(externalPointerEvent.value); + const scaledX = scales.xScale.pureScale(externalPointerEvent.x); if (scaledX === null) { return EMPTY_VALUES; diff --git a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/on_pointer_move_caller.ts b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/on_pointer_move_caller.ts index bb1002f0f21f..75843608b03a 100644 --- a/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/on_pointer_move_caller.ts +++ b/packages/osd-charts/packages/charts/src/chart_types/xy_chart/state/selectors/on_pointer_move_caller.ts @@ -20,13 +20,13 @@ import { Selector } from 'reselect'; import { ChartType } from '../../..'; -import { Scale } from '../../../../scales'; -import { SettingsSpec, PointerEvent } from '../../../../specs'; +import { PointerEvent, PointerOverEvent, PointerUpdateTrigger } from '../../../../specs'; import { PointerEventType } from '../../../../specs/constants'; import { GlobalChartState } from '../../../../state/chart_state'; import { createCustomCachedSelector } from '../../../../state/create_selector'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import { ComputedScales } from '../utils/types'; import { computeSeriesGeometriesSelector } from './compute_series_geometries'; import { getGeometriesIndexKeysSelector } from './get_geometries_index_keys'; import { getOrientedProjectedPointerPositionSelector } from './get_oriented_projected_pointer_position'; @@ -40,23 +40,23 @@ const getPointerEventSelector = createCustomCachedSelector( getGeometriesIndexKeysSelector, ], (chartId, orientedProjectedPointerPosition, seriesGeometries, geometriesIndexKeys): PointerEvent => - getPointerEvent(chartId, orientedProjectedPointerPosition, seriesGeometries.scales.xScale, geometriesIndexKeys), + getPointerEvent(chartId, orientedProjectedPointerPosition, seriesGeometries.scales, geometriesIndexKeys), ); function getPointerEvent( chartId: string, orientedProjectedPointerPosition: PointerPosition, - xScale: Scale | undefined, + { xScale, yScales }: ComputedScales, geometriesIndexKeys: any[], ): PointerEvent { - // update che cursorBandPosition based on chart configuration + // update the cursorBandPosition based on chart configuration if (!xScale) { return { chartId, type: PointerEventType.Out, }; } - const { x, y } = orientedProjectedPointerPosition; + const { x, y, verticalPanelValue, horizontalPanelValue } = orientedProjectedPointerPosition; if (x === -1 || y === -1) { return { chartId, @@ -75,11 +75,30 @@ function getPointerEvent( type: PointerEventType.Over, unit: xScale.unit, scale: xScale.type, - value: xValue.value, + x: xValue.value, + y: [...yScales.entries()].map(([groupId, yScale]) => { + return { value: yScale.invert(y), groupId }; + }), + smVerticalValue: verticalPanelValue, + smHorizontalValue: horizontalPanelValue, }; } -function hasPointerEventChanged(prevPointerEvent: PointerEvent, nextPointerEvent: PointerEvent | null) { +function isSameEventValue(a: PointerOverEvent, b: PointerOverEvent, changeTrigger: PointerUpdateTrigger) { + const checkX = changeTrigger === PointerUpdateTrigger.X || changeTrigger === PointerUpdateTrigger.Both; + const checkY = changeTrigger === PointerUpdateTrigger.Y || changeTrigger === PointerUpdateTrigger.Both; + + return ( + (!checkX || (a.x === b.x && a.scale === b.scale && a.unit === b.unit)) && + (!checkY || a.y.every((y, i) => y.value === b.y[i]?.value)) + ); +} + +function hasPointerEventChanged( + prevPointerEvent: PointerEvent, + nextPointerEvent: PointerEvent | null, + changeTrigger: PointerUpdateTrigger, +) { if (nextPointerEvent && prevPointerEvent.type !== nextPointerEvent.type) { return true; } @@ -95,9 +114,7 @@ function hasPointerEventChanged(prevPointerEvent: PointerEvent, nextPointerEvent nextPointerEvent && prevPointerEvent.type === PointerEventType.Over && nextPointerEvent.type === PointerEventType.Over && - (prevPointerEvent.value !== nextPointerEvent.value || - prevPointerEvent.scale !== nextPointerEvent.scale || - prevPointerEvent.unit !== nextPointerEvent.unit) + !isSameEventValue(prevPointerEvent, nextPointerEvent, changeTrigger) ) { return true; } @@ -112,7 +129,7 @@ export function createOnPointerMoveCaller(): (state: GlobalChartState) => void { if (selector === null && state.chartType === ChartType.XYAxis) { selector = createCustomCachedSelector( [getSettingsSpecSelector, getPointerEventSelector, getChartIdSelector], - (settings: SettingsSpec, nextPointerEvent: PointerEvent, chartId: string): void => { + ({ onPointerUpdate, pointerUpdateTrigger }, nextPointerEvent, chartId): void => { if (prevPointerEvent === null) { prevPointerEvent = { chartId, @@ -125,9 +142,9 @@ export function createOnPointerMoveCaller(): (state: GlobalChartState) => void { // we have to update the prevPointerEvents before possibly calling the onPointerUpdate // to avoid a recursive loop of calls caused by the impossibility to update the prevPointerEvent prevPointerEvent = nextPointerEvent; - if (settings && settings.onPointerUpdate && hasPointerEventChanged(tempPrev, nextPointerEvent)) { - settings.onPointerUpdate(nextPointerEvent); - } + + if (onPointerUpdate && hasPointerEventChanged(tempPrev, nextPointerEvent, pointerUpdateTrigger)) + onPointerUpdate(nextPointerEvent); }, ); } diff --git a/packages/osd-charts/packages/charts/src/components/__snapshots__/chart.test.tsx.snap b/packages/osd-charts/packages/charts/src/components/__snapshots__/chart.test.tsx.snap index d351eff506e5..303b64418ea4 100644 --- a/packages/osd-charts/packages/charts/src/components/__snapshots__/chart.test.tsx.snap +++ b/packages/osd-charts/packages/charts/src/components/__snapshots__/chart.test.tsx.snap @@ -54,7 +54,7 @@ exports[`Chart should render the legend name test 1`] = ` - + diff --git a/packages/osd-charts/packages/charts/src/mocks/store/store.ts b/packages/osd-charts/packages/charts/src/mocks/store/store.ts index 8cecad3d15a1..61d31dec0ee4 100644 --- a/packages/osd-charts/packages/charts/src/mocks/store/store.ts +++ b/packages/osd-charts/packages/charts/src/mocks/store/store.ts @@ -17,12 +17,15 @@ * under the License. */ +import { Cancelable } from 'lodash'; import { createStore, Store } from 'redux'; -import { DEFAULT_SETTINGS_SPEC, Spec } from '../../specs'; +import { DEFAULT_SETTINGS_SPEC, SettingsSpec, Spec, SpecType } from '../../specs'; import { updateParentDimensions } from '../../state/actions/chart_settings'; import { upsertSpec, specParsed } from '../../state/actions/specs'; import { chartStoreReducer, GlobalChartState } from '../../state/chart_state'; +import { getSettingsSpecSelector } from '../../state/selectors/get_settings_specs'; +import { mergePartial } from '../../utils/common'; /** @internal */ export class MockStore { @@ -58,4 +61,31 @@ export class MockStore { ) { store.dispatch(updateParentDimensions({ width, height, top, left })); } + + /** + * udpate settings spec in store + */ + static updateSettings(store: Store, newSettings: Partial) { + const specs = Object.values(store.getState().specs).map((s) => { + if (s.specType === SpecType.Settings) { + return mergePartial(s, newSettings); + } + + return s; + }); + + MockStore.addSpecs(specs, store); + } + + /** + * flush all debounced listeners + * + * See packages/charts/src/__mocks__/ts-debounce.ts + */ + static flush(store: Store) { + const settings = getSettingsSpecSelector(store.getState()); + + // debounce mocked as lodash.debounce to enable flush + if (settings.onPointerUpdate) ((settings.onPointerUpdate as unknown) as Cancelable).flush(); + } } diff --git a/packages/osd-charts/packages/charts/src/specs/constants.ts b/packages/osd-charts/packages/charts/src/specs/constants.ts index c4e16dfbfaec..bd20958aaa40 100644 --- a/packages/osd-charts/packages/charts/src/specs/constants.ts +++ b/packages/osd-charts/packages/charts/src/specs/constants.ts @@ -108,6 +108,18 @@ export const BrushAxis = Object.freeze({ /** @public */ export type BrushAxis = $Values; +/** + * pointer update trigger + * @public + */ +export const PointerUpdateTrigger = Object.freeze({ + X: 'x' as const, + Y: 'y' as const, + Both: 'both' as const, +}); +/** @public */ +export type PointerUpdateTrigger = $Values; + /** * The position to stick the tooltip to * @public @@ -162,6 +174,7 @@ export const DEFAULT_SETTINGS_SPEC: SettingsSpec = { type: DEFAULT_TOOLTIP_TYPE, snap: DEFAULT_TOOLTIP_SNAP, }, + pointerUpdateTrigger: PointerUpdateTrigger.X, externalPointerEvents: { tooltip: { visible: false, diff --git a/packages/osd-charts/packages/charts/src/specs/settings.test.tsx b/packages/osd-charts/packages/charts/src/specs/settings.test.tsx index 94965def337a..084976e32694 100644 --- a/packages/osd-charts/packages/charts/src/specs/settings.test.tsx +++ b/packages/osd-charts/packages/charts/src/specs/settings.test.tsx @@ -122,13 +122,13 @@ describe('Settings spec component', () => { expect(settingSpec.onLegendItemPlusClick).toBeUndefined(); expect(settingSpec.onLegendItemMinusClick).toBeUndefined(); - const onElementClick = (): void => {}; - const onElementOver = (): void => {}; - const onOut = () => {}; - const onBrushEnd = (): void => {}; - const onLegendEvent = (): void => {}; - const onPointerUpdateEvent = (): void => {}; - const onRenderChangeEvent = (): void => {}; + const onElementClick = jest.fn(); + const onElementOver = jest.fn(); + const onOut = jest.fn(); + const onBrushEnd = jest.fn(); + const onLegendEvent = jest.fn(); + const onPointerUpdateEvent = jest.fn(); + const onRenderChangeEvent = jest.fn(); const updatedProps: Partial = { onElementClick, @@ -156,8 +156,10 @@ describe('Settings spec component', () => { expect(settingSpec.onLegendItemClick).toEqual(onLegendEvent); expect(settingSpec.onLegendItemPlusClick).toEqual(onLegendEvent); expect(settingSpec.onLegendItemMinusClick).toEqual(onLegendEvent); - expect(settingSpec.onPointerUpdate).toEqual(onPointerUpdateEvent); expect(settingSpec.onRenderChange).toEqual(onRenderChangeEvent); + + // check for debounced functions + expect(settingSpec.onPointerUpdate).toBeDefined(); }); test('should allow partial theme', () => { diff --git a/packages/osd-charts/packages/charts/src/specs/settings.tsx b/packages/osd-charts/packages/charts/src/specs/settings.tsx index 5bc356b88b47..2a7f8e086a93 100644 --- a/packages/osd-charts/packages/charts/src/specs/settings.tsx +++ b/packages/osd-charts/packages/charts/src/specs/settings.tsx @@ -45,7 +45,15 @@ import { GeometryValue } from '../utils/geometry'; import { GroupId } from '../utils/ids'; import { SeriesCompareFn } from '../utils/series_sort'; import { PartialTheme, Theme } from '../utils/themes/theme'; -import { BinAgg, BrushAxis, DEFAULT_SETTINGS_SPEC, Direction, PointerEventType, TooltipType } from './constants'; +import { + BinAgg, + BrushAxis, + DEFAULT_SETTINGS_SPEC, + Direction, + PointerEventType, + PointerUpdateTrigger, + TooltipType, +} from './constants'; /** @public */ export interface LayerValue { @@ -144,7 +152,11 @@ export type ElementOverListener = ( export type BrushEndListener = (brushArea: XYBrushArea) => void; /** @public */ export type LegendItemListener = (series: SeriesIdentifier[]) => void; -/** @public */ +/** + * The listener type for generic mouse move + * + * @public + */ export type PointerUpdateListener = (event: PointerEvent) => void; /** * Listener to be called when chart render state changes @@ -167,7 +179,7 @@ export interface BasePointerEvent { * fired as callback argument for `PointerUpdateListener` * @public */ -export interface PointerOverEvent extends BasePointerEvent { +export interface PointerOverEvent extends BasePointerEvent, ProjectedValues { type: typeof PointerEventType.Over; scale: ScaleContinuousType | ScaleOrdinalType; /** @@ -175,7 +187,6 @@ export interface PointerOverEvent extends BasePointerEvent { * @alpha */ unit?: string; - value: number | string | null; } /** @public */ export interface PointerOutEvent extends BasePointerEvent { @@ -501,12 +512,31 @@ export interface SettingsSpec extends Spec, LegendSpec { onElementOut?: BasicListener; pointBuffer?: MarkBuffer; onBrushEnd?: BrushEndListener; - onPointerUpdate?: PointerUpdateListener; onRenderChange?: RenderChangeListener; xDomain?: CustomXDomain; + + /** + * debounce delay used for resizing chart + */ resizeDebounce?: number; + /** + * debounce delay used for onPointerUpdate listener + */ + pointerUpdateDebounce?: number; + + /** + * trigger for onPointerUpdate listener. + * + * - `'x'` - only triggers lister when x value changes + * - `'y'` - only triggers lister when y values change + * - `'both'` - triggers lister when x or y values change + * + * @defaultValue 'x' + */ + pointerUpdateTrigger: PointerUpdateTrigger; + /** * Block the brush tool on a specific axis: x, y or both. * @defaultValue `x` {@link (BrushAxis:type) | BrushAxis.X} @@ -639,6 +669,8 @@ export type DefaultSettingsProps = | 'rendering' | 'rotation' | 'resizeDebounce' + | 'pointerUpdateDebounce' + | 'pointerUpdateTrigger' | 'animateData' | 'debug' | 'tooltip' diff --git a/packages/osd-charts/packages/charts/src/state/selectors/get_settings_specs.ts b/packages/osd-charts/packages/charts/src/state/selectors/get_settings_specs.ts index ab6c3940aae4..89a90b3dbe7d 100644 --- a/packages/osd-charts/packages/charts/src/state/selectors/get_settings_specs.ts +++ b/packages/osd-charts/packages/charts/src/state/selectors/get_settings_specs.ts @@ -17,6 +17,8 @@ * under the License. */ +import { debounce } from 'ts-debounce'; + import { ChartType } from '../../chart_types'; import { SpecType, DEFAULT_SETTINGS_SPEC } from '../../specs/constants'; import { SettingsSpec } from '../../specs/settings'; @@ -24,6 +26,8 @@ import { GlobalChartState } from '../chart_state'; import { createCustomCachedSelector } from '../create_selector'; import { getSpecsFromStore } from '../utils'; +const DEFAULT_POINTER_UPDATE_DEBOUNCE = 16; + /** @internal */ export const getSpecs = (state: GlobalChartState) => state.specs; @@ -35,8 +39,16 @@ export const getSettingsSpecSelector = createCustomCachedSelector( (specs): SettingsSpec => { const settingsSpecs = getSpecsFromStore(specs, ChartType.Global, SpecType.Settings); if (settingsSpecs.length === 1) { - return settingsSpecs[0]; + return handleListenerDebouncing(settingsSpecs[0]); } return DEFAULT_SETTINGS_SPEC; }, ); + +function handleListenerDebouncing(settings: SettingsSpec): SettingsSpec { + const delay = settings.pointerUpdateDebounce ?? DEFAULT_POINTER_UPDATE_DEBOUNCE; + + if (settings.onPointerUpdate) settings.onPointerUpdate = debounce(settings.onPointerUpdate, delay); + + return settings; +} diff --git a/packages/osd-charts/packages/charts/src/state/selectors/is_external_tooltip_visible.ts b/packages/osd-charts/packages/charts/src/state/selectors/is_external_tooltip_visible.ts index cf2b345eb092..6e8723f1a5da 100644 --- a/packages/osd-charts/packages/charts/src/state/selectors/is_external_tooltip_visible.ts +++ b/packages/osd-charts/packages/charts/src/state/selectors/is_external_tooltip_visible.ts @@ -40,7 +40,7 @@ export const isExternalTooltipVisibleSelector = createCustomCachedSelector( if (!pointer || pointer.type !== PointerEventType.Over || externalPointerEvents.tooltip?.visible === false) { return false; } - const x = xScale.pureScale(pointer.value); + const x = xScale.pureScale(pointer.x); if (x == null || x > chartDimensions.width + chartDimensions.left || x < 0) { return false; diff --git a/packages/osd-charts/stories/interactions/16_cursor_update_action.tsx b/packages/osd-charts/stories/interactions/16_cursor_update_action.tsx index 2ce92a9eaa76..1687d0cf359a 100644 --- a/packages/osd-charts/stories/interactions/16_cursor_update_action.tsx +++ b/packages/osd-charts/stories/interactions/16_cursor_update_action.tsx @@ -18,7 +18,7 @@ */ import { action } from '@storybook/addon-actions'; -import { boolean, select } from '@storybook/addon-knobs'; +import { boolean, number, select } from '@storybook/addon-knobs'; import React from 'react'; import { @@ -89,6 +89,18 @@ export const Example = () => { const topPlacement = getPlacementKnob('external tooltip placement', Placement.Left, group1); const bottomPlacement = getPlacementKnob('external tooltip placement', Placement.Left, group2); + const debounceDelay = number('pointer update debounce', 20, { min: 0, max: 200, step: 10 }); + const trigger = + select( + 'pointer update trigger', + { + 'Only x': 'x', + 'Only y': 'y', + 'Both x and y': 'both', + }, + 'x', + ) ?? 'x'; + return ( <> @@ -96,6 +108,8 @@ export const Example = () => { showLegend showLegendExtra onPointerUpdate={pointerUpdate} + pointerUpdateDebounce={debounceDelay} + pointerUpdateTrigger={trigger} externalPointerEvents={{ tooltip: { visible: topVisible, placement: topPlacement }, }} diff --git a/packages/osd-charts/yarn.lock b/packages/osd-charts/yarn.lock index ab90a9a2999e..c5517a51f0de 100644 --- a/packages/osd-charts/yarn.lock +++ b/packages/osd-charts/yarn.lock @@ -24597,10 +24597,10 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/true-myth/-/true-myth-4.1.0.tgz#a73e1f945c5382758ba9806c15062d2ebbf35427" integrity sha512-X4oBf1WOuLMfXpcKLI94dV4Htknv06vMADss3Whcybr85W1ctjZGZOzMMRYXUUBlhV4bDAGeMojzN/dw7N7qWA== -ts-debounce@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-1.0.0.tgz#e433301744ba75fe25466f7f23e1382c646aae6a" - integrity sha512-V+IzWj418IoqqxVJD6I0zjPtgIyvAJ8VyViqzcxZ0JRiJXsi5mCmy1yUKkWd2gUygT28a8JsVFCgqdrf2pLUHQ== +ts-debounce@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-3.0.0.tgz#9beedf59c04de3b5bef8ff28bd6885624df357be" + integrity sha512-7jiRWgN4/8IdvCxbIwnwg2W0bbYFBH6BxFqBjMKk442t7+liF2Z1H6AUCcl8e/pD93GjPru+axeiJwFmRww1WQ== ts-dedent@^1.1.0: version "1.1.1"