diff --git a/.eslintrc.js b/.eslintrc.js index d80a2b9b4f..c83645ca79 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -93,7 +93,7 @@ module.exports = { 'under the License.', ], 'block', - ['-\\*-(.*)-\\*-', 'eslint(.*)'], + ['-\\*-(.*)-\\*-', 'eslint(.*)', '@jest-environment'], ], }, settings: { diff --git a/.playground/playground.tsx b/.playground/playground.tsx index 4c4796e5ca..deed09e964 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -22,51 +22,57 @@ import { ScaleType, Position, Axis, - LineSeries, - LineAnnotation, - RectAnnotation, - AnnotationDomainTypes, - LineAnnotationDatum, - RectAnnotationDatum, + Settings, + PartitionElementEvent, + XYChartElementEvent, + BarSeries, } from '../src'; -import { SeededDataGenerator } from '../src/mocks/utils'; export class Playground extends React.Component<{}, { isSunburstShown: boolean }> { + onClick = (elements: Array) => { + // eslint-disable-next-line no-console + console.log(elements[0]); + }; render() { - const dg = new SeededDataGenerator(); - const data = dg.generateGroupedSeries(10, 2).map((item) => ({ - ...item, - y1: item.y + 100, - })); - const lineDatum: LineAnnotationDatum[] = [{ dataValue: 321321 }]; - const rectDatum: RectAnnotationDatum[] = [{ coordinates: { x1: 100 } }]; - return ( <>
- - - - - + - + { + return `${d} $`; + }, + }} /> - -
diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-sunburst-slice-clicks-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-sunburst-slice-clicks-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..04f85d76c2 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-sunburst-slice-clicks-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-tooltips-rotation-90-shows-tooltip-on-sunburst-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-tooltips-rotation-90-shows-tooltip-on-sunburst-1-snap.png new file mode 100644 index 0000000000..2874bc10f6 Binary files /dev/null and b/integration/tests/__image_snapshots__/interactions-test-ts-tooltips-rotation-90-shows-tooltip-on-sunburst-1-snap.png differ diff --git a/integration/tests/interactions.test.ts b/integration/tests/interactions.test.ts index 3c2069522f..b999eccb66 100644 --- a/integration/tests/interactions.test.ts +++ b/integration/tests/interactions.test.ts @@ -94,5 +94,14 @@ describe.only('Tooltips', () => { }, ); }); + it('shows tooltip on sunburst', async () => { + await common.expectChartWithMouseAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/interactions--sunburst-slice-clicks', + { + x: 350, + y: 100, + }, + ); + }); }); }); diff --git a/src/chart_types/partition_chart/layout/types/config_types.ts b/src/chart_types/partition_chart/layout/types/config_types.ts index 1e611b54fd..5af1490afb 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -22,8 +22,8 @@ import { $Values as Values } from 'utility-types'; import { Color, ValueFormatter } from '../../../../utils/commons'; export const PartitionLayout = Object.freeze({ - sunburst: 'sunburst', - treemap: 'treemap', + sunburst: 'sunburst' as 'sunburst', + treemap: 'treemap' as 'treemap', }); export type PartitionLayout = Values; // could use ValuesType diff --git a/src/chart_types/partition_chart/state/chart_state.tsx b/src/chart_types/partition_chart/state/chart_state.tsx index 8bf2145152..870cac84f5 100644 --- a/src/chart_types/partition_chart/state/chart_state.tsx +++ b/src/chart_types/partition_chart/state/chart_state.tsx @@ -23,9 +23,21 @@ import { Partition } from '../renderer/canvas/partition'; import { isTooltipVisibleSelector } from '../state/selectors/is_tooltip_visible'; import { getTooltipInfoSelector } from '../state/selectors/tooltip'; import { Tooltip } from '../../../components/tooltip'; +import { createOnElementClickCaller } from './selectors/on_element_click_caller'; +import { createOnElementOverCaller } from './selectors/on_element_over_caller'; +import { createOnElementOutCaller } from './selectors/on_element_out_caller'; const EMPTY_MAP = new Map(); export class PartitionState implements InternalChartState { + onElementClickCaller: (state: GlobalChartState) => void; + onElementOverCaller: (state: GlobalChartState) => void; + onElementOutCaller: (state: GlobalChartState) => void; + + constructor() { + this.onElementClickCaller = createOnElementClickCaller(); + this.onElementOverCaller = createOnElementOverCaller(); + this.onElementOutCaller = createOnElementOutCaller(); + } chartType = ChartTypes.Partition; isBrushAvailable() { return false; @@ -67,4 +79,9 @@ export class PartitionState implements InternalChartState { y1: position.y, }; } + eventCallbacks(globalState: GlobalChartState) { + this.onElementOverCaller(globalState); + this.onElementOutCaller(globalState); + this.onElementClickCaller(globalState); + } } diff --git a/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts new file mode 100644 index 0000000000..fa7670c8ef --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts @@ -0,0 +1,76 @@ +/* + * 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 { GlobalChartState, PointerState } from '../../../../state/chart_state'; +import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import { SettingsSpec, LayerValue } from '../../../../specs'; +import { getPickedShapesLayerValues } from './picked_shapes'; +import { getPieSpecOrNull } from './pie_spec'; +import { ChartTypes } from '../../..'; +import { SeriesIdentifier } from '../../../xy_chart/utils/series'; +import { isClicking } from '../../../../state/utils'; +import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; + +/** + * 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 + */ +export function createOnElementClickCaller(): (state: GlobalChartState) => void { + let prevClick: PointerState | null = null; + let selector: Selector | null = null; + return (state: GlobalChartState) => { + if (selector === null && state.chartType === ChartTypes.Partition) { + selector = createCachedSelector( + [getPieSpecOrNull, getLastClickSelector, getSettingsSpecSelector, getPickedShapesLayerValues], + (pieSpec, lastClick: PointerState | null, settings: SettingsSpec, pickedShapes): void => { + if (!pieSpec) { + return; + } + if (!settings.onElementClick) { + return; + } + const nextPickedShapesLength = pickedShapes.length; + if (nextPickedShapesLength > 0 && isClicking(prevClick, lastClick)) { + if (settings && settings.onElementClick) { + const elements = pickedShapes.map<[Array, SeriesIdentifier]>((values) => { + return [ + values, + { + specId: pieSpec.id, + key: `spec{${pieSpec.id}}`, + }, + ]; + }); + settings.onElementClick(elements); + } + } + prevClick = lastClick; + }, + )({ + keySelector: (state: GlobalChartState) => state.chartId, + }); + } + if (selector) { + selector(state); + } + }; +} diff --git a/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts new file mode 100644 index 0000000000..5260644303 --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/on_element_out_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 { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import createCachedSelector from 're-reselect'; +import { getPickedShapesLayerValues } from './picked_shapes'; +import { getPieSpecOrNull } from './pie_spec'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { Selector } from 'react-redux'; +import { ChartTypes } from '../../../index'; +import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; + +/** + * Will call the onElementOut listener every time the following preconditions are met: + * - the onElementOut listener is available + * - the highlighted geometries list goes from a list of at least one object to an empty one + */ +export function createOnElementOutCaller(): (state: GlobalChartState) => void { + let prevPickedShapes: number | null = null; + let selector: Selector | null = null; + return (state: GlobalChartState) => { + if (selector === null && state.chartType === ChartTypes.Partition) { + selector = createCachedSelector( + [getPieSpecOrNull, getPickedShapesLayerValues, getSettingsSpecSelector], + (pieSpec, pickedShapes, settings): void => { + if (!pieSpec) { + return; + } + if (!settings.onElementOut) { + return; + } + const nextPickedShapes = pickedShapes.length; + + if (prevPickedShapes !== null && prevPickedShapes > 0 && nextPickedShapes === 0) { + settings.onElementOut(); + } + prevPickedShapes = nextPickedShapes; + }, + )({ + keySelector: getChartIdSelector, + }); + } + if (selector) { + selector(state); + } + }; +} diff --git a/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts new file mode 100644 index 0000000000..6a6a247d73 --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts @@ -0,0 +1,95 @@ +/* + * 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 { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import createCachedSelector from 're-reselect'; +import { LayerValue } from '../../../../specs'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { Selector } from 'react-redux'; +import { ChartTypes } from '../../../index'; +import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { getPieSpecOrNull } from './pie_spec'; +import { getPickedShapesLayerValues } from './picked_shapes'; +import { SeriesIdentifier } from '../../../xy_chart/utils/series'; + +function isOverElement(prevPickedShapes: Array> = [], nextPickedShapes: Array>) { + if (nextPickedShapes.length === 0) { + return; + } + if (nextPickedShapes.length !== prevPickedShapes.length) { + return true; + } + return !nextPickedShapes.every((nextPickedShapeValues, index) => { + const prevPickedShapeValues = prevPickedShapes[index]; + if (prevPickedShapeValues === null) { + return false; + } + if (prevPickedShapeValues.length !== nextPickedShapeValues.length) { + return false; + } + return nextPickedShapeValues.every((layerValue, i) => { + const prevPickedValue = prevPickedShapeValues[i]; + if (!prevPickedValue) { + return false; + } + return layerValue.value === prevPickedValue.value && layerValue.groupByRollup === prevPickedValue.groupByRollup; + }); + }); +} + +/** + * Will call the onElementOver listener every time the following preconditions are met: + * - the onElementOver listener is available + * - we have a new set of highlighted geometries on our state + */ +export function createOnElementOverCaller(): (state: GlobalChartState) => void { + let prevPickedShapes: Array> = []; + let selector: Selector | null = null; + return (state: GlobalChartState) => { + if (selector === null && state.chartType === ChartTypes.Partition) { + selector = createCachedSelector( + [getPieSpecOrNull, getPickedShapesLayerValues, getSettingsSpecSelector], + (pieSpec, nextPickedShapes, settings): void => { + if (!pieSpec) { + return; + } + if (!settings.onElementOver) { + return; + } + + if (isOverElement(prevPickedShapes, nextPickedShapes)) { + const elements = nextPickedShapes.map<[Array, SeriesIdentifier]>((values) => [ + values, + { + specId: pieSpec.id, + key: `spec{${pieSpec.id}}`, + }, + ]); + settings.onElementOver(elements); + } + prevPickedShapes = nextPickedShapes; + }, + )({ + keySelector: getChartIdSelector, + }); + } + if (selector) { + selector(state); + } + }; +} diff --git a/src/chart_types/partition_chart/state/selectors/picked_shapes.ts b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts new file mode 100644 index 0000000000..621c644ecc --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts @@ -0,0 +1,64 @@ +/* + * 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 { partitionGeometries } from './geometries'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { LayerValue } from '../../../../specs'; +import { PARENT_KEY, DEPTH_KEY, AGGREGATE_KEY, CHILDREN_KEY, SORT_INDEX_KEY } from '../../layout/utils/group_by_rollup'; +import { QuadViewModel } from '../../layout/types/viewmodel_types'; + +function getCurrentPointerPosition(state: GlobalChartState) { + return state.interactions.pointer.current.position; +} + +export const getPickedShapes = createCachedSelector( + [partitionGeometries, getCurrentPointerPosition], + (geoms, pointerPosition): QuadViewModel[] => { + const picker = geoms.pickQuads; + const diskCenter = geoms.diskCenter; + const x = pointerPosition.x - diskCenter.x; + const y = pointerPosition.y - diskCenter.y; + return picker(x, y); + }, +)((state) => state.chartId); + +export const getPickedShapesLayerValues = createCachedSelector( + [getPickedShapes], + (pickedShapes): Array> => { + const elements = pickedShapes.map>((model) => { + const values: Array = []; + values.push({ + groupByRollup: model.dataName, + value: model.value, + }); + let parent = model[PARENT_KEY]; + let index = model[PARENT_KEY].sortIndex; + while (parent[DEPTH_KEY] > 0) { + const value = parent[AGGREGATE_KEY]; + const dataName = parent[PARENT_KEY][CHILDREN_KEY][index][0]; + values.push({ groupByRollup: dataName, value }); + + parent = parent[PARENT_KEY]; + index = parent[SORT_INDEX_KEY]; + } + return values.reverse(); + }); + return elements; + }, +)((state) => state.chartId); diff --git a/src/chart_types/partition_chart/state/selectors/pie_spec.ts b/src/chart_types/partition_chart/state/selectors/pie_spec.ts new file mode 100644 index 0000000000..7eebf7a3ef --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/pie_spec.ts @@ -0,0 +1,28 @@ +/* + * 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 { GlobalChartState } from '../../../../state/chart_state'; +import { getSpecsFromStore } from '../../../../state/utils'; +import { PartitionSpec } from '../../specs'; +import { ChartTypes } from '../../..'; +import { SpecTypes } from '../../../../specs'; + +export function getPieSpecOrNull(state: GlobalChartState): PartitionSpec | null { + const pieSpecs = getSpecsFromStore(state.specs, ChartTypes.Partition, SpecTypes.Series); + return pieSpecs.length > 0 ? pieSpecs[0] : null; +} diff --git a/src/chart_types/partition_chart/state/selectors/tooltip.ts b/src/chart_types/partition_chart/state/selectors/tooltip.ts index bb38020629..80ad02e6ad 100644 --- a/src/chart_types/partition_chart/state/selectors/tooltip.ts +++ b/src/chart_types/partition_chart/state/selectors/tooltip.ts @@ -18,23 +18,10 @@ import createCachedSelector from 're-reselect'; import { GlobalChartState } from '../../../../state/chart_state'; -import { partitionGeometries } from './geometries'; import { INPUT_KEY } from '../../layout/utils/group_by_rollup'; -import { QuadViewModel } from '../../layout/types/viewmodel_types'; import { TooltipInfo } from '../../../../components/tooltip/types'; -import { ChartTypes } from '../../..'; -import { SpecTypes } from '../../../../specs'; -import { getSpecsFromStore } from '../../../../state/utils'; -import { PartitionSpec } from '../../specs'; - -function getCurrentPointerPosition(state: GlobalChartState) { - return state.interactions.pointer.current.position; -} - -function getPieSpecOrNull(state: GlobalChartState): PartitionSpec | null { - const pieSpecs = getSpecsFromStore(state.specs, ChartTypes.Partition, SpecTypes.Series); - return pieSpecs.length > 0 ? pieSpecs[0] : null; -} +import { getPieSpecOrNull } from './pie_spec'; +import { getPickedShapes } from './picked_shapes'; function getValueFormatter(state: GlobalChartState) { return getPieSpecOrNull(state)?.valueFormatter; @@ -50,16 +37,12 @@ const EMPTY_TOOLTIP = Object.freeze({ }); export const getTooltipInfoSelector = createCachedSelector( - [getPieSpecOrNull, partitionGeometries, getCurrentPointerPosition, getValueFormatter, getLabelFormatters], - (pieSpec, geoms, pointerPosition, valueFormatter, labelFormatters): TooltipInfo => { + [getPieSpecOrNull, getPickedShapes, getValueFormatter, getLabelFormatters], + (pieSpec, pickedShapes, valueFormatter, labelFormatters): TooltipInfo => { if (!pieSpec || !valueFormatter || !labelFormatters) { return EMPTY_TOOLTIP; } - const picker = geoms.pickQuads; - const diskCenter = geoms.diskCenter; - const x = pointerPosition.x - diskCenter.x; - const y = pointerPosition.y - diskCenter.y; - const pickedShapes: Array = picker(x, y); + const datumIndices = new Set(); const tooltipInfo: TooltipInfo = { header: null, diff --git a/src/chart_types/xy_chart/state/chart_state.tsx b/src/chart_types/xy_chart/state/chart_state.tsx index 889c8bb89c..b74d72734c 100644 --- a/src/chart_types/xy_chart/state/chart_state.tsx +++ b/src/chart_types/xy_chart/state/chart_state.tsx @@ -37,10 +37,31 @@ import { htmlIdGenerator } from '../../../utils/commons'; import { Tooltip } from '../../../components/tooltip'; import { getTooltipAnchorPositionSelector } from './selectors/get_tooltip_position'; import { SeriesKey } from '../utils/series'; +import { createOnElementClickCaller } from './selectors/on_element_click_caller'; +import { createOnElementOverCaller } from './selectors/on_element_over_caller'; +import { createOnElementOutCaller } from './selectors/on_element_out_caller'; +import { createOnBrushEndCaller } from './selectors/on_brush_end_caller'; +import { createOnPointerMoveCaller } from './selectors/on_pointer_move_caller'; export class XYAxisChartState implements InternalChartState { - chartType = ChartTypes.XYAxis; - legendId: string = htmlIdGenerator()('legend'); + onElementClickCaller: (state: GlobalChartState) => void; + onElementOverCaller: (state: GlobalChartState) => void; + onElementOutCaller: (state: GlobalChartState) => void; + onBrushEndCaller: (state: GlobalChartState) => void; + onPointerMoveCaller: (state: GlobalChartState) => void; + chartType: ChartTypes; + legendId: string; + + constructor() { + this.onElementClickCaller = createOnElementClickCaller(); + this.onElementOverCaller = createOnElementOverCaller(); + this.onElementOutCaller = createOnElementOutCaller(); + this.onBrushEndCaller = createOnBrushEndCaller(); + this.onPointerMoveCaller = createOnPointerMoveCaller(); + this.chartType = ChartTypes.XYAxis; + this.legendId = htmlIdGenerator()('legend'); + } + isBrushAvailable(globalState: GlobalChartState) { return isBrushAvailableSelector(globalState); } @@ -80,4 +101,11 @@ export class XYAxisChartState implements InternalChartState { getTooltipAnchor(globalState: GlobalChartState) { return getTooltipAnchorPositionSelector(globalState); } + eventCallbacks(globalState: GlobalChartState) { + this.onElementOverCaller(globalState); + this.onElementOutCaller(globalState); + this.onElementClickCaller(globalState); + this.onBrushEndCaller(globalState); + this.onPointerMoveCaller(globalState); + } } diff --git a/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts b/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts index 41c0a091ad..a8d2ec6d0c 100644 --- a/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts +++ b/src/chart_types/xy_chart/state/selectors/on_element_click_caller.ts @@ -25,33 +25,8 @@ import { SettingsSpec } from '../../../../specs'; import { IndexedGeometry, GeometryValue } from '../../../../utils/geometry'; import { ChartTypes } from '../../../index'; import { XYChartSeriesIdentifier } from '../../utils/series'; - -const getLastClickSelector = (state: GlobalChartState) => state.interactions.pointer.lastClick; - -interface Props { - settings: SettingsSpec | undefined; - lastClick: PointerState | null; - indexedGeometries: IndexedGeometry[]; -} - -function isClicking(prevProps: Props | null, nextProps: Props | null) { - if (nextProps === null) { - return false; - } - if (!nextProps.settings || !nextProps.settings.onElementClick || nextProps.indexedGeometries.length === 0) { - return false; - } - const prevLastClick = prevProps !== null ? prevProps.lastClick : null; - const nextLastClick = nextProps !== null ? nextProps.lastClick : null; - - if (prevLastClick === null && nextLastClick !== null) { - return true; - } - if (prevLastClick !== null && nextLastClick !== null && prevLastClick.time !== nextLastClick.time) { - return true; - } - return false; -} +import { isClicking } from '../../../../state/utils'; +import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; /** * Will call the onElementClick listener every time the following preconditions are met: @@ -60,20 +35,17 @@ function isClicking(prevProps: Props | null, nextProps: Props | null) { * - the pointer state goes from down state to up state */ export function createOnElementClickCaller(): (state: GlobalChartState) => void { - let prevProps: Props | null = null; + 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 => { - const nextProps = { - lastClick, - settings, - indexedGeometries, - }; - - if (isClicking(prevProps, nextProps)) { + 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], @@ -81,7 +53,7 @@ export function createOnElementClickCaller(): (state: GlobalChartState) => void settings.onElementClick(elements); } } - prevProps = nextProps; + prevClick = lastClick; }, )({ keySelector: (state: GlobalChartState) => state.chartId, diff --git a/src/components/chart.test.tsx b/src/components/chart.test.tsx index 4dc02b80b9..6be95918b7 100644 --- a/src/components/chart.test.tsx +++ b/src/components/chart.test.tsx @@ -1,3 +1,6 @@ +/** + * @jest-environment node + */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -15,9 +18,6 @@ * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ -/** - * @jest-environment node - */ // Using node env because JSDOM environment throws warnings: // Jest doesn't work well with the environment detection hack that react-redux uses internally. // https://github.com/reduxjs/react-redux/issues/1373 diff --git a/src/components/chart.tsx b/src/components/chart.tsx index 70c0ef7f60..e8962725e4 100644 --- a/src/components/chart.tsx +++ b/src/components/chart.tsx @@ -31,15 +31,9 @@ import { ChartSize, getChartSize } from '../utils/chart_size'; import { ChartStatus } from './chart_status'; import { chartStoreReducer, GlobalChartState } from '../state/chart_state'; import { isInitialized } from '../state/selectors/is_initialized'; -import { createOnElementOutCaller } from '../chart_types/xy_chart/state/selectors/on_element_out_caller'; -import { createOnElementOverCaller } from '../chart_types/xy_chart/state/selectors/on_element_over_caller'; -import { createOnElementClickCaller } from '../chart_types/xy_chart/state/selectors/on_element_click_caller'; -import { ChartTypes } from '../chart_types/index'; import { getSettingsSpecSelector } from '../state/selectors/get_settings_specs'; -import { createOnBrushEndCaller } from '../chart_types/xy_chart/state/selectors/on_brush_end_caller'; import { onExternalPointerEvent } from '../state/actions/events'; import { PointerEvent } from '../specs'; -import { createOnPointerMoveCaller } from '../chart_types/xy_chart/state/selectors/on_pointer_move_caller'; interface ChartProps { /** The type of rendered @@ -91,11 +85,6 @@ export class Chart extends React.Component { legendPosition: Position.Right, }; - const onElementClickCaller = createOnElementClickCaller(); - const onElementOverCaller = createOnElementOverCaller(); - const onElementOutCaller = createOnElementOutCaller(); - const onBrushEndCaller = createOnBrushEndCaller(); - const onPointerMoveCaller = createOnPointerMoveCaller(); this.unsubscribeToStore = this.chartStore.subscribe(() => { const state = this.chartStore.getState(); if (!isInitialized(state)) { @@ -108,15 +97,9 @@ export class Chart extends React.Component { legendPosition: settings.legendPosition, }); } - - if (state.chartType !== ChartTypes.XYAxis) { - return; + if (state.internalChartState) { + state.internalChartState.eventCallbacks(state); } - onElementOverCaller(state); - onElementOutCaller(state); - onElementClickCaller(state); - onBrushEndCaller(state); - onPointerMoveCaller(state); }); } diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 11d9e4ea80..b22736455f 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -31,9 +31,18 @@ import { XYChartSeriesIdentifier, SeriesIdentifier } from '../chart_types/xy_cha import { Accessor } from '../utils/accessor'; import { Position, Rendering, Rotation, Color } from '../utils/commons'; import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; +import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; -export type ElementClickListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; -export type ElementOverListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; +export interface LayerValue { + groupByRollup: PrimitiveValue; + value: number; +} + +export type XYChartElementEvent = [GeometryValue, XYChartSeriesIdentifier]; +export type PartitionElementEvent = [Array, SeriesIdentifier]; + +export type ElementClickListener = (elements: Array) => void; +export type ElementOverListener = (elements: Array) => void; export type BrushEndListener = (min: number, max: number) => void; export type LegendItemListener = (series: XYChartSeriesIdentifier | null) => void; export type PointerUpdateListener = (event: PointerEvent) => void; diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index d733d36aa7..9b7db537c2 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -101,6 +101,8 @@ export interface InternalChartState { * @param globalState */ getTooltipAnchor(globalState: GlobalChartState): TooltipAnchorPosition | null; + + eventCallbacks(globalState: GlobalChartState): void; } export interface SpecList { diff --git a/src/state/selectors/get_last_click.ts b/src/state/selectors/get_last_click.ts new file mode 100644 index 0000000000..e93d517321 --- /dev/null +++ b/src/state/selectors/get_last_click.ts @@ -0,0 +1,23 @@ +import { GlobalChartState } from '../chart_state'; + +/* + * 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. */ + +export function getLastClickSelector(state: GlobalChartState) { + return state.interactions.pointer.lastClick; +} diff --git a/src/state/utils.ts b/src/state/utils.ts index caa43fd717..3b57807ef1 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { SpecList } from './chart_state'; +import { SpecList, PointerState } from './chart_state'; import { Spec } from '../specs'; import { ChartTypes } from '../chart_types'; @@ -32,3 +32,13 @@ export function getSpecsFromStore(specs: SpecList, chartType: Ch return specs[specId] as U; }); } + +export function isClicking(prevClick: PointerState | null, lastClick: PointerState | null) { + if (prevClick === null && lastClick !== null) { + return true; + } + if (prevClick !== null && lastClick !== null && prevClick.time !== lastClick.time) { + return true; + } + return false; +} diff --git a/stories/interactions/4_sunburst_slice_clicks.tsx b/stories/interactions/4_sunburst_slice_clicks.tsx new file mode 100644 index 0000000000..85f759eefe --- /dev/null +++ b/stories/interactions/4_sunburst_slice_clicks.tsx @@ -0,0 +1,106 @@ +/* + * 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 { action } from '@storybook/addon-actions'; +import React from 'react'; +import { Chart, Position, Settings, Partition } from '../../src'; +import { indexInterpolatedFillColor, interpolatorCET2s } from '../utils/utils'; + +const onElementListeners = { + onElementClick: action('onElementClick'), + onElementOver: action('onElementOver'), + onElementOut: action('onElementOut'), +}; +type PieDatum = [string, number, string, number]; +const pieData: Array = [ + ['CN', 301, 'IN', 44], + ['CN', 301, 'US', 24], + ['CN', 301, 'ID', 13], + ['CN', 301, 'BR', 8], + ['IN', 245, 'US', 22], + ['IN', 245, 'BR', 11], + ['IN', 245, 'ID', 10], + ['US', 130, 'CN', 33], + ['US', 130, 'IN', 23], + ['US', 130, 'US', 9], + ['US', 130, 'ID', 7], + ['US', 130, 'BR', 5], + ['ID', 55, 'BR', 4], + ['ID', 55, 'US', 3], + ['PK', 43, 'FR', 2], + ['PK', 43, 'PK', 2], +]; + +export const example = () => { + return ( + + + { + return d[3]; + }} + layers={[ + { + groupByRollup: (d: PieDatum) => { + return d[0]; + }, + nodeLabel: (d) => { + return `dest: ${d}`; + }, + shape: { + fillColor: (d) => { + // pick color from color palette based on mean angle - rather distinct colors in the inner ring + return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + }, + }, + }, + { + groupByRollup: (d: PieDatum) => { + return d[2]; + }, + nodeLabel: (d) => { + return `source: ${d}`; + }, + shape: { + fillColor: (d) => { + // pick color from color palette based on mean angle - rather distinct colors in the inner ring + return indexInterpolatedFillColor(interpolatorCET2s)(d, (d.x0 + d.x1) / 2 / (2 * Math.PI), []); + }, + }, + }, + ]} + /> + + ); +}; + +example.story = { + parameters: { + info: { + text: `The \`onElementClick\` receive an argument with the following type definition: \`Array<[Array, SeriesIdentifier]>\`. + +Usually the outer array contains only one item but, in a near future, we will group smaller slices into a single one during the interaction. + +For every clicked slice, you will have an array of \`LayerValue\`s and a \`SeriesIdentifier\`. The array of \`LayerValues\` is sorted +in the same way as the \`layers\` props, and helps you to idenfity the \`groupByRollup\` value and the slice value on every sunburst level. + `, + }, + }, +}; diff --git a/stories/interactions/interactions.stories.tsx b/stories/interactions/interactions.stories.tsx index 62b096a3f7..a4e35d82be 100644 --- a/stories/interactions/interactions.stories.tsx +++ b/stories/interactions/interactions.stories.tsx @@ -29,6 +29,7 @@ export { example as barClicksAndHovers } from './1_bar_clicks'; export { example as areaPointClicksAndHovers } from './2_area_point_clicks'; export { example as linePointClicksAndHovers } from './3_line_point_clicks'; export { example as lineAreaBarPointClicksAndHovers } from './4_line_area_bar_clicks'; +export { example as sunburstSliceClicks } from './4_sunburst_slice_clicks'; export { example as clicksHoversOnLegendItemsBarChart } from './5_clicks_legend_items_bar'; export { example as clickHoversOnLegendItemsAreaChart } from './6_clicks_legend_items_area'; export { example as clickHoversOnLegendItemsLineChart } from './7_clicks_legend_items_line';