From 12f02997d2a2b6f08d48f121466f1109ba037211 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Tue, 24 Nov 2020 14:46:18 +0100 Subject: [PATCH 1/2] feat: add projection click listener close #846 --- api/charts.api.md | 15 +++++ .../xy_chart/state/chart_state.tsx | 4 ++ .../state/selectors/get_cursor_pointer.ts | 14 ++++- .../selectors/get_projected_scaled_values.ts | 54 ++++++++++++++++ .../selectors/on_projection_click_caller.ts | 62 +++++++++++++++++++ src/specs/settings.tsx | 40 +++++++++++- stories/interactions/1_bar_clicks.tsx | 1 + 7 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts create mode 100644 src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts diff --git a/api/charts.api.md b/api/charts.api.md index 0e2ab07c47..786398249f 100644 --- a/api/charts.api.md +++ b/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/src/chart_types/xy_chart/state/chart_state.tsx b/src/chart_types/xy_chart/state/chart_state.tsx index c25a665c1b..4958446cdb 100644 --- a/src/chart_types/xy_chart/state/chart_state.tsx +++ b/src/chart_types/xy_chart/state/chart_state.tsx @@ -51,6 +51,7 @@ import { createOnElementClickCaller } from './selectors/on_element_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'; +import { createOnProjectionClickCaller } from './selectors/on_projection_click_caller'; /** @internal */ export class XYAxisChartState implements InternalChartState { @@ -62,6 +63,7 @@ export class XYAxisChartState implements InternalChartState { onElementOutCaller: (state: GlobalChartState) => void; onBrushEndCaller: (state: GlobalChartState) => void; onPointerMoveCaller: (state: GlobalChartState) => void; + onProjectionClickCaller: (state: GlobalChartState) => void; constructor() { this.onElementClickCaller = createOnElementClickCaller(); @@ -69,6 +71,7 @@ export class XYAxisChartState implements InternalChartState { this.onElementOutCaller = createOnElementOutCaller(); this.onBrushEndCaller = createOnBrushEndCaller(); this.onPointerMoveCaller = createOnPointerMoveCaller(); + this.onProjectionClickCaller = createOnProjectionClickCaller(); this.chartType = ChartTypes.XYAxis; this.legendId = htmlIdGenerator()('legend'); } @@ -148,6 +151,7 @@ export class XYAxisChartState implements InternalChartState { this.onElementClickCaller(globalState); this.onBrushEndCaller(globalState); this.onPointerMoveCaller(globalState); + this.onProjectionClickCaller(globalState); } getDebugState(globalState: GlobalChartState) { diff --git a/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts b/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts index 96ebd7acfa..d886309c97 100644 --- a/src/chart_types/xy_chart/state/selectors/get_cursor_pointer.ts +++ b/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/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts b/src/chart_types/xy_chart/state/selectors/get_projected_scaled_values.ts new file mode 100644 index 0000000000..072e4a7d33 --- /dev/null +++ b/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/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts b/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts new file mode 100644 index 0000000000..fc5637b4ad --- /dev/null +++ b/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts @@ -0,0 +1,62 @@ +/* + * 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 { Selector } from 'reselect'; + +import { ChartTypes } from '../../..'; +import { GlobalChartState, PointerState } from '../../../../state/chart_state'; +import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; +import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import { isClicking } from '../../../../state/utils'; +import { getProjectedScaledValues } from './get_projected_scaled_values'; + +/** + * Will call the onElementClick listener every time the following preconditions are met: + * - the onElementClick listener is available + * - we have at least one highlighted geometry + * - the pointer state goes from down state to up state + * @internal + */ +export function createOnProjectionClickCaller(): (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, getProjectedScaledValues], + (lastClick, { onProjectionClick }, values): void => { + if (!onProjectionClick) { + return; + } + + if (values !== undefined && isClicking(prevClick, lastClick)) { + onProjectionClick(values); + } + prevClick = lastClick; + }, + )({ + keySelector: ({ chartId }) => chartId, + }); + } + if (selector) { + selector(state); + } + }; +} diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 62c85f1a48..544a515ea3 100644 --- a/src/specs/settings.tsx +++ b/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/stories/interactions/1_bar_clicks.tsx b/stories/interactions/1_bar_clicks.tsx index ca808f4a98..5ae5bf0c75 100644 --- a/stories/interactions/1_bar_clicks.tsx +++ b/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 = () => { From 5d203f221540c800315e1e875096f67d6a191d5d Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 25 Nov 2020 10:19:52 +0100 Subject: [PATCH 2/2] refactor: merge element and project click handlers --- .../xy_chart/state/chart_state.tsx | 13 ++- ...ent_click_caller.ts => on_click_caller.ts} | 79 +++++++++++++------ .../selectors/on_projection_click_caller.ts | 62 --------------- 3 files changed, 60 insertions(+), 94 deletions(-) rename src/chart_types/xy_chart/state/selectors/{on_element_click_caller.ts => on_click_caller.ts} (51%) delete mode 100644 src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts diff --git a/src/chart_types/xy_chart/state/chart_state.tsx b/src/chart_types/xy_chart/state/chart_state.tsx index 4958446cdb..00bd5c0716 100644 --- a/src/chart_types/xy_chart/state/chart_state.tsx +++ b/src/chart_types/xy_chart/state/chart_state.tsx @@ -47,31 +47,29 @@ 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'; -import { createOnProjectionClickCaller } from './selectors/on_projection_click_caller'; /** @internal */ 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; - onProjectionClickCaller: (state: GlobalChartState) => void; constructor() { - this.onElementClickCaller = createOnElementClickCaller(); + this.onClickCaller = createOnClickCaller(); this.onElementOverCaller = createOnElementOverCaller(); this.onElementOutCaller = createOnElementOutCaller(); this.onBrushEndCaller = createOnBrushEndCaller(); this.onPointerMoveCaller = createOnPointerMoveCaller(); - this.onProjectionClickCaller = createOnProjectionClickCaller(); + this.chartType = ChartTypes.XYAxis; this.legendId = htmlIdGenerator()('legend'); } @@ -148,10 +146,9 @@ 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); - this.onProjectionClickCaller(globalState); } getDebugState(globalState: GlobalChartState) { diff --git a/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts b/src/chart_types/xy_chart/state/selectors/on_click_caller.ts similarity index 51% rename from src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts rename to src/chart_types/xy_chart/state/selectors/on_click_caller.ts index 488ec42fe8..038b90fe65 100644 --- a/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts +++ b/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/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts b/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts deleted file mode 100644 index fc5637b4ad..0000000000 --- a/src/chart_types/xy_chart/state/selectors/on_projection_click_caller.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 { Selector } from 'reselect'; - -import { ChartTypes } from '../../..'; -import { GlobalChartState, PointerState } from '../../../../state/chart_state'; -import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; -import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { isClicking } from '../../../../state/utils'; -import { getProjectedScaledValues } from './get_projected_scaled_values'; - -/** - * Will call the onElementClick listener every time the following preconditions are met: - * - the onElementClick listener is available - * - we have at least one highlighted geometry - * - the pointer state goes from down state to up state - * @internal - */ -export function createOnProjectionClickCaller(): (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, getProjectedScaledValues], - (lastClick, { onProjectionClick }, values): void => { - if (!onProjectionClick) { - return; - } - - if (values !== undefined && isClicking(prevClick, lastClick)) { - onProjectionClick(values); - } - prevClick = lastClick; - }, - )({ - keySelector: ({ chartId }) => chartId, - }); - } - if (selector) { - selector(state); - } - }; -}