Skip to content

Commit

Permalink
feat: add projection click listener (opensearch-project#913)
Browse files Browse the repository at this point in the history
This commits adds a projection click listener called onProjectionListener and available in the <Settings /> 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 opensearch-project#846
  • Loading branch information
markov00 authored Nov 25, 2020
1 parent 6d5467c commit 19c14e4
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 30 deletions.
15 changes: 15 additions & 0 deletions packages/osd-charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1605,6 +1619,7 @@ export interface SettingsSpec extends Spec {
onLegendItemPlusClick?: LegendItemListener;
// (undocumented)
onPointerUpdate?: PointerUpdateListener;
onProjectionClick?: ProjectionClickListener;
// (undocumented)
onRenderChange?: RenderChangeListener;
orderOrdinalBinsBy?: OrderBy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
}
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<GlobalChartState, void> | 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;
}
40 changes: 39 additions & 1 deletion packages/osd-charts/src/specs/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ export type XYChartElementEvent = [GeometryValue, XYChartSeriesIdentifier];
export type PartitionElementEvent = [Array<LayerValue>, 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<XYChartElementEvent | PartitionElementEvent | HeatmapElementEvent>,
) => void;
Expand All @@ -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`
*/
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/osd-charts/stories/interactions/1_bar_clicks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const onElementListeners = {
onElementClick: action('onElementClick'),
onElementOver: action('onElementOver'),
onElementOut: action('onElementOut'),
onProjectionClick: action('onProjectionClick'),
};

export const Example = () => {
Expand Down

0 comments on commit 19c14e4

Please sign in to comment.