From e7e4b2abb81b2a6cabecdd5f3482b68ac9a1ff9d Mon Sep 17 00:00:00 2001 From: Amardeepsingh Siglani Date: Mon, 22 May 2023 14:58:25 -0700 Subject: [PATCH] [Feature anywhere] Add annotation click handler (#3777) * added interaction handling * added framework for invoking flyout, tooltip * using signals to listen for events on point annotation Signed-off-by: Amardeepsingh Siglani --- src/plugins/ui_actions/public/index.ts | 2 + .../triggers/external_action_trigger.ts | 44 +++++++++++++++++ .../ui_actions/public/triggers/index.ts | 1 + .../vis_augmenter/public/test_constants.ts | 8 +++- .../vis_augmenter/public/vega/constants.ts | 8 ++++ .../vis_augmenter/public/vega/helpers.ts | 38 ++++++++++++++- .../vis_type_vega/public/data_model/types.ts | 2 + .../public/data_model/vega_parser.ts | 47 +++++++++++++++++++ .../public/expressions/line_vega_spec_fn.ts | 2 + .../public/vega_view/vega_base_view.js | 14 ++++++ .../public/vega_visualization.js | 1 + .../public/embeddable/events.ts | 3 ++ .../visualizations/public/expressions/vis.ts | 5 ++ 13 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/plugins/ui_actions/public/triggers/external_action_trigger.ts create mode 100644 src/plugins/vis_augmenter/public/vega/constants.ts diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 3560f473d33b..489cb5eee363 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -61,6 +61,8 @@ export { visualizeFieldTrigger, VISUALIZE_GEO_FIELD_TRIGGER, visualizeGeoFieldTrigger, + EXTERNAL_ACTION_TRIGGER, + externalActionTrigger, } from './triggers'; export { TriggerContextMapping, diff --git a/src/plugins/ui_actions/public/triggers/external_action_trigger.ts b/src/plugins/ui_actions/public/triggers/external_action_trigger.ts new file mode 100644 index 000000000000..87941fd47b88 --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/external_action_trigger.ts @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * 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 { i18n } from '@osd/i18n'; +import { Trigger } from '.'; + +export const EXTERNAL_ACTION_TRIGGER = 'EXTERNAL_ACTION_TRIGGER'; +export const externalActionTrigger: Trigger<'EXTERNAL_ACTION_TRIGGER'> = { + id: EXTERNAL_ACTION_TRIGGER, + title: i18n.translate('uiActions.triggers.externalActionTitle', { + defaultMessage: 'Single click', + }), + description: i18n.translate('uiActions.triggers.externalActionDescription', { + defaultMessage: + 'A data point click on the visualization used to trigger external action like show flyout, etc.', + }), +}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index 2d012df76e08..0fcbbc4ee3fa 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -36,4 +36,5 @@ export * from './value_click_trigger'; export * from './apply_filter_trigger'; export * from './visualize_field_trigger'; export * from './visualize_geo_field_trigger'; +export * from './external_action_trigger'; export * from './default_trigger'; diff --git a/src/plugins/vis_augmenter/public/test_constants.ts b/src/plugins/vis_augmenter/public/test_constants.ts index d8c4386d0f69..0e45fdc05725 100644 --- a/src/plugins/vis_augmenter/public/test_constants.ts +++ b/src/plugins/vis_augmenter/public/test_constants.ts @@ -5,6 +5,7 @@ import { OpenSearchDashboardsDatatable } from '../../expressions/public'; import { VIS_LAYER_COLUMN_TYPE, VisLayerTypes, HOVER_PARAM } from './'; +import { VisAnnotationType } from './vega/constants'; const TEST_X_AXIS_ID = 'test-x-axis-id'; const TEST_X_AXIS_ID_DIRTY = 'test.x.axis.id'; @@ -489,8 +490,12 @@ const TEST_EVENTS_LAYER_SINGLE_VIS_LAYER = { color: 'red', filled: true, opacity: 1, + style: [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`], }, - transform: [{ filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` }], + transform: [ + { filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, + ], params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }], encoding: { x: { @@ -536,6 +541,7 @@ const TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS = { { filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0 || datum['${TEST_PLUGIN_RESOURCE_ID_2}'] > 0`, }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, ], }; diff --git a/src/plugins/vis_augmenter/public/vega/constants.ts b/src/plugins/vis_augmenter/public/vega/constants.ts new file mode 100644 index 000000000000..a5729725422e --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum VisAnnotationType { + POINT_IN_TIME_ANNOTATION = 'POINT_IN_TIME_ANNOTATION', +} diff --git a/src/plugins/vis_augmenter/public/vega/helpers.ts b/src/plugins/vis_augmenter/public/vega/helpers.ts index 2132e170bb4d..8eaa11f4c0c2 100644 --- a/src/plugins/vis_augmenter/public/vega/helpers.ts +++ b/src/plugins/vis_augmenter/public/vega/helpers.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import { cloneDeep, isEmpty, get } from 'lodash'; +import { Item } from 'vega'; import { OpenSearchDashboardsDatatable, OpenSearchDashboardsDatatableColumn, @@ -24,6 +25,7 @@ import { VisLayers, VisLayerTypes, } from '../'; +import { VisAnnotationType } from './constants'; // Given any visLayers, create a map to indicate which VisLayer types are present. // Convert to an array since ES6 Maps cannot be stringified. @@ -48,6 +50,30 @@ export const enableVisLayersInSpecConfig = (spec: object, visLayers: VisLayers): }; }; +/** + * Adds the signals which vega will use to trigger required events on the point in time annotation marks + */ +export const addVisEventSignalsToSpecConfig = (spec: object) => { + const config = get(spec, 'config', { kibana: {} }); + const signals = { + ...(config.kibana.signals || {}), + [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`]: [ + { + name: 'PointInTimeAnnotationVisEvent', + on: [{ events: 'click', update: 'opensearchDashboardsVisEventTriggered(event, datum)' }], + }, + ], + }; + + return { + ...config, + kibana: { + ...config.kibana, + signals, + }, + }; +}; + // Get the first xaxis field as only 1 setup of X Axis will be supported and // there won't be support for split series and split chart export const getXAxisId = ( @@ -313,8 +339,14 @@ export const addPointInTimeEventsLayersToSpec = ( color: EVENT_COLOR, filled: true, opacity: 1, + // This style is only used to locate this mark when trying to add signals in the compiled vega spec. + // @see @method vega_parser._compileVegaLite + style: [`${VisAnnotationType.POINT_IN_TIME_ANNOTATION}`], }, - transform: [{ filter: generateVisLayerFilterString(visLayerColumnIds) }], + transform: [ + { filter: generateVisLayerFilterString(visLayerColumnIds) }, + { calculate: `'${VisAnnotationType.POINT_IN_TIME_ANNOTATION}'`, as: 'annotationType' }, + ], params: [{ name: HOVER_PARAM, select: { type: 'point', on: 'mouseover' } }], encoding: { x: { @@ -340,3 +372,7 @@ export const addPointInTimeEventsLayersToSpec = ( return newSpec; }; + +export const isPointInTimeAnnotation = (item?: Item | null) => { + return item?.datum?.annotationType === VisAnnotationType.POINT_IN_TIME_ANNOTATION; +}; diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts index b5db91558c03..3947808c72c1 100644 --- a/src/plugins/vis_type_vega/public/data_model/types.ts +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -33,6 +33,7 @@ import { SearchResponse, SearchParams } from 'elasticsearch'; import { Filter } from 'src/plugins/data/public'; import { DslQuery } from 'src/plugins/data/common'; import { VisLayerTypes } from 'src/plugins/vis_augmenter/public'; +import { Signal } from 'vega'; import { OpenSearchQueryParser } from './opensearch_query_parser'; import { EmsFileParser } from './ems_file_parser'; import { UrlParser } from './url_parser'; @@ -115,6 +116,7 @@ export interface OpenSearchDashboards { type: string; renderer: Renderer; visibleVisLayers?: Map; + signals?: { [markId: string]: Signal[] }; } export interface VegaSpec { diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index 187c4690aad5..8f473b70544b 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -36,6 +36,7 @@ import { euiPaletteColorBlind } from '@elastic/eui'; import { euiThemeVars } from '@osd/ui-shared-deps/theme'; import { i18n } from '@osd/i18n'; // @ts-ignore +import { Signal } from 'vega'; import { vega, vegaLite } from '../lib/vega'; import { OpenSearchQueryParser } from './opensearch_query_parser'; import { Utils } from './utils'; @@ -323,6 +324,52 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never delete this.spec.autosize; } } + + if (this._config?.signals) { + Object.entries(this._config?.signals).forEach(([markId, signals]: [string, any]) => { + const mark = this.getMarkWithStyle(this.spec.marks, markId); + + if (mark) { + signals.forEach((signal: Signal) => { + signal.on?.forEach((eventObj) => { + // We are prepending mark name here so that the signals only listens to the events on + // the elements related to this mark + eventObj.events = `@${mark.name}:${eventObj.events}`; + }); + }); + this.spec.signals = (this.spec.signals || []).concat(signals); + } + }); + } + } + + /** + * This method recursively looks for a mark that includes the given style. + * Returns undefined if it doesn't find it. + */ + getMarkWithStyle(marks: any[], style: string): any { + if (!marks) { + return undefined; + } + + if (Array.isArray(marks)) { + const markWithStyle = marks.find((mark) => { + return mark.style?.includes(style); + }); + + if (markWithStyle) { + return markWithStyle; + } + + for (let i = 0; i < marks.length; i++) { + const res = this.getMarkWithStyle(marks[i].marks, style); + if (res) { + return res; + } + } + + return undefined; + } } /** diff --git a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts index 499310c106d2..136ba12f8862 100644 --- a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts +++ b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts @@ -18,6 +18,7 @@ import { addPointInTimeEventsLayersToTable, addPointInTimeEventsLayersToSpec, enableVisLayersInSpecConfig, + addVisEventSignalsToSpecConfig, } from '../../../vis_augmenter/public'; import { formatDatatable, createSpecFromDatatable } from './helpers'; import { VegaVisualizationDependencies } from '../plugin'; @@ -85,6 +86,7 @@ export const createLineVegaSpecFn = ( if (!isEmpty(pointInTimeEventsVisLayers) && dimensions.x !== null) { spec = addPointInTimeEventsLayersToSpec(table, dimensions, spec); spec.config = enableVisLayersInSpecConfig(spec, pointInTimeEventsVisLayers); + spec.config = addVisEventSignalsToSpecConfig(spec); } return JSON.stringify(spec); }, diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 65843ff05162..7083a6896064 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -51,6 +51,7 @@ const vegaFunctions = { opensearchDashboardsRemoveFilter: 'removeFilterHandler', opensearchDashboardsRemoveAllFilters: 'removeAllFiltersHandler', opensearchDashboardsSetTimeFilter: 'setTimeFilterHandler', + opensearchDashboardsVisEventTriggered: 'triggerExternalActionHandler', }; for (const funcName of Object.keys(vegaFunctions)) { @@ -76,6 +77,7 @@ export class VegaBaseView { this._serviceSettings = opts.serviceSettings; this._filterManager = opts.filterManager; this._applyFilter = opts.applyFilter; + this._triggerExternalAction = opts.externalAction; this._timefilter = opts.timefilter; this._view = null; this._vegaViewConfig = null; @@ -343,6 +345,18 @@ export class VegaBaseView { this._applyFilter({ filters: [filter] }); } + /** + * This method is triggered using signal expression in vega-spec via @see opensearchDashboardsVisEventTriggered + * @param {import('vega').ScenegraphEvent} event Event triggered by the underlying vega visualization. + * @param {import('vega').Item} datum Data associated with the element on which the event was triggered. + */ + triggerExternalActionHandler(event, datum) { + this._triggerExternalAction({ + event, + item: datum, + }); + } + /** * @param {object} query Query DSL snippet, as used in the query DSL editor * @param {string} [index] as defined in OpenSearch Dashboards, or default if missing diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js index af5c58f8a149..379670bda413 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.js @@ -90,6 +90,7 @@ export const createVegaVisualization = ({ getServiceSettings }) => serviceSettings, filterManager, timefilter, + externalAction: this._vis.API.events.externalAction, }; if (vegaParser.useMap) { diff --git a/src/plugins/visualizations/public/embeddable/events.ts b/src/plugins/visualizations/public/embeddable/events.ts index 2a17ef9d5ef3..3d34cfe49959 100644 --- a/src/plugins/visualizations/public/embeddable/events.ts +++ b/src/plugins/visualizations/public/embeddable/events.ts @@ -32,16 +32,19 @@ import { APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, + EXTERNAL_ACTION_TRIGGER, } from '../../../ui_actions/public'; export interface VisEventToTrigger { ['applyFilter']: typeof APPLY_FILTER_TRIGGER; ['brush']: typeof SELECT_RANGE_TRIGGER; ['filter']: typeof VALUE_CLICK_TRIGGER; + ['externalAction']: typeof EXTERNAL_ACTION_TRIGGER; } export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = { applyFilter: APPLY_FILTER_TRIGGER, brush: SELECT_RANGE_TRIGGER, filter: VALUE_CLICK_TRIGGER, + externalAction: EXTERNAL_ACTION_TRIGGER, }; diff --git a/src/plugins/visualizations/public/expressions/vis.ts b/src/plugins/visualizations/public/expressions/vis.ts index acf747973dee..02f13ab2ad8d 100644 --- a/src/plugins/visualizations/public/expressions/vis.ts +++ b/src/plugins/visualizations/public/expressions/vis.ts @@ -55,6 +55,7 @@ export interface ExprVisAPIEvents { filter: (data: any) => void; brush: (data: any) => void; applyFilter: (data: any) => void; + externalAction: (data: any) => void; } export interface ExprVisAPI { @@ -99,6 +100,10 @@ export class ExprVis extends EventEmitter { if (!this.eventsSubject) return; this.eventsSubject.next({ name: 'applyFilter', data }); }, + externalAction: (data: any) => { + if (!this.eventsSubject) return; + this.eventsSubject.next({ name: 'externalAction', data }); + }, }, }; }