diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 36192094afc64e..18be04e3f8c66a 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -27,7 +27,7 @@ import { } from '@elastic/charts'; import { RangeSelectContext, ValueClickContext } from '../../../../embeddable/public'; -import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { Datatable } from '../../../../expressions/public'; export interface ClickTriggerEvent { name: 'filterBucket'; @@ -39,6 +39,13 @@ export interface BrushTriggerEvent { data: RangeSelectContext['data']; } +type AllSeriesAccessors = Array<[accessor: Accessor | AccessorFn, value: string | number]>; + +/** + * returns accessor value from string or function accessor + * @param datum + * @param accessor + */ function getAccessorValue(datum: Datum, accessor: Accessor | AccessorFn) { if (typeof accessor === 'function') { return accessor(datum); @@ -52,8 +59,12 @@ function getAccessorValue(datum: Datum, accessor: Accessor | AccessorFn) { * difficult to match the correct column. This creates a test object to throw * an error when the target id is accessed, thus matcing the target column. */ -function validateFnAccessorId(id: string, accessor: AccessorFn) { - const matchedMessage = 'validateFnAccessorId matched'; +function validateAccessorId(id: string, accessor: Accessor | AccessorFn) { + if (typeof accessor !== 'function') { + return id === accessor; + } + + const matchedMessage = 'validateAccessorId matched'; try { accessor({ @@ -67,58 +78,100 @@ function validateFnAccessorId(id: string, accessor: AccessorFn) { } } +/** + * Groups split accessors by their accessor string or function and related value + * + * @param splitAccessors + * @param splitSeriesAccessorFnMap + */ +const getAllSplitAccessors = ( + splitAccessors: Map, + splitSeriesAccessorFnMap?: Map +): Array<[accessor: Accessor | AccessorFn, value: string | number]> => + [...splitAccessors.entries()].map(([key, value]) => [ + splitSeriesAccessorFnMap?.get?.(key) ?? key, + value, + ]); + +/** + * Reduces matching column indexes + * + * @param xAccessor + * @param yAccessor + * @param splitAccessors + */ +const columnReducer = ( + xAccessor: Accessor | AccessorFn | null, + yAccessor: Accessor | AccessorFn | null, + splitAccessors: AllSeriesAccessors +) => (acc: number[], { id }: Datatable['columns'][number], index: number): number[] => { + if ( + (xAccessor !== null && validateAccessorId(id, xAccessor)) || + (yAccessor !== null && validateAccessorId(id, yAccessor)) || + splitAccessors.some(([accessor]) => validateAccessorId(id, accessor)) + ) { + acc.push(index); + } + + return acc; +}; + +/** + * Finds matching row index for given accessors and geometry values + * + * @param geometry + * @param xAccessor + * @param yAccessor + * @param splitAccessors + */ +const rowFindPredicate = ( + geometry: GeometryValue | null, + xAccessor: Accessor | AccessorFn | null, + yAccessor: Accessor | AccessorFn | null, + splitAccessors: AllSeriesAccessors +) => (row: Datatable['rows'][number]): boolean => + (geometry === null || + (xAccessor !== null && + getAccessorValue(row, xAccessor) === geometry.x && + yAccessor !== null && + getAccessorValue(row, yAccessor) === geometry.y)) && + [...splitAccessors].every(([accessor, value]) => getAccessorValue(row, accessor) === value); + /** * Helper function to transform `@elastic/charts` click event into filter action event + * + * @param table + * @param xAccessor + * @param splitSeriesAccessorFnMap needed when using `splitSeriesAccessors` as `AccessorFn` + * @param negate */ export const getFilterFromChartClickEventFn = ( table: Datatable, xAccessor: Accessor | AccessorFn, + splitSeriesAccessorFnMap?: Map, negate: boolean = false ) => (points: Array<[GeometryValue, XYChartSeriesIdentifier]>): ClickTriggerEvent => { const data: ValueClickContext['data']['data'] = []; - const seenKeys = new Set(); points.forEach((point) => { const [geometry, { yAccessor, splitAccessors }] = point; - const columnIndices = table.columns.reduce((acc, { id }, index) => { - if ( - (typeof xAccessor === 'function' && validateFnAccessorId(id, xAccessor)) || - [xAccessor, yAccessor, ...splitAccessors.keys()].includes(id) - ) { - acc.push(index); - } - - return acc; - }, []); - - const rowIndex = table.rows.findIndex((row) => { - return ( - getAccessorValue(row, xAccessor) === geometry.x && - row[yAccessor] === geometry.y && - [...splitAccessors.entries()].every(([key, value]) => row[key] === value) - ); - }); - - data.push( - ...columnIndices - .map((column) => ({ - table, - column, - row: rowIndex, - value: null, - })) - .filter((column) => { - // filter duplicate values when multiple geoms are highlighted - const key = `column:${column},row:${rowIndex}`; - if (seenKeys.has(key)) { - return false; - } - - seenKeys.add(key); - - return true; - }) + const allSplitAccessors = getAllSplitAccessors(splitAccessors, splitSeriesAccessorFnMap); + const columnIndices = table.columns.reduce( + columnReducer(xAccessor, yAccessor, allSplitAccessors), + [] + ); + const row = table.rows.findIndex( + rowFindPredicate(geometry, xAccessor, yAccessor, allSplitAccessors) ); + const value = getAccessorValue(table.rows[row], yAccessor); + const newData = columnIndices.map((column) => ({ + table, + column, + row, + value, + })); + + data.push(...newData); }); return { @@ -135,22 +188,21 @@ export const getFilterFromChartClickEventFn = ( */ export const getFilterFromSeriesFn = (table: Datatable) => ( { splitAccessors }: XYChartSeriesIdentifier, + splitSeriesAccessorFnMap?: Map, negate = false ): ClickTriggerEvent => { - const data = table.columns.reduce((acc, { id }, column) => { - if ([...splitAccessors.keys()].includes(id)) { - const value = splitAccessors.get(id); - const row = table.rows.findIndex((r) => r[id] === value); - acc.push({ - table, - column, - row, - value, - }); - } - - return acc; - }, []); + const allSplitAccessors = getAllSplitAccessors(splitAccessors, splitSeriesAccessorFnMap); + const columnIndices = table.columns.reduce( + columnReducer(null, null, allSplitAccessors), + [] + ); + const row = table.rows.findIndex(rowFindPredicate(null, null, null, allSplitAccessors)); + const data: ValueClickContext['data']['data'] = columnIndices.map((column) => ({ + table, + column, + row, + value: null, + })); return { name: 'filterBucket', @@ -170,7 +222,7 @@ export const getBrushFromChartBrushEventFn = ( ) => ({ x: selectedRange }: XYBrushArea): BrushTriggerEvent => { const [start, end] = selectedRange ?? [0, 0]; const range: [number, number] = [start, end]; - const column = table.columns.findIndex((c) => c.id === xAccessor); + const column = table.columns.findIndex(({ id }) => validateAccessorId(id, xAccessor)); return { data: { diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx index a8215057189e50..3427baed41b8da 100644 --- a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx @@ -33,6 +33,7 @@ import { Aspects } from '../types'; import './_detailed_tooltip.scss'; import { fillEmptyValue } from '../utils/get_series_name_fn'; +import { COMPLEX_SPLIT_ACCESSOR } from '../utils/accessors'; interface TooltipData { label: string; @@ -75,12 +76,17 @@ const getTooltipData = ( } valueSeries.splitAccessors.forEach((splitValue, key) => { - const split = (aspects.series ?? []).find(({ accessor }) => accessor === key); + const split = (aspects.series ?? []).find(({ accessor }, i) => { + return accessor === key || key === `${COMPLEX_SPLIT_ACCESSOR}::${i}`; + }); if (split) { data.push({ label: split?.title, - value: split?.formatter ? split?.formatter(splitValue) : `${splitValue}`, + value: + split?.formatter && !key.toString().startsWith(COMPLEX_SPLIT_ACCESSOR) + ? split?.formatter(splitValue) + : `${splitValue}`, }); } }); diff --git a/src/plugins/vis_type_xy/public/utils/accessors.tsx b/src/plugins/vis_type_xy/public/utils/accessors.tsx new file mode 100644 index 00000000000000..a7a9813f7e00c1 --- /dev/null +++ b/src/plugins/vis_type_xy/public/utils/accessors.tsx @@ -0,0 +1,90 @@ +/* + * 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 { AccessorFn, Accessor } from '@elastic/charts'; +import { BUCKET_TYPES } from '../../../data/public'; +import { FakeParams, Aspect } from '../types'; + +export const COMPLEX_X_ACCESSOR = '__customXAccessor__'; +export const COMPLEX_SPLIT_ACCESSOR = '__complexSplitAccessor__'; + +export const getXAccessor = (aspect: Aspect): Accessor | AccessorFn => { + return ( + getComplexAccessor(COMPLEX_X_ACCESSOR)(aspect) ?? + (() => (aspect.params as FakeParams)?.defaultValue) + ); +}; + +const getFieldName = (fieldName: string, index?: number) => { + const indexStr = index !== undefined ? `::${index}` : ''; + + return `${fieldName}${indexStr}`; +}; + +/** + * Returns accessor function for complex accessor types + * @param aspect + */ +export const getComplexAccessor = (fieldName: string) => ( + aspect: Aspect, + index?: number +): Accessor | AccessorFn | undefined => { + if (!aspect.accessor) { + return; + } + + if ( + !( + (aspect.aggType === BUCKET_TYPES.DATE_RANGE || aspect.aggType === BUCKET_TYPES.RANGE) && + aspect.formatter + ) + ) { + return aspect.accessor; + } + + const formatter = aspect.formatter; + const accessor = aspect.accessor; + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (!v) { + return; + } + const f = formatter(v); + return f; + }; + + fn.fieldName = getFieldName(fieldName, index); + + return fn; +}; + +export const getSplitSeriesAccessorFnMap = ( + splitSeriesAccessors: Array +): Map => { + const m = new Map(); + + splitSeriesAccessors.forEach((accessor, index) => { + if (typeof accessor === 'function') { + const fieldName = getFieldName(COMPLEX_SPLIT_ACCESSOR, index); + m.set(fieldName, accessor); + } + }); + + return m; +}; diff --git a/src/plugins/vis_type_xy/public/utils/get_x_accessor.tsx b/src/plugins/vis_type_xy/public/utils/get_x_accessor.tsx deleted file mode 100644 index 3455c143ea94b4..00000000000000 --- a/src/plugins/vis_type_xy/public/utils/get_x_accessor.tsx +++ /dev/null @@ -1,48 +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 { AccessorFn, Accessor } from '@elastic/charts'; -import { BUCKET_TYPES } from '../../../data/public'; -import { FakeParams, Aspect } from '../types'; - -export const getXAccessor = (xAspect: Aspect): Accessor | AccessorFn => { - if (!xAspect.accessor) { - return () => (xAspect.params as FakeParams)?.defaultValue; - } - - if ( - !( - (xAspect.aggType === BUCKET_TYPES.DATE_RANGE || xAspect.aggType === BUCKET_TYPES.RANGE) && - xAspect.formatter - ) - ) { - return xAspect.accessor; - } - - const formatter = xAspect.formatter; - const accessor = xAspect.accessor; - return (d) => { - const v = d[accessor]; - if (!v) { - return; - } - const f = formatter(v); - return f; - }; -}; diff --git a/src/plugins/vis_type_xy/public/utils/index.tsx b/src/plugins/vis_type_xy/public/utils/index.tsx index 96478b516aa69b..20b5d7a3c8881c 100644 --- a/src/plugins/vis_type_xy/public/utils/index.tsx +++ b/src/plugins/vis_type_xy/public/utils/index.tsx @@ -23,3 +23,4 @@ export { getLegendActions } from './get_legend_actions'; export { getSeriesNameFn } from './get_series_name_fn'; export { getXDomain, getAdjustedDomain } from './domain'; export { useColorPicker } from './use_color_picker'; +export { getXAccessor } from './accessors'; diff --git a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx index 789e4746210175..50a95924d3995b 100644 --- a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx +++ b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx @@ -18,7 +18,6 @@ */ import React from 'react'; -import { compact } from 'lodash'; import { AreaSeries, @@ -68,7 +67,8 @@ export const renderAllSeries = ( getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName, getSeriesColor: SeriesColorAccessorFn, timeZone: string, - xAccessor: Accessor | AccessorFn + xAccessor: Accessor | AccessorFn, + splitSeriesAccessors: Array ) => seriesParams.map( ({ @@ -89,10 +89,6 @@ export const renderAllSeries = ( } const id = `${type}-${yAspect.accessor}`; - - const splitSeriesAccessors = aspects.series - ? (compact(aspects.series.map(({ accessor }) => accessor)) as string[]) - : []; const yAxisScale = yAxes.find(({ groupId: axisGroupId }) => axisGroupId === groupId)?.scale; const isStacked = mode === 'stacked' || yAxisScale?.mode === 'percentage'; const stackMode = yAxisScale?.mode === 'normal' ? undefined : yAxisScale?.mode; diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index bde1b21cb059a1..d87500218975a5 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -41,6 +41,7 @@ import { } from '@elastic/charts'; import { keys } from '@elastic/eui'; +import { compact } from 'lodash'; import { getFilterFromChartClickEventFn, getFilterFromSeriesFn, @@ -59,6 +60,7 @@ import { getSeriesNameFn, getLegendActions, useColorPicker, + getXAccessor, } from './utils'; import { XYAxis, XYEndzones, XYCurrentTime, XYSettings, XYThresholdLine } from './components'; import { getConfig } from './config'; @@ -66,7 +68,11 @@ import { getThemeService, getColorsService, getDataActions } from './services'; import { ChartType } from '../common'; import './_chart.scss'; -import { getXAccessor } from './utils/get_x_accessor'; +import { + COMPLEX_SPLIT_ACCESSOR, + getComplexAccessor, + getSplitSeriesAccessorFnMap, +} from './utils/accessors'; export interface VisComponentProps { visParams: VisParams; @@ -111,14 +117,22 @@ const VisComponent = (props: VisComponentProps) => { ); const handleFilterClick = useCallback( - (visData: Datatable, xAccessor: Accessor | AccessorFn): ElementClickListener => (elements) => { - if (xAccessor !== null) { - const event = getFilterFromChartClickEventFn( - visData, - xAccessor - )(elements as XYChartElementEvent[]); - props.fireEvent(event); - } + ( + visData: Datatable, + xAccessor: Accessor | AccessorFn, + splitSeriesAccessors: Array + ): ElementClickListener => { + const splitSeriesAccessorFnMap = getSplitSeriesAccessorFnMap(splitSeriesAccessors); + return (elements) => { + if (xAccessor !== null) { + const event = getFilterFromChartClickEventFn( + visData, + xAccessor, + splitSeriesAccessorFnMap + )(elements as XYChartElementEvent[]); + props.fireEvent(event); + } + }; }, [props] ); @@ -140,14 +154,19 @@ const VisComponent = (props: VisComponentProps) => { ); const getFilterEventData = useCallback( - (visData: Datatable, xAccessor: Accessor | AccessorFn) => ( - series: XYChartSeriesIdentifier - ): ClickTriggerEvent | null => { - if (xAccessor !== null) { - return getFilterFromSeriesFn(visData)(series); - } + ( + visData: Datatable, + xAccessor: Accessor | AccessorFn, + splitSeriesAccessors: Array + ) => { + const splitSeriesAccessorFnMap = getSplitSeriesAccessorFnMap(splitSeriesAccessors); + return (series: XYChartSeriesIdentifier): ClickTriggerEvent | null => { + if (xAccessor !== null) { + return getFilterFromSeriesFn(visData)(series, splitSeriesAccessorFnMap); + } - return null; + return null; + }; }, [] ); @@ -242,6 +261,9 @@ const VisComponent = (props: VisComponentProps) => { [allSeries, getSeriesName, props.uiState] ); const xAccessor = getXAccessor(config.aspects.x); + const splitSeriesAccessors = config.aspects.series + ? compact(config.aspects.series.map(getComplexAccessor(COMPLEX_SPLIT_ACCESSOR))) + : []; return (
@@ -258,14 +280,14 @@ const VisComponent = (props: VisComponentProps) => { xDomain={xDomain} adjustedXDomain={adjustedXDomain} legendColorPicker={useColorPicker(legendPosition, setColor, getSeriesName)} - onElementClick={handleFilterClick(visData, xAccessor)} + onElementClick={handleFilterClick(visData, xAccessor, splitSeriesAccessors)} onBrushEnd={handleBrush(visData, xAccessor, 'interval' in config.aspects.x.params)} onRenderChange={onRenderChange} legendAction={ config.aspects.series && (config.aspects.series?.length ?? 0) > 0 ? getLegendActions( canFilter, - getFilterEventData(visData, xAccessor), + getFilterEventData(visData, xAccessor, splitSeriesAccessors), handleFilterAction, getSeriesName ) @@ -293,7 +315,8 @@ const VisComponent = (props: VisComponentProps) => { getSeriesName, getSeriesColor, timeZone, - xAccessor + xAccessor, + splitSeriesAccessors )}