From 19c14e492e26a3aeb7fd5527a08db4f5041fe4be Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 25 Nov 2020 17:57:21 +0100 Subject: [PATCH] feat: add projection click listener (#913) This commits adds a projection click listener called onProjectionListener and available in the component with the following characteristics: - it will fire every time the user clicks on the projection area (everything within the axes) - the listener is called with an object that contains the nearest X and Ys values. These values are not screen coordinates but the real data domain values inverted computed inverting the x and y scales. - we prevent the onProjectionListener from firing an event if a onElementClick listener is available and it has fired a click event close #846 --- packages/osd-charts/api/charts.api.md | 15 ++++ .../xy_chart/state/chart_state.tsx | 9 ++- .../state/selectors/get_cursor_pointer.ts | 14 +++- .../selectors/get_projected_scaled_values.ts | 54 +++++++++++++ ...ent_click_caller.ts => on_click_caller.ts} | 79 +++++++++++++------ packages/osd-charts/src/specs/settings.tsx | 40 +++++++++- .../stories/interactions/1_bar_clicks.tsx | 1 + 7 files changed, 182 insertions(+), 30 deletions(-) create mode 100644 packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts rename packages/osd-charts/src/chart_types/xy_chart/state/selectors/{on_element_click_caller.ts => on_click_caller.ts} (51%) diff --git a/packages/osd-charts/api/charts.api.md b/packages/osd-charts/api/charts.api.md index 50ba6281f1b9..d88a2c6e6d3d 100644 --- a/packages/osd-charts/api/charts.api.md +++ b/packages/osd-charts/api/charts.api.md @@ -1367,6 +1367,20 @@ export interface Postfixes { y1AccessorFormat?: string; } +// @public +export type ProjectedValues = { + x: PrimitiveValue; + y: Array<{ + value: PrimitiveValue; + groupId: string; + }>; + smVerticalValue: PrimitiveValue; + smHorizontalValue: PrimitiveValue; +}; + +// @public +export type ProjectionClickListener = (values: ProjectedValues) => void; + // Warning: (ae-missing-release-tag) "RectAnnotation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1605,6 +1619,7 @@ export interface SettingsSpec extends Spec { onLegendItemPlusClick?: LegendItemListener; // (undocumented) onPointerUpdate?: PointerUpdateListener; + onProjectionClick?: ProjectionClickListener; // (undocumented) onRenderChange?: RenderChangeListener; orderOrdinalBinsBy?: OrderBy; diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx b/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx index c25a665c1bf3..00bd5c071621 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx +++ b/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx @@ -47,7 +47,7 @@ import { isBrushingSelector } from './selectors/is_brushing'; import { isChartEmptySelector } from './selectors/is_chart_empty'; import { isTooltipVisibleSelector } from './selectors/is_tooltip_visible'; import { createOnBrushEndCaller } from './selectors/on_brush_end_caller'; -import { createOnElementClickCaller } from './selectors/on_element_click_caller'; +import { createOnClickCaller } from './selectors/on_click_caller'; import { createOnElementOutCaller } from './selectors/on_element_out_caller'; import { createOnElementOverCaller } from './selectors/on_element_over_caller'; import { createOnPointerMoveCaller } from './selectors/on_pointer_move_caller'; @@ -57,18 +57,19 @@ export class XYAxisChartState implements InternalChartState { chartType: ChartTypes; legendId: string; - onElementClickCaller: (state: GlobalChartState) => void; + onClickCaller: (state: GlobalChartState) => void; onElementOverCaller: (state: GlobalChartState) => void; onElementOutCaller: (state: GlobalChartState) => void; onBrushEndCaller: (state: GlobalChartState) => void; onPointerMoveCaller: (state: GlobalChartState) => void; constructor() { - this.onElementClickCaller = createOnElementClickCaller(); + this.onClickCaller = createOnClickCaller(); this.onElementOverCaller = createOnElementOverCaller(); this.onElementOutCaller = createOnElementOutCaller(); this.onBrushEndCaller = createOnBrushEndCaller(); this.onPointerMoveCaller = createOnPointerMoveCaller(); + this.chartType = ChartTypes.XYAxis; this.legendId = htmlIdGenerator()('legend'); } @@ -145,7 +146,7 @@ export class XYAxisChartState implements InternalChartState { eventCallbacks(globalState: GlobalChartState) { this.onElementOverCaller(globalState); this.onElementOutCaller(globalState); - this.onElementClickCaller(globalState); + this.onClickCaller(globalState); this.onBrushEndCaller(globalState); this.onPointerMoveCaller(globalState); } diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts index 96ebd7acfaa3..d886309c97e1 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts @@ -23,6 +23,7 @@ import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; import { computeChartDimensionsSelector } from './compute_chart_dimensions'; +import { getProjectedScaledValues } from './get_projected_scaled_values'; import { getHighlightedGeomsSelector } from './get_tooltip_values_highlighted_geoms'; import { isBrushAvailableSelector } from './is_brush_available'; @@ -34,10 +35,18 @@ export const getPointerCursorSelector = createCachedSelector( getHighlightedGeomsSelector, getSettingsSpecSelector, getCurrentPointerPositionSelector, + getProjectedScaledValues, computeChartDimensionsSelector, isBrushAvailableSelector, ], - (highlightedGeometries, settingsSpec, currentPointerPosition, { chartDimensions }, isBrushAvailable): string => { + ( + highlightedGeometries, + settingsSpec, + currentPointerPosition, + projectedValues, + { chartDimensions }, + isBrushAvailable, + ): string => { const { x, y } = currentPointerPosition; // get positions relative to chart const xPos = x - chartDimensions.left; @@ -53,6 +62,9 @@ export const getPointerCursorSelector = createCachedSelector( if (highlightedGeometries.length > 0 && (settingsSpec.onElementClick || settingsSpec.onElementOver)) { return 'pointer'; } + if (projectedValues !== null && settingsSpec.onProjectionClick) { + return 'pointer'; + } return isBrushAvailable ? 'crosshair' : 'default'; }, )(getChartIdSelector); diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts new file mode 100644 index 000000000000..072e4a7d3344 --- /dev/null +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts @@ -0,0 +1,54 @@ +/* + * 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. + */ + +import createCachedSelector from 're-reselect'; + +import { ProjectedValues } from '../../../../specs/settings'; +import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { computeSeriesGeometriesSelector } from './compute_series_geometries'; +import { getGeometriesIndexKeysSelector } from './get_geometries_index_keys'; +import { getOrientedProjectedPointerPositionSelector } from './get_oriented_projected_pointer_position'; + +/** @internal */ +export const getProjectedScaledValues = createCachedSelector( + [getOrientedProjectedPointerPositionSelector, computeSeriesGeometriesSelector, getGeometriesIndexKeysSelector], + ( + { x, y, verticalPanelValue, horizontalPanelValue }, + { scales: { xScale, yScales } }, + geometriesIndexKeys, + ): ProjectedValues | undefined => { + if (!xScale) { + return; + } + + const xValue = xScale.invertWithStep(x, geometriesIndexKeys); + if (!xValue) { + return; + } + + return { + x: xValue.value, + y: [...yScales.entries()].map(([groupId, yScale]) => { + return { value: yScale.invert(y), groupId }; + }), + smVerticalValue: verticalPanelValue, + smHorizontalValue: horizontalPanelValue, + }; + }, +)(getChartIdSelector); diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_click_caller.ts similarity index 51% rename from packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts rename to packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_click_caller.ts index 488ec42fe8ea..038b90fe658c 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/on_click_caller.ts @@ -21,13 +21,15 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'reselect'; import { ChartTypes } from '../../..'; -import { SettingsSpec } from '../../../../specs'; +import { ProjectedValues, SettingsSpec } from '../../../../specs'; import { GlobalChartState, PointerState } from '../../../../state/chart_state'; +import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; import { isClicking } from '../../../../state/utils'; import { IndexedGeometry, GeometryValue } from '../../../../utils/geometry'; import { XYChartSeriesIdentifier } from '../../utils/series'; +import { getProjectedScaledValues } from './get_projected_scaled_values'; import { getHighlightedGeomsSelector } from './get_tooltip_values_highlighted_geoms'; /** @@ -37,33 +39,62 @@ import { getHighlightedGeomsSelector } from './get_tooltip_values_highlighted_ge * - the pointer state goes from down state to up state * @internal */ -export function createOnElementClickCaller(): (state: GlobalChartState) => void { +export function createOnClickCaller(): (state: GlobalChartState) => void { let prevClick: PointerState | null = null; let selector: Selector | null = null; return (state: GlobalChartState) => { - if (selector === null && state.chartType === ChartTypes.XYAxis) { - selector = createCachedSelector( - [getLastClickSelector, getSettingsSpecSelector, getHighlightedGeomsSelector], - (lastClick: PointerState | null, settings: SettingsSpec, indexedGeometries: IndexedGeometry[]): void => { - if (!settings.onElementClick) { - return; - } - if (indexedGeometries.length > 0 && isClicking(prevClick, lastClick)) { - if (settings && settings.onElementClick) { - const elements = indexedGeometries.map<[GeometryValue, XYChartSeriesIdentifier]>( - ({ value, seriesIdentifier }) => [value, seriesIdentifier], - ); - settings.onElementClick(elements); - } - } - prevClick = lastClick; - }, - )({ - keySelector: (state: GlobalChartState) => state.chartId, - }); - } if (selector) { - selector(state); + return selector(state); + } + if (state.chartType !== ChartTypes.XYAxis) { + return; } + selector = createCachedSelector( + [getLastClickSelector, getSettingsSpecSelector, getHighlightedGeomsSelector, getProjectedScaledValues], + ( + lastClick: PointerState | null, + { onElementClick, onProjectionClick }: SettingsSpec, + indexedGeometries: IndexedGeometry[], + values, + ): void => { + if (!isClicking(prevClick, lastClick)) { + return; + } + const elementClickFired = tryFiringOnElementClick(indexedGeometries, onElementClick); + if (!elementClickFired) { + tryFiringOnProjectionClick(values, onProjectionClick); + } + prevClick = lastClick; + }, + )({ + keySelector: getChartIdSelector, + }); }; } + +function tryFiringOnElementClick( + indexedGeometries: IndexedGeometry[], + onElementClick: SettingsSpec['onElementClick'], +): boolean { + if (indexedGeometries.length === 0 || !onElementClick) { + return false; + } + + const elements = indexedGeometries.map<[GeometryValue, XYChartSeriesIdentifier]>(({ value, seriesIdentifier }) => [ + value, + seriesIdentifier, + ]); + onElementClick(elements); + return true; +} + +function tryFiringOnProjectionClick( + values: ProjectedValues | undefined, + onProjectionClick: SettingsSpec['onProjectionClick'], +): boolean { + if (values === undefined || !onProjectionClick) { + return false; + } + onProjectionClick(values); + return true; +} diff --git a/packages/osd-charts/src/specs/settings.tsx b/packages/osd-charts/src/specs/settings.tsx index 62c85f1a48d9..544a515ea344 100644 --- a/packages/osd-charts/src/specs/settings.tsx +++ b/packages/osd-charts/src/specs/settings.tsx @@ -55,6 +55,38 @@ export type XYChartElementEvent = [GeometryValue, XYChartSeriesIdentifier]; export type PartitionElementEvent = [Array, SeriesIdentifier]; export type HeatmapElementEvent = [Cell, SeriesIdentifier]; +/** + * @public + * An object that contains the scaled mouse position based on + * the current chart configuration. + */ +export type ProjectedValues = { + /** + * The independent variable of the chart + */ + x: PrimitiveValue; + /** + * The set of dependent variable, each one with its own groupId + */ + y: Array<{ value: PrimitiveValue; groupId: string }>; + /** + * The categorical value used for the vertical placement of the chart + * in a small multiple layout + */ + smVerticalValue: PrimitiveValue; + /** + * The categorical value used for the horizontal placement of the chart + * in a small multiple layout + */ + smHorizontalValue: PrimitiveValue; +}; + +/** + * @public + * The listener type for click on the projection area. + */ +export type ProjectionClickListener = (values: ProjectedValues) => void; + export type ElementClickListener = ( elements: Array, ) => void; @@ -77,7 +109,7 @@ export interface BasePointerEvent { type: PointerEventType; } /** - * Event used to syncronize pointers/mouse positions between Charts. + * Event used to synchronize pointers/mouse positions between Charts. * * fired as callback argument for `PointerUpdateListener` */ @@ -328,6 +360,12 @@ export interface SettingsSpec extends Spec { * Compares title, position and first & last tick labels */ hideDuplicateAxes: boolean; + /** + * Attach a listener for click on the projection area. + * The listener will be called with the current x value snapped to the closest + * X axis point, and an array of Y values for every groupId used in the chart. + */ + onProjectionClick?: ProjectionClickListener; onElementClick?: ElementClickListener; onElementOver?: ElementOverListener; onElementOut?: BasicListener; diff --git a/packages/osd-charts/stories/interactions/1_bar_clicks.tsx b/packages/osd-charts/stories/interactions/1_bar_clicks.tsx index ca808f4a9836..5ae5bf0c75eb 100644 --- a/packages/osd-charts/stories/interactions/1_bar_clicks.tsx +++ b/packages/osd-charts/stories/interactions/1_bar_clicks.tsx @@ -27,6 +27,7 @@ const onElementListeners = { onElementClick: action('onElementClick'), onElementOver: action('onElementOver'), onElementOut: action('onElementOut'), + onProjectionClick: action('onProjectionClick'), }; export const Example = () => {