diff --git a/changelogs/fragments/7288.yml b/changelogs/fragments/7288.yml new file mode 100644 index 000000000000..ccb0f4f9bf66 --- /dev/null +++ b/changelogs/fragments/7288.yml @@ -0,0 +1,2 @@ +feat: +- [VisBuilder] Add Capability to generate dynamic vega ([#7288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7288)) \ No newline at end of file diff --git a/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts b/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts index 301374747f8b..59b2ae473cac 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts @@ -48,6 +48,9 @@ const names: Record = { visualizations: i18n.translate('advancedSettings.categoryNames.visualizationsLabel', { defaultMessage: 'Visualizations', }), + visbuilder: i18n.translate('advancedSettings.categoryNames.visbuilderLabel', { + defaultMessage: 'VisBuilder', + }), discover: i18n.translate('advancedSettings.categoryNames.discoverLabel', { defaultMessage: 'Discover', }), diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 2062cb2a6fe7..f936d1eff65c 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -134,3 +134,4 @@ export { UnmappedTypeStrings, ExpressionValueRender as Render, } from '../common'; +export { getExpressionsService } from './services'; diff --git a/src/plugins/vis_builder/common/constants.ts b/src/plugins/vis_builder/common/constants.ts new file mode 100644 index 000000000000..0a3d2b6cf41b --- /dev/null +++ b/src/plugins/vis_builder/common/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const VISBUILDER_ENABLE_VEGA_SETTING = 'visbuilder:enableVega'; diff --git a/src/plugins/vis_builder/public/application/components/workspace.tsx b/src/plugins/vis_builder/public/application/components/workspace.tsx index 62f92b835050..2596c149169c 100644 --- a/src/plugins/vis_builder/public/application/components/workspace.tsx +++ b/src/plugins/vis_builder/public/application/components/workspace.tsx @@ -13,6 +13,7 @@ import { validateSchemaState, validateAggregations } from '../utils/validations' import { useTypedDispatch, useTypedSelector, setUIStateState } from '../utils/state_management'; import { useAggs, useVisualizationType } from '../utils/use'; import { PersistedState } from '../../../../visualizations/public'; +import { VISBUILDER_ENABLE_VEGA_SETTING } from '../../../common/constants'; import hand_field from '../../assets/hand_field.svg'; import fields_bg from '../../assets/fields_bg.svg'; @@ -27,6 +28,7 @@ export const WorkspaceUI = () => { notifications: { toasts }, data, uiActions, + uiSettings, }, } = useOpenSearchDashboards(); const { toExpression, ui } = useVisualizationType(); @@ -37,6 +39,7 @@ export const WorkspaceUI = () => { filters: data.query.filterManager.getFilters(), timeRange: data.query.timefilter.timefilter.getTime(), }); + const useVega = uiSettings.get(VISBUILDER_ENABLE_VEGA_SETTING); const rootState = useTypedSelector((state) => state); const dispatch = useTypedDispatch(); // Visualizations require the uiState object to persist even when the expression changes @@ -81,12 +84,20 @@ export const WorkspaceUI = () => { return; } - const exp = await toExpression(rootState, searchContext); + const exp = await toExpression(rootState, searchContext, useVega); setExpression(exp); } loadExpression(); - }, [rootState, toExpression, toasts, ui.containerConfig.data.schemas, searchContext, aggConfigs]); + }, [ + rootState, + toExpression, + toasts, + ui.containerConfig.data.schemas, + searchContext, + aggConfigs, + useVega, + ]); useLayoutEffect(() => { const subscription = data.query.state$.subscribe(({ state }) => { diff --git a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx index 38b84452ff4c..4043ceda67ab 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -34,11 +34,13 @@ import { getIndexPatterns, getTypeService, getUIActions, + getUISettings, } from '../plugin_services'; import { PersistedState, prepareJson } from '../../../visualizations/public'; import { VisBuilderSavedVis } from '../saved_visualizations/transforms'; import { handleVisEvent } from '../application/utils/handle_vis_event'; import { VisBuilderEmbeddableFactoryDeps } from './vis_builder_embeddable_factory'; +import { VISBUILDER_ENABLE_VEGA_SETTING } from '../../common/constants'; // Apparently this needs to match the saved object type for the clone and replace panel actions to work export const VISBUILDER_EMBEDDABLE = VISBUILDER_SAVED_OBJECT; @@ -150,11 +152,16 @@ export class VisBuilderEmbeddable extends Embeddable { visualizations: visualizationsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), data: dataPluginMock.createSetupContract(), + expressions: expressionsPluginMock.createSetupContract(), // Add this line }; const setup = plugin.setup(coreSetup, setupDeps); @@ -41,6 +43,7 @@ describe('VisBuilderPlugin', () => { aliasApp: PLUGIN_ID, }) ); + expect(setupDeps.expressions.registerFunction).toHaveBeenCalled(); // Add this expectation }); }); }); diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 87ac09588d4d..20b13281e53b 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -56,6 +56,7 @@ import { withNotifyOnErrors, } from '../../opensearch_dashboards_utils/public'; import { opensearchFilters } from '../../data/public'; +import { createRawDataVisFn } from './visualizations/vega/utils/expression_helper'; export class VisBuilderPlugin implements @@ -74,7 +75,7 @@ export class VisBuilderPlugin public setup( core: CoreSetup, - { embeddable, visualizations, data }: VisBuilderPluginSetupDependencies + { embeddable, visualizations, data, expressions: exp }: VisBuilderPluginSetupDependencies ) { const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ baseUrl: core.http.basePath.prepend(`/app/${PLUGIN_ID}`), @@ -107,6 +108,7 @@ export class VisBuilderPlugin // Register Default Visualizations const typeService = this.typeService; registerDefaultTypes(typeService.setup()); + exp.registerFunction(createRawDataVisFn()); // Register the plugin to core core.application.register({ diff --git a/src/plugins/vis_builder/public/services/type_service/types.ts b/src/plugins/vis_builder/public/services/type_service/types.ts index bc0a5cfe6c61..0c232829431c 100644 --- a/src/plugins/vis_builder/public/services/type_service/types.ts +++ b/src/plugins/vis_builder/public/services/type_service/types.ts @@ -31,6 +31,7 @@ export interface VisualizationTypeOptions { }; readonly toExpression: ( state: RenderState, - searchContext: IExpressionLoaderParams['searchContext'] + searchContext: IExpressionLoaderParams['searchContext'], + useVega: boolean ) => Promise; } diff --git a/src/plugins/vis_builder/public/services/type_service/visualization_type.tsx b/src/plugins/vis_builder/public/services/type_service/visualization_type.tsx index 0c2fadf3cf38..dfa76faa32dc 100644 --- a/src/plugins/vis_builder/public/services/type_service/visualization_type.tsx +++ b/src/plugins/vis_builder/public/services/type_service/visualization_type.tsx @@ -18,7 +18,8 @@ export class VisualizationType implements IVisualizationType { public readonly ui: IVisualizationType['ui']; public readonly toExpression: ( state: RenderState, - searchContext: IExpressionLoaderParams['searchContext'] + searchContext: IExpressionLoaderParams['searchContext'], + useVega: boolean ) => Promise; constructor(options: VisualizationTypeOptions) { diff --git a/src/plugins/vis_builder/public/types.ts b/src/plugins/vis_builder/public/types.ts index 61088400d92d..77510a593790 100644 --- a/src/plugins/vis_builder/public/types.ts +++ b/src/plugins/vis_builder/public/types.ts @@ -8,7 +8,7 @@ import { SavedObject, SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; import { VisualizationsSetup } from '../../visualizations/public'; -import { ExpressionsStart } from '../../expressions/public'; +import { ExpressionsStart, ExpressionsPublicPlugin } from '../../expressions/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { DataPublicPluginStart } from '../../data/public'; import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; @@ -18,6 +18,7 @@ import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; import { DataPublicPluginSetup } from '../../data/public'; import { UiActionsStart } from '../../ui_actions/public'; import { Capabilities } from '../../../core/public'; +import { IUiSettingsClient } from '../../../core/public'; export type VisBuilderSetup = TypeServiceSetup; export interface VisBuilderStart extends TypeServiceStart { @@ -28,6 +29,7 @@ export interface VisBuilderPluginSetupDependencies { embeddable: EmbeddableSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + expressions: ReturnType; } export interface VisBuilderPluginStartDependencies { embeddable: EmbeddableStart; @@ -37,6 +39,7 @@ export interface VisBuilderPluginStartDependencies { dashboard: DashboardStart; expressions: ExpressionsStart; uiActions: UiActionsStart; + uiSettings: IUiSettingsClient; } export interface VisBuilderServices extends CoreStart { diff --git a/src/plugins/vis_builder/public/visualizations/common/expression_helpers.ts b/src/plugins/vis_builder/public/visualizations/common/expression_helpers.ts index f50ab9172cdb..72c5bd7111e9 100644 --- a/src/plugins/vis_builder/public/visualizations/common/expression_helpers.ts +++ b/src/plugins/vis_builder/public/visualizations/common/expression_helpers.ts @@ -7,20 +7,21 @@ import { cloneDeep } from 'lodash'; import { OpenSearchaggsExpressionFunctionDefinition } from '../../../../data/public'; import { ExpressionFunctionOpenSearchDashboards } from '../../../../expressions'; import { buildExpressionFunction } from '../../../../expressions/public'; -import { VisualizationState } from '../../application/utils/state_management'; +import { VisualizationState, StyleState } from '../../application/utils/state_management'; import { getSearchService, getIndexPatterns } from '../../plugin_services'; -import { StyleState } from '../../application/utils/state_management'; +import { IExpressionLoaderParams } from '../../../../expressions/public'; export const getAggExpressionFunctions = async ( visualization: VisualizationState, - style?: StyleState + style?: StyleState, + useVega: boolean = false, + searchContext?: IExpressionLoaderParams['searchContext'] ) => { const { activeVisualization, indexPattern: indexId = '' } = visualization; const { aggConfigParams } = activeVisualization || {}; const indexPatternsService = getIndexPatterns(); const indexPattern = await indexPatternsService.get(indexId); - // aggConfigParams is the serealizeable aggConfigs that need to be reconstructed here using the agg servce const aggConfigs = getSearchService().aggs.createAggConfigs( indexPattern, cloneDeep(aggConfigParams) @@ -31,7 +32,6 @@ export const getAggExpressionFunctions = async ( {} ); - // soon this becomes: const opensearchaggs = vis.data.aggs!.toExpressionAst(); const opensearchaggs = buildExpressionFunction( 'opensearchaggs', { @@ -43,9 +43,20 @@ export const getAggExpressionFunctions = async ( } ); + let expressionFns = [opensearchDashboards, opensearchaggs]; + + if (useVega === true && searchContext) { + const opensearchDashboardsContext = buildExpressionFunction('opensearch_dashboards_context', { + timeRange: JSON.stringify(searchContext.timeRange || {}), + filters: JSON.stringify(searchContext.filters || []), + query: JSON.stringify(searchContext.query || []), + }); + expressionFns = [opensearchDashboards, opensearchDashboardsContext, opensearchaggs]; + } + return { aggConfigs, indexPattern, - expressionFns: [opensearchDashboards, opensearchaggs], + expressionFns, }; }; diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/axes.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/axes.test.ts new file mode 100644 index 000000000000..25d8d2e37cce --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/axes.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildAxes } from './axes'; + +describe('axes.ts', () => { + describe('buildAxes', () => { + it('should return correct axis configurations for date x-axis', () => { + const dimensions = { + x: { format: { id: 'date' } }, + y: [{ label: 'Y Axis' }], + }; + const formats = { + xAxisLabel: 'X Axis', + yAxisLabel: 'Custom Y Axis', + }; + + const result = buildAxes(dimensions, formats); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + orient: 'bottom', + scale: 'x', + labelAngle: -90, + labelAlign: 'right', + labelBaseline: 'middle', + title: 'X Axis', + format: '%Y-%m-%d %H:%M', + }); + expect(result[1]).toEqual({ + orient: 'left', + scale: 'y', + title: 'Custom Y Axis', + }); + }); + + it('should not add format when x is not date', () => { + const dimensions = { + x: { format: { id: 'number' } }, + y: [{ label: 'Y Axis' }], + }; + const result = buildAxes(dimensions, 'X', 'Y'); + + expect(result[0]).not.toHaveProperty('format'); + }); + + it('should use default labels when not provided', () => { + const dimensions = { + x: {}, + y: [{ label: 'Default Y' }], + }; + const result = buildAxes(dimensions, '', ''); + + expect(result[0].title).toBe('_all'); + expect(result[1].title).toBe('Default Y'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/axes.ts b/src/plugins/vis_builder/public/visualizations/vega/components/axes.ts new file mode 100644 index 000000000000..033ce020a8d9 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/axes.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AxisFormats } from '../utils/types'; + +export interface AxisConfig { + orient?: string; + scale?: string; + labelAngle?: number; + labelAlign?: string; + labelBaseline?: string; + title: any; + format?: string; // property for date format +} + +/** + * Builds the axes configuration for a chart. + * + * Note: This axis configuration is currently tailored for specific use cases. + * In the future, we plan to expand and generalize this function to accommodate + * a wider range of chart types and axis configurations. + * @param {any} dimensions - The dimensions of the data. + * @param {AxisFormats} formats - The formatting information for axes. + */ + +export const buildAxes = (dimensions: any, formats: AxisFormats): AxisConfig[] => { + const { xAxisLabel, yAxisLabel } = formats; + const xAxis: AxisConfig = { + orient: 'bottom', + scale: 'x', + labelAngle: -90, + labelAlign: 'right', + labelBaseline: 'middle', + title: xAxisLabel || '_all', + }; + + // Add date format if x dimension is a date type + if (dimensions.x && dimensions.x.format && dimensions.x.format.id === 'date') { + xAxis.format = '%Y-%m-%d %H:%M'; + } + + const yAxis: AxisConfig = { + orient: 'left', + scale: 'y', + title: yAxisLabel ? yAxisLabel : dimensions.y && dimensions.y[0] ? dimensions.y[0].label : '', + }; + + return [xAxis, yAxis]; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/encoding.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/encoding.test.ts new file mode 100644 index 000000000000..4000f3123f07 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/encoding.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVegaLiteEncoding } from './encoding'; + +describe('encoding.ts', () => { + describe('buildVegaLiteEncoding', () => { + it('should build correct encoding for x and y axes', () => { + const dimensions = { x: [{}], y: [{}] }; + const formats = { xAxisLabel: 'X Label', yAxisLabel: 'Y Label' }; + const result = buildVegaLiteEncoding(dimensions, formats); + + expect(result.x).toBeDefined(); + expect(result.y).toBeDefined(); + expect(result.x!.axis!.title).toBe('X Label'); + expect(result.y!.axis!.title).toBe('Y Label'); + }); + + it('should include color encoding when y dimension is present', () => { + const dimensions = { x: [{}], y: [{}] }; + const formats = {}; + const result = buildVegaLiteEncoding(dimensions, formats); + + expect(result.color).toBeDefined(); + expect(result.color!.field).toBe('series'); + expect(result.color!.type).toBe('nominal'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/encoding.ts b/src/plugins/vis_builder/public/visualizations/vega/components/encoding.ts new file mode 100644 index 000000000000..64f420243b2c --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/encoding.ts @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mapFieldTypeToVegaType } from '../utils/helpers'; +import { AxisFormat, AxisFormats } from '../utils/types'; + +interface BaseEncodingChannel { + field?: string; + type?: string; + legend?: { title: string | null } | null; +} + +export interface AxisEncodingChannel extends BaseEncodingChannel { + axis?: { title: string }; +} + +export type ColorEncodingChannel = BaseEncodingChannel; + +export interface TooltipEncodingChannel extends BaseEncodingChannel { + title?: string; +} +export interface VegaEncoding { + x?: AxisEncodingChannel; + y?: AxisEncodingChannel; + color?: ColorEncodingChannel; + tooltip?: TooltipEncodingChannel | TooltipEncodingChannel[]; + [key: string]: + | BaseEncodingChannel + | BaseEncodingChannel[] + | TooltipEncodingChannel + | TooltipEncodingChannel[] + | undefined; +} + +export interface VegaScale { + name: string; + type: string; + domain: { + data: string; + field: string; + filter?: string; + }; + range?: string; + padding?: number; + nice?: boolean; + zero?: boolean; +} + +/** + * Builds encoding configuration for Vega-Lite specifications. + * + * @param {any} dimensions - The dimensions of the data. + * @param {AxisFormats} formats - The formatting information for axes. + * @returns {VegaEncoding} The Vega-Lite encoding configuration. + */ +export const buildVegaLiteEncoding = (dimensions: any, formats: AxisFormats): VegaEncoding => { + const { xAxisFormat, xAxisLabel, yAxisFormat, yAxisLabel } = formats; + const encoding: VegaEncoding = {}; + + // Handle x-axis + encoding.x = buildAxisEncoding('x', dimensions.x, xAxisFormat, xAxisLabel); + + // Handle y-axis + encoding.y = buildAxisEncoding('y', dimensions.y, yAxisFormat, yAxisLabel); + + // Handle color encoding for multiple y dimensions or series + if (dimensions.y) { + encoding.color = buildColorEncoding('series', 'nominal'); + } + + return encoding; +}; + +/** + * Builds encoding for an axis. + * + * @param {string} field - The field name ('x' or 'y'). + * @param {any[]} dimension - The dimension data. + * @param {AxisFormat} axisFormat - The axis format information. + * @param {string} axisLabel - The axis label. + * @returns {AxisEncodingChannel} The axis encoding configuration. + */ +const buildAxisEncoding = ( + field: string, + dimension: any[] | undefined, + axisFormat?: AxisFormat, + axisLabel?: string +): AxisEncodingChannel => { + return { + field, + type: dimension ? mapFieldTypeToVegaType(axisFormat?.id) : 'ordinal', + axis: { title: axisLabel ? axisLabel : '' }, + }; +}; + +/** + * Builds encoding for color. + * + * @param {string} field - The field name for color encoding. + * @param {string} type - The data type for color encoding. + * @returns {ColorEncodingChannel} The color encoding configuration. + */ +const buildColorEncoding = (field: string, type: string): ColorEncodingChannel => { + return { + field, + type, + legend: { title: null }, + }; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/legend.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/legend.test.ts new file mode 100644 index 000000000000..48c6a2264595 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/legend.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildLegend } from './legend'; + +describe('legend.ts', () => { + describe('buildLegend', () => { + it('should build Vega legend configuration', () => { + const result = buildLegend('right', true); + expect(result.fill).toBe('color'); + expect(result.orient).toBe('right'); + }); + + it('should build Vega-Lite legend configuration', () => { + const result = buildLegend('top', false); + expect(result.fill).toBeUndefined(); + expect(result.orient).toBe('top'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/legend.ts b/src/plugins/vis_builder/public/visualizations/vega/components/legend.ts new file mode 100644 index 000000000000..a96652939bf5 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/legend.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// Types for legend positions and legend configurations +type LegendPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface VegaLegendConfig { + fill?: string; + orient: LegendPosition; + [key: string]: any; // For any additional properties +} + +interface VegaLiteLegendConfig { + orient: LegendPosition; + [key: string]: any; // For any additional properties +} + +/** + * Builds a legend configuration for Vega or Vega-Lite specifications. + * + * @param {LegendPosition} legendPosition - The position of the legend ('top', 'bottom', 'left', 'right'). + * @param {boolean} isVega - Whether to build for Vega (true) or Vega-Lite (false). + * @returns {VegaLegendConfig | VegaLiteLegendConfig} The legend configuration object. + */ +export const buildLegend = ( + legendPosition: LegendPosition, + isVega: boolean = false +): VegaLegendConfig | VegaLiteLegendConfig => { + if (isVega) { + return buildVegaLegend(legendPosition); + } + return buildVegaLiteLegend(legendPosition); +}; + +/** + * Builds a legend configuration specifically for Vega specifications. + * + * @param {LegendPosition} legendPosition - The position of the legend. + * @returns {VegaLegendConfig} The Vega legend configuration object. + */ +const buildVegaLegend = (legendPosition: LegendPosition): VegaLegendConfig => { + return { + fill: 'color', + orient: legendPosition, + }; +}; + +/** + * Builds a legend configuration specifically for Vega-Lite specifications. + * + * @param {LegendPosition} legendPosition - The position of the legend. + * @returns {VegaLiteLegendConfig} The Vega-Lite legend configuration object. + */ +const buildVegaLiteLegend = (legendPosition: LegendPosition): VegaLiteLegendConfig => { + return { + orient: legendPosition, + }; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts new file mode 100644 index 000000000000..fc682940a4f9 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildMarkForVegaLite, buildMarkForVega } from './mark'; + +describe('mark.ts', () => { + describe('buildMarkForVegaLite', () => { + it('should build line mark', () => { + const result = buildMarkForVegaLite('line'); + expect(result.type).toBe('line'); + expect(result.point).toBe(true); + }); + + it('should build area mark', () => { + const result = buildMarkForVegaLite('area'); + expect(result.type).toBe('area'); + expect(result.line).toBe(true); + }); + + it('should build bar mark', () => { + const result = buildMarkForVegaLite('bar'); + expect(result.type).toBe('bar'); + }); + }); + + describe('buildMarkForVega', () => { + it('should build group mark with correct structure', () => { + const dimensions = { y: [{ label: 'Y Label' }] }; + const formats = { xAxisLabel: 'X Label' }; + const result = buildMarkForVega('line', dimensions, formats); + + expect(result.type).toBe('group'); + expect(result.marks).toBeDefined(); + expect(result.scales).toHaveLength(3); + expect(result.axes).toHaveLength(2); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts new file mode 100644 index 000000000000..dd85544dd839 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts @@ -0,0 +1,297 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AxisFormats } from '../utils/types'; +import { buildAxes } from './axes'; + +export type VegaMarkType = + | 'line' + | 'rect' + | 'area' + | 'symbol' + | 'bar' + | 'point' + | 'circle' + | 'square' + | 'group'; + +export interface VegaMark { + type: VegaMarkType; + from?: { + data?: string; + facet?: { + name?: string; + data?: string; + groupby?: string | string[]; + filter?: string; + }; + }; + encode?: { + enter?: Record; + update?: Record; + }; + signals?: Array<{ name: string; update: string }>; + scales?: any[]; // TODO: create a more specific type for scales + axes?: any[]; // TODO: create a more specific type for axes + title?: { + text: string | { signal: string }; + }; + marks?: VegaMark[]; +} + +interface BaseVegaLiteMark { + type: VegaMarkType; + tooltip?: boolean; + [key: string]: any; +} + +interface LineVegaLiteMark extends BaseVegaLiteMark { + type: 'line'; + point?: boolean | { filled?: boolean; size?: number }; +} + +interface AreaVegaLiteMark extends BaseVegaLiteMark { + type: 'area'; + line?: boolean; +} + +interface BarVegaLiteMark extends BaseVegaLiteMark { + type: 'bar'; + cornerRadius?: number; +} + +export type VegaLiteMark = BaseVegaLiteMark | LineVegaLiteMark | AreaVegaLiteMark | BarVegaLiteMark; + +/** + * Builds a mark configuration for Vega-Lite based on the chart type. + * + * @param {string} vegaType - The type of Vega mark to build. + * @returns {VegaLiteMark} The Vega-Lite mark configuration. + */ +export const buildMarkForVegaLite = (vegaType: string): VegaLiteMark => { + switch (vegaType) { + case 'line': + return { type: 'line', point: true }; + case 'area': + return { type: 'area', line: true, opacity: 1, fillOpacity: 1, baseline: 0 }; + case 'rect': + case 'bar': + return { type: 'bar' }; + default: + // Currently we can only handle line/area/bar. + // Set default to use line chart. + return { type: 'line', point: true }; + } +}; + +/** + * Builds a mark configuration for Vega based on the chart type. + * + * @param {VegaMarkType} chartType - The type of chart to build the mark for. + * @param {any} dimensions - The dimensions of the data. + * @param {AxisFormats} formats - The formatting information for axes. + * @returns {VegaMark} An array of mark configurations. + */ +export const buildMarkForVega = ( + chartType: VegaMarkType, + dimensions: any, + formats: AxisFormats +): VegaMark => { + const baseMark: VegaMark = { + type: 'group', + from: { + facet: { + name: 'split_data', + data: chartType === 'area' ? 'stacked' : 'source', + groupby: 'split', + }, + }, + encode: { + enter: { + width: { signal: 'chartWidth' }, + height: { signal: 'height' }, + }, + }, + signals: [{ name: 'width', update: 'chartWidth' }], + scales: [ + buildXScale(chartType, dimensions), + buildYScale(chartType), + { + name: 'color', + type: 'ordinal', + domain: { data: 'split_data', field: 'series' }, + range: 'category', + }, + ], + axes: buildAxes(dimensions, formats), + title: { + text: { signal: 'parent.split' }, + }, + marks: [ + { + type: 'group', + from: { + facet: { + name: 'series_data', + data: 'split_data', + groupby: 'series', + }, + }, + marks: buildChartTypeMarksForVega(chartType, dimensions), + }, + ], + }; + + return baseMark; +}; + +const buildXScale = (chartType: VegaMarkType, dimensions) => { + // For date-based data, use a time scale regardless of the chart type. + if (dimensions.x && dimensions.x.format && dimensions.x.format.id === 'date') { + return { + name: 'x', + type: 'time', + domain: { data: 'split_data', field: 'x' }, + range: 'width', + }; + } + + switch (chartType) { + case 'bar': + return { + name: 'x', + type: 'band', + domain: { data: 'split_data', field: 'x' }, + range: 'width', + padding: 0.1, + }; + case 'line': + case 'area': + default: + return { + name: 'x', + type: 'point', + domain: { data: 'split_data', field: 'x' }, + range: 'width', + padding: 0.5, + }; + } +}; + +const buildYScale = (chartType: VegaMarkType) => { + return { + name: 'y', + type: 'linear', + domain: { data: 'split_data', field: chartType === 'area' ? 'y1' : 'y' }, + range: 'height', + nice: true, + zero: true, + }; +}; + +/** + * Builds a mark configuration for Vega based on the chart type. + * + * @param {VegaMarkType} chartType - The type of chart to build the mark for. + * @param {any} dimensions - The dimensions of the data. + * @param {AxisFormats} formats - The formatting information for axes. + * @returns {VegaMark[]} An array of mark configurations. + */ +const buildChartTypeMarksForVega = (chartType: VegaMarkType, dimensions: any): VegaMark[] => { + switch (chartType) { + case 'line': + return buildMarkForLine(dimensions); + case 'bar': + return buildMarkForBar(); + case 'area': + return buildMarkForArea(); + default: + return buildMarkForLine(dimensions); + } +}; + +/** + * Builds a mark configuration for a line chart in Vega. + * + * @param {any} dimensions - The dimensions of the data. + * @returns {VegaMark[]} An array of mark configurations for line and point marks. + */ +const buildMarkForLine = (dimensions: any): VegaMark[] => { + const marks: VegaMark[] = [ + { + type: 'line', + from: { data: 'series_data' }, + encode: { + enter: { + x: { scale: 'x', field: 'x' }, + y: { scale: 'y', field: 'y' }, + stroke: { scale: 'color', field: 'series' }, + strokeWidth: { value: 2 }, + }, + }, + }, + { + type: 'symbol', + from: { data: 'series_data' }, + encode: { + enter: { + x: { scale: 'x', field: 'x' }, + y: { scale: 'y', field: 'y' }, + fill: { scale: 'color', field: 'series' }, + size: dimensions.z ? { scale: 'size', field: 'z' } : { value: 50 }, + }, + }, + }, + ]; + return marks; +}; + +/** + * Builds a mark configuration for a histogram in Vega. + * + * @returns {VegaMark[]} An array with a single mark configuration for rect marks. + */ +const buildMarkForBar = (): VegaMark[] => { + return [ + { + type: 'rect', + from: { data: 'series_data' }, + encode: { + enter: { + x: { scale: 'x', field: 'x' }, + width: { scale: 'x', band: 1, offset: -1 }, + y: { scale: 'y', field: 'y' }, + y2: { scale: 'y', value: 0 }, + fill: { scale: 'color', field: 'series' }, + }, + }, + }, + ]; +}; + +/** + * Builds a mark configuration for an area chart in Vega. + * + * @returns {VegaMark[]} An array with a single mark configuration for grouped area marks. + */ +const buildMarkForArea = (): VegaMark[] => { + return [ + { + type: 'area', + from: { data: 'series_data' }, + encode: { + enter: { + x: { scale: 'x', field: 'x' }, + y: { scale: 'y', field: 'y0' }, + y2: { scale: 'y', field: 'y1' }, + fill: { scale: 'color', field: 'series' }, + fillOpacity: { value: 1 }, + stroke: { scale: 'color', field: 'series' }, + strokeWidth: { value: 1 }, + }, + }, + }, + ]; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.test.ts new file mode 100644 index 000000000000..6526eaa0c59e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildTooltip } from './tooltip'; +import { VegaLiteSpec } from '../utils/types'; + +describe('tooltip.ts', () => { + describe('buildTooltip', () => { + it('should build tooltip with combined series and y value when yAxisLabel is not provided', () => { + const baseSpec: VegaLiteSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [] }, + mark: { type: 'point' }, + encoding: {}, + }; + const dimensions = {}; + const formats = { xAxisLabel: 'X Label' }; + + buildTooltip(baseSpec, dimensions, formats); + + expect(baseSpec.transform).toBeDefined(); + expect(baseSpec.encoding.tooltip).toBeDefined(); + expect(Array.isArray(baseSpec.encoding.tooltip)).toBe(true); + expect((baseSpec.encoding.tooltip as any[]).length).toBe(2); + expect((baseSpec.encoding.tooltip as any[])[0].field).toBe('x'); + expect((baseSpec.encoding.tooltip as any[])[1].field).toBe('metrics'); + }); + + it('should build tooltip with separate x and y fields when yAxisLabel is provided', () => { + const baseSpec: VegaLiteSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [] }, + mark: { type: 'point' }, + encoding: {}, + }; + const dimensions = {}; + const formats = { xAxisLabel: 'X Label', yAxisLabel: 'Y Label' }; + + buildTooltip(baseSpec, dimensions, formats); + + expect(baseSpec.transform).toBeUndefined(); + expect(baseSpec.encoding.tooltip).toBeDefined(); + expect(Array.isArray(baseSpec.encoding.tooltip)).toBe(true); + expect((baseSpec.encoding.tooltip as any[]).length).toBe(2); + expect((baseSpec.encoding.tooltip as any[])[0].field).toBe('x'); + expect((baseSpec.encoding.tooltip as any[])[1].field).toBe('y'); + }); + + it('should add z dimension to tooltip when present', () => { + const baseSpec: VegaLiteSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [] }, + mark: { type: 'point' }, + encoding: {}, + }; + const dimensions = { z: [{ label: 'Z Label' }] }; + const formats = { xAxisLabel: 'X Label', yAxisLabel: 'Y Label' }; + + buildTooltip(baseSpec, dimensions, formats); + + expect(baseSpec.encoding.tooltip).toBeDefined(); + expect(Array.isArray(baseSpec.encoding.tooltip)).toBe(true); + expect((baseSpec.encoding.tooltip as any[]).length).toBe(3); + expect((baseSpec.encoding.tooltip as any[])[2].field).toBe('z'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.ts b/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.ts new file mode 100644 index 000000000000..b73bfcf6f0fb --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/tooltip.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AxisFormats, VegaLiteSpec } from '../utils/types'; + +/** + * Builds tooltip configuration for a dynamic Vega specification using OpenSearch data. + * + * @param {VegaLiteSpec} baseSpec - The base Vega Lite specification to modify. + * @param {any} dimensions - The dimensions of the data. + * @param {AxisFormats} formats - The formatting information for axes. + * @returns {void} - This function modifies the baseSpec object in place. + */ +export const buildTooltip = ( + baseSpec: VegaLiteSpec, + dimensions: any, + formats: AxisFormats +): void => { + const { xAxisLabel, yAxisLabel } = formats; + + if (!baseSpec.encoding) { + baseSpec.encoding = {}; + } + + // Configure tooltip based on the presence of yAxisLabel + if (!yAxisLabel) { + // If yAxisLabel is not provided, combine series and y value for tooltip + baseSpec.transform = [ + { + calculate: "datum.series + ': ' + datum.y", + as: 'metrics', + }, + ]; + + baseSpec.encoding.tooltip = [ + { field: 'x', type: 'nominal', title: xAxisLabel || '_all' }, + { field: 'metrics', type: 'nominal' }, + ]; + } else { + // If yAxisLabel is provided, use separate fields for x and y in tooltip + baseSpec.encoding.tooltip = [ + { field: 'x', type: 'nominal', title: xAxisLabel || '_all' }, + { field: 'y', type: 'nominal', title: yAxisLabel }, + ]; + } + + // Add z dimension to tooltip if it exists + if (dimensions.z && dimensions.z.length > 0) { + baseSpec.encoding.tooltip.push({ + field: 'z', + type: 'quantitative', + title: dimensions.z[0].label, + }); + } + + // Enable tooltip for the mark + baseSpec.mark = { + ...baseSpec.mark, + tooltip: true, + }; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts new file mode 100644 index 000000000000..dd26df5219c2 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createRawDataVisFn, executeExpression } from './expression_helper'; +import { getExpressionsService } from '../../../../../expressions/public'; + +jest.mock('../../../../../expressions/public', () => ({ + getExpressionsService: jest.fn(), +})); + +describe('expression_helper.ts', () => { + describe('createRawDataVisFn', () => { + it('should create a function definition for raw data visualization', () => { + const result = createRawDataVisFn(); + expect(result.name).toBe('rawData'); + expect(result.type).toBe('opensearch_dashboards_datatable'); + expect(result.inputTypes).toEqual(['opensearch_dashboards_datatable']); + }); + + it('should return the input context unchanged', () => { + const result = createRawDataVisFn(); + const context = { some: 'data' }; + expect(result.fn(context as any)).toBe(context); + }); + }); + + describe('executeExpression', () => { + it('should execute an expression and return the result', async () => { + const mockExecute = jest.fn().mockResolvedValue({ + getData: jest.fn().mockResolvedValue({ result: 'data' }), + }); + (getExpressionsService as jest.Mock).mockReturnValue({ + execute: mockExecute, + }); + + const result = await executeExpression('test expression', { context: 'data' }); + expect(result).toEqual({ result: 'data' }); + expect(mockExecute).toHaveBeenCalledWith( + 'test expression', + { type: 'null' }, + { context: 'data' } + ); + }); + + it('should throw an error if expression service is not available', async () => { + (getExpressionsService as jest.Mock).mockReturnValue(null); + + await expect(executeExpression('test', {})).rejects.toThrow( + 'Expression service is not available' + ); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts new file mode 100644 index 000000000000..2967fa177983 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ExpressionFunctionDefinition, + OpenSearchDashboardsDatatable, + ExpressionValueBoxed, +} from '../../../../../expressions/common'; +import { getExpressionsService } from '../../../../../expressions/public'; + +/** + * Creates a function definition for raw data visualization. + * This function simply returns the input data without modification. + * + * @returns {ExpressionFunctionDefinition} The function definition for raw data visualization. + */ +export const createRawDataVisFn = (): ExpressionFunctionDefinition< + 'rawData', + OpenSearchDashboardsDatatable, + {}, + OpenSearchDashboardsDatatable +> => ({ + name: 'rawData', + type: 'opensearch_dashboards_datatable', + inputTypes: ['opensearch_dashboards_datatable'], + help: 'Returns raw data from opensearchaggs without modification', + args: {}, + fn(context: OpenSearchDashboardsDatatable): OpenSearchDashboardsDatatable { + // Simply return the input context, which should be the opensearchaggs result + return context; + }, +}); + +/** + * Executes an expression with the given context. + * + * @param {string} expression - The expression to execute. + * @param {any} context - The context to use for execution. + * @returns {Promise} A promise that resolves to the execution result. + * @throws {Error} If the expression service is not available or execution fails. + */ +export async function executeExpression( + expression: string, + context: any +): Promise { + const expressionService = getExpressionsService(); + + if (!expressionService) { + throw new Error('Expression service is not available'); + } + + try { + const result = await expressionService.execute(expression, { type: 'null' }, context); + const data = await result.getData(); + return data as ExpressionValueBoxed; + } catch (error) { + throw error; + } +} diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts new file mode 100644 index 000000000000..92576d1a608b --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { flattenDataHandler, mapFieldTypeToVegaType, mapChartTypeToVegaType } from './helpers'; + +// Mock the vislibSeriesResponseHandler +jest.mock('../../../../../vis_type_vislib/public', () => ({ + vislibSeriesResponseHandler: jest.fn((context, dimensions) => { + if (dimensions.splitRow || dimensions.splitColumn) { + return { + rows: context.rows, + }; + } else { + return { + series: context.series, + }; + } + }), +})); + +describe('helpers.ts', () => { + describe('flattenDataHandler', () => { + it('should flatten series data correctly with split', () => { + const context = { + rows: [ + { + label: 'Group 1', + series: [ + { + label: 'Series 1', + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + ], + }, + ], + }; + const dimensions = { splitRow: [{}] }; + const result = flattenDataHandler(context, dimensions); + + expect(result.series).toHaveLength(2); + expect(result.series[0]).toEqual({ x: 1, y: 10, series: 'Series 1', split: 'Group 1' }); + expect(result.series[1]).toEqual({ x: 2, y: 20, series: 'Series 1', split: 'Group 1' }); + }); + + it('should handle series without split', () => { + const context = { + series: [ + { + label: 'Series 1', + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + ], + }; + const dimensions = {}; + const result = flattenDataHandler(context, dimensions); + + expect(result.series).toHaveLength(2); + expect(result.series[0]).toEqual({ x: 1, y: 10, series: 'Series 1' }); + expect(result.series[1]).toEqual({ x: 2, y: 20, series: 'Series 1' }); + }); + }); + + describe('mapFieldTypeToVegaType', () => { + it('should map OpenSearch field types to Vega data types', () => { + expect(mapFieldTypeToVegaType('number')).toBe('quantitative'); + expect(mapFieldTypeToVegaType('date')).toBe('temporal'); + expect(mapFieldTypeToVegaType('keyword')).toBe('nominal'); + expect(mapFieldTypeToVegaType('unknown')).toBe('nominal'); + }); + }); + + describe('mapChartTypeToVegaType', () => { + it('should map chart types to Vega mark types', () => { + expect(mapChartTypeToVegaType('histogram')).toBe('bar'); + expect(mapChartTypeToVegaType('line')).toBe('line'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts new file mode 100644 index 000000000000..e15edf1ac795 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vislibSeriesResponseHandler, + vislibSlicesResponseHandler, +} from '../../../../../vis_type_vislib/public'; +import { AxisFormats } from './types'; + +/** + * Sets axis properties (Format and Label) for x, y, and z axes + * @param {Object} converted - The object to set properties on + * @param {Array} group - The group containing axis information + */ +const setAxisProperties = (converted: any, group: any[]): void => { + const axes = ['xAxis', 'yAxis', 'zAxis']; + const properties = ['Format', 'Label']; + + axes.forEach((axis) => { + properties.forEach((prop) => { + const key = `${axis}${prop}` as keyof AxisFormats; + converted[key] = group[0][key]; + }); + }); +}; + +interface SeriesValue { + x: any; + y: any; + z?: any; +} + +interface Series { + label: string; + values: SeriesValue[]; +} + +interface FlattenedSeriesItem extends SeriesValue { + series: string; + split?: string; +} + +/** + * Flattens series data into a single array of data points + * @param {Series} series - The series data to flatten + * @param {string|null} splitLabel - The label for the split, if any + * @returns {FlattenedSeriesItem} Flattened array of data points + */ +const flattenSeries = ( + series: Series[], + splitLabel: string | null = null +): FlattenedSeriesItem[] => { + return series.flatMap((s) => { + if (!s.values || !Array.isArray(s.values)) { + throw new Error('Each series must have a "values" array'); + } + + return s.values.map( + (v): FlattenedSeriesItem => { + const baseItem: FlattenedSeriesItem = { + x: v.x, + y: v.y, + series: s.label, + }; + + if (v.z !== undefined) { + baseItem.z = v.z; + } + + if (splitLabel) { + baseItem.split = splitLabel; + } + + return baseItem; + } + ); + }); +}; + +export const flattenDataHandler = (context, dimensions, handlerType = 'series') => { + // Currently, our vislib only supports 'series' or 'slices' response types. + // This will need to be updated if more types are added in the future. + const handler = + handlerType === 'series' ? vislibSeriesResponseHandler : vislibSlicesResponseHandler; + const converted = handler(context, dimensions); + + if (handlerType === 'series') { + // Determine the group based on split dimensions + const group = dimensions.splitRow + ? converted.rows + : dimensions.splitColumn + ? converted.columns + : []; + + if (group && group.length !== 0) { + converted.series = group.flatMap((split) => flattenSeries(split.series, split.label)); + setAxisProperties(converted, group); + } else { + converted.series = flattenSeries(converted.series); + } + } else if (handlerType === 'slices') { + // TODO: Handle slices data, such as pie charts + // This section should be implemented when support for slice-based charts is added + } + + return converted; +}; + +/** + * Maps OpenSearch field types to Vega data types + * @param {string} fieldType - The OpenSearch field type + * @returns {string} The corresponding Vega data type + */ +export const mapFieldTypeToVegaType = (fieldType) => { + const typeMap = { + number: 'quantitative', + date: 'temporal', + time: 'temporal', + terms: 'nominal', + keyword: 'nominal', + ip: 'nominal', + boolean: 'nominal', + histogram: 'quantitative', + }; + + // Default to 'nominal' if the field type is not recognized + return typeMap[fieldType] || 'nominal'; +}; + +/** + * Maps chart types to Vega mark types + * @param {string} chartType - The chart type + * @returns {string} The corresponding Vega mark type + */ +export const mapChartTypeToVegaType = (chartType) => + chartType === 'histogram' ? 'bar' : chartType; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts new file mode 100644 index 000000000000..855d88fd145e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VegaEncoding } from '../components/encoding'; +import { VegaLiteMark } from '../components/mark'; +export interface AxisFormat { + id: string; +} + +export interface AxisFormats { + xAxisLabel?: string; + yAxisLabel?: string; + zAxisLabel?: string; + xAxisFormat?: AxisFormat; + yAxisFormat?: AxisFormat; + zAxisFormat?: AxisFormat; +} + +// VegaLiteSpec interface +export interface VegaLiteSpec { + $schema: string; + data: { + values: any[]; + }; + mark: VegaLiteMark; + encoding: VegaEncoding; + transform?: Array<{ + calculate: string; + as: string; + }>; + layer?: LayerSpec[]; + config?: { + legend?: any; + [key: string]: any; + }; + selection?: { + legend_selection?: { + type: string; + fields: string[]; + bind: string; + }; + }; +} + +export interface LayerSpec { + mark: VegaLiteMark; + encoding: VegaEncoding; +} + +// VegaSpec interface +export interface VegaSpec { + $schema: string; + padding?: number | { [key: string]: number }; + data: Array<{ + name: string; + values?: any[]; + source?: string; + transform?: Array<{ + type: string; + [key: string]: any; + }>; + }>; + signals?: Array<{ + name: string; + update: string; + [key: string]: any; + }>; + scales?: Array<{ + name: string; + type: string; + domain: any; + range: any; + [key: string]: any; + }>; + layout?: { + [key: string]: any; + }; + marks: Array<{ + type: string; + from?: any; + encode?: { + [key: string]: any; + }; + signals?: Array<{ + name: string; + update: string; + }>; + scales?: any[]; + axes?: Array<{ + orient: string; + scale: string; + [key: string]: any; + }>; + title?: { + [key: string]: any; + }; + marks?: any[]; + [key: string]: any; + }>; + legends?: Array<{ + [key: string]: any; + }>; + [key: string]: any; // Allow for additional properties +} diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts new file mode 100644 index 000000000000..0f1f25b1e762 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateVegaLiteSpec } from './vega_lite_spec_builder'; + +describe('generateVegaLiteSpec', () => { + it('should generate a basic Vega-Lite specification', () => { + const data = { + xAxisFormat: { id: 'date' }, + xAxisLabel: 'Date', + yAxisFormat: { id: 'number' }, + yAxisLabel: 'Value', + series: [{ x: '2023-01-01', y: 10, series: 'A' }], + }; + const visConfig = { + dimensions: { x: [{}], y: [{}] }, + addLegend: true, + legendPosition: 'right', + addTooltip: true, + }; + const style = { type: 'line' }; + + const result = generateVegaLiteSpec(data, visConfig, style); + + expect(result.$schema).toBe('https://vega.github.io/schema/vega-lite/v5.json'); + expect(result.data).toBeDefined(); + expect(result.mark).toBeDefined(); + expect(result.encoding).toBeDefined(); + }); + + it('should handle different chart types', () => { + const data = { + xAxisFormat: { id: 'date' }, + xAxisLabel: 'Date', + yAxisFormat: { id: 'number' }, + yAxisLabel: 'Value', + series: [{ x: '2023-01-01', y: 10, series: 'A' }], + }; + const visConfig = { + dimensions: { x: [{}], y: [{}] }, + addLegend: true, + legendPosition: 'right', + addTooltip: true, + }; + + const lineResult = generateVegaLiteSpec(data, visConfig, { type: 'line' }); + expect(lineResult.mark).toEqual({ type: 'line', point: true, tooltip: true }); + + const areaResult = generateVegaLiteSpec(data, visConfig, { type: 'area' }); + expect(areaResult.mark).toEqual({ + type: 'area', + line: true, + opacity: 1, + tooltip: true, + fillOpacity: 1, + baseline: 0, + }); + + const barResult = generateVegaLiteSpec(data, visConfig, { type: 'bar' }); + expect(barResult.mark).toEqual({ type: 'bar', tooltip: true }); + }); + + it('should add legend when specified', () => { + const data = { + xAxisFormat: { id: 'date' }, + xAxisLabel: 'Date', + yAxisFormat: { id: 'number' }, + yAxisLabel: 'Value', + series: [{ x: '2023-01-01', y: 10, series: 'A' }], + }; + const visConfig = { + dimensions: { x: [{}], y: [{}] }, + addLegend: true, + legendPosition: 'right', + addTooltip: true, + }; + const style = { type: 'line' }; + + const result = generateVegaLiteSpec(data, visConfig, style); + + expect(result.config).toBeDefined(); + expect(result.config!.legend).toBeDefined(); + expect(result.config!.legend!.orient).toBe('right'); + }); + + it('should add tooltip when specified', () => { + const data = { + xAxisFormat: { id: 'date' }, + xAxisLabel: 'Date', + yAxisFormat: { id: 'number' }, + yAxisLabel: 'Value', + series: [{ x: '2023-01-01', y: 10, series: 'A' }], + }; + const visConfig = { + dimensions: { x: [{}], y: [{}] }, + addLegend: true, + legendPosition: 'right', + addTooltip: true, + }; + const style = { type: 'line' }; + + const result = generateVegaLiteSpec(data, visConfig, style); + + expect(result.encoding!.tooltip).toBeDefined(); + expect(result.mark).toHaveProperty('tooltip', true); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts new file mode 100644 index 000000000000..6110b3f75ed9 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVegaLiteEncoding } from './components/encoding'; +import { buildMarkForVegaLite, VegaMarkType } from './components/mark'; +import { buildTooltip } from './components/tooltip'; +import { buildLegend } from './components/legend'; +import { StyleState } from '../../application/utils/state_management'; +import { VegaLiteSpec, AxisFormats } from './utils/types'; +import { mapChartTypeToVegaType } from './utils/helpers'; + +/** + * Builds a Vega-Lite specification based on the provided data, visual configuration, and style. + * + * @param {any} data - The data configuration, normally including axis formats and transformed data. + * @param {any} visConfig - The visual configuration including dimensions and display options. + * @param {StyleState} style - The StyleState defined in style slice. + * @returns {VegaLiteSpec} The complete Vega-Lite specification. + */ +export const generateVegaLiteSpec = ( + data: any, + visConfig: any, + style: StyleState +): VegaLiteSpec => { + const { dimensions, addLegend, legendPosition, addTooltip } = visConfig; + const { type } = style; + const vegaType = mapChartTypeToVegaType(type) as VegaMarkType; + const { + xAxisFormat, + xAxisLabel, + yAxisFormat, + yAxisLabel, + zAxisFormat, + series: transformedData, + } = data; + + const formats: AxisFormats = { + xAxisFormat, + xAxisLabel, + yAxisFormat, + yAxisLabel, + zAxisFormat, + }; + + // Build the base Vega-Lite specification + const baseSpec: VegaLiteSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: transformedData }, + mark: buildMarkForVegaLite(vegaType), + encoding: buildVegaLiteEncoding(dimensions, formats), + }; + + // Handle special case for line charts with dot size + if (dimensions.z) { + baseSpec.layer = [ + { + mark: { type: 'line', point: false }, + encoding: buildVegaLiteEncoding(dimensions, formats), + }, + { + mark: { type: 'point', filled: true }, + encoding: { + ...buildVegaLiteEncoding(dimensions, formats), + size: { + field: 'z', + type: 'quantitative', + legend: null, + }, + }, + }, + ]; + } + + // Add legend if specified + if (addLegend) { + baseSpec.config = { + legend: buildLegend(legendPosition), + }; + } + + // Add tooltip if specified + if (addTooltip) { + buildTooltip(baseSpec, dimensions, formats); + } + + return baseSpec; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts new file mode 100644 index 000000000000..3197e28ae40a --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateVegaSpec } from './vega_spec_builder'; + +describe('generateVegaSpec', () => { + const baseData = { + xAxisFormat: { id: 'date' }, + xAxisLabel: 'Date', + yAxisFormat: { id: 'number' }, + yAxisLabel: 'Value', + series: [{ x: '2023-01-01', y: 10, series: 'A' }], + }; + + const baseVisConfig = { + dimensions: { + x: { format: { id: 'date' } }, + y: [{ format: { id: 'number' } }], + }, + addLegend: true, + legendPosition: 'right', + }; + + it('should generate a basic Vega specification', () => { + const style = { type: 'line' }; + + const result = generateVegaSpec(baseData, baseVisConfig, style); + + expect(result.$schema).toBe('https://vega.github.io/schema/vega/v5.json'); + expect(result.data).toBeDefined(); + expect(result.scales).toBeDefined(); + expect(result.marks).toBeDefined(); + expect(result.legends).toBeDefined(); + }); + + it('should handle area charts', () => { + const style = { type: 'area' }; + + const result = generateVegaSpec(baseData, baseVisConfig, style); + + expect(result.data).toBeDefined(); + expect(result.data?.some((d) => d.name === 'stacked')).toBe(true); + expect(result.marks?.[0]?.type).toBe('group'); + }); + + it('should add legend when specified', () => { + const style = { type: 'line' }; + + const result = generateVegaSpec(baseData, baseVisConfig, style); + + expect(result.legends).toBeDefined(); + expect(result.legends?.[0]?.orient).toBe('right'); + }); + + it('should not add legend when not specified', () => { + const visConfigNoLegend = { + ...baseVisConfig, + addLegend: false, + }; + const style = { type: 'line' }; + + const result = generateVegaSpec(baseData, visConfigNoLegend, style); + + expect(result.legends).toBeUndefined(); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts new file mode 100644 index 000000000000..90181a8e5513 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildMarkForVega, VegaMarkType } from './components/mark'; +import { buildLegend } from './components/legend'; +import { VegaSpec, AxisFormats } from './utils/types'; +import { StyleState } from '../../application/utils/state_management'; +import { mapChartTypeToVegaType } from './utils/helpers'; + +/** + * Builds a Vega specification based on the provided data, visual configuration, and style. + * + * @param {object} data - The data object containing series and axis information. + * @param {any} visConfig - The visual configuration settings. + * @param {StyleState} style - The style configuration for the visualization. + * @returns {VegaSpec} The complete Vega specification. + */ +export const generateVegaSpec = (data: any, visConfig: any, style: StyleState): VegaSpec => { + const { dimensions, addLegend, legendPosition } = visConfig; + const { type } = style; + const vegaType = mapChartTypeToVegaType(type) as VegaMarkType; + const { + xAxisFormat, + xAxisLabel, + yAxisFormat, + yAxisLabel, + zAxisFormat, + series: transformedData, + } = data; + + const formats: AxisFormats = { + xAxisFormat, + xAxisLabel, + yAxisFormat, + yAxisLabel, + zAxisFormat, + }; + + const spec: VegaSpec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + padding: 5, + data: [ + { + name: 'source', + values: transformedData, + }, + { + name: 'splits', + source: 'source', + transform: [ + { + type: 'aggregate', + groupby: ['split'], + }, + ], + }, + ], + signals: [ + { name: 'splitCount', update: 'length(data("splits"))' }, + { name: 'chartWidth', update: 'width / splitCount - 10' }, + ], + scales: [ + { + name: 'splitScale', + type: 'band', + domain: { data: type === 'area' ? 'stacked' : 'splits', field: 'split' }, + range: 'width', + padding: 0.1, + }, + { + name: 'color', + type: 'ordinal', + domain: { data: 'source', field: 'series' }, + range: 'category', + }, + ], + layout: { + columns: { signal: 'splitCount' }, + padding: { row: 40, column: 20 }, + }, + marks: [buildMarkForVega(vegaType, dimensions, formats)], + }; + + // Special case 1: Handle dot aggregation for line chart + if (dimensions.z) { + spec.scales!.push({ + name: 'size', + type: 'sqrt', + domain: { data: 'source', field: 'z' }, + range: [{ signal: '2' }, { signal: 'width * height / 500' }], + }); + } + + // Special case 2: Add stack transform for area charts + if (type === 'area') { + spec.data.push({ + name: 'stacked', + source: 'source', + transform: [ + { + type: 'stack', + groupby: ['split', 'x'], + sort: { field: 'series' }, + field: 'y', + }, + ], + }); + } + + // Add legend if specified + if (addLegend) { + spec.legends = [buildLegend(legendPosition, true)]; + } + + return spec; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts new file mode 100644 index 000000000000..b1ea168e8279 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { StyleState } from '../../application/utils/state_management'; +import { flattenDataHandler } from './utils/helpers'; +import { generateVegaLiteSpec } from './vega_lite_spec_builder'; +import { generateVegaSpec } from './vega_spec_builder'; +import { VegaLiteSpec, VegaSpec } from './utils/types'; + +/** + * Builds a Vega or Vega-Lite specification based on the provided context, visual configuration, and style. + * + * @param {any} context - The context data for the visualization. + * @param {any} visConfig - The visual configuration settings. + * @param {StyleState} style - The style configuration for the visualization. + * @returns {VegaLiteSpec | VegaSpec} The complete Vega or Vega-Lite specification. + */ +export const createVegaSpec = ( + context: any, + visConfig: any, + style: StyleState +): VegaLiteSpec | VegaSpec => { + const { dimensions } = visConfig; + + // Transform the data using the flattenDataHandler + const transformedData = flattenDataHandler(context, dimensions, 'series'); + + // Determine whether to use Vega or Vega-Lite based on the presence of split dimensions + // TODO: Summarize the cases to use Vega. Change this to a better determine function. + if (dimensions.splitRow || dimensions.splitColumn) { + // Use Vega for more complex, split visualizations + return generateVegaSpec(transformedData, visConfig, style); + } else { + // Use Vega-Lite for simpler visualizations + return generateVegaLiteSpec(transformedData, visConfig, style); + } +}; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.test.ts b/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.test.ts new file mode 100644 index 000000000000..4a0db43133f9 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toExpression } from './to_expression'; +import * as expressionHelpers from '../../common/expression_helpers'; +import * as vegaSpecFactory from '../../vega/vega_spec_factory'; +import * as expressionHelper from '../../vega/utils/expression_helper'; +import * as createVis from '../common/create_vis'; +import * as visualizationsPublic from '../../../../../visualizations/public'; +import * as expressionsPublic from '../../../../../expressions/public'; + +jest.mock('../../common/expression_helpers'); +jest.mock('../../vega/vega_spec_factory'); +jest.mock('../../vega/utils/expression_helper'); +jest.mock('../common/create_vis'); +jest.mock('../../../../../visualizations/public'); +jest.mock('../../../../../expressions/public'); + +jest.mock('../../../plugin_services', () => ({ + getSearchService: jest.fn(() => ({ + aggs: { + createAggConfigs: jest.fn(), + }, + })), + getTimeFilter: jest.fn(() => ({ + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + })), +})); + +describe('area/to_expression.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate vislib expression for area chart when useVega is false', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'area' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + x: [{ label: 'X-Axis' }], + y: [{ label: 'Y-Axis' }], + }); + + const mockExpression = { + toString: jest.fn().mockReturnValue('vislib | mockFn1 | mockFn2'), + }; + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue(mockExpression); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + const result = await toExpression(mockState as any, mockSearchContext as any, false); + + expect(result).toBe('vislib | mockFn1 | mockFn2'); + expect(expressionsPublic.buildExpression).toHaveBeenCalledWith( + expect.arrayContaining(['mockFn1', 'mockFn2', expect.any(Object)]) + ); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith( + 'vislib', + expect.objectContaining({ + type: 'area', + visConfig: expect.stringContaining('"addTimeMarker":false'), + }) + ); + }); + + it('should generate vega expression for area chart when useVega is true', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'area' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + (expressionHelper.executeExpression as jest.Mock).mockResolvedValue({ someData: 'value' }); + (vegaSpecFactory.createVegaSpec as jest.Mock).mockReturnValue({ spec: 'mockVegaSpec' }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + x: [{ label: 'X-Axis' }], + y: [{ label: 'Y-Axis' }], + }); + + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue('rawData | mockFn1 | mockFn2'), + }); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + (visualizationsPublic.buildPipeline as jest.Mock).mockResolvedValue('vega | mockVegaSpec'); + + const result = await toExpression(mockState as any, mockSearchContext as any, true); + + expect(result).toBe('vega | mockVegaSpec'); + expect(vegaSpecFactory.createVegaSpec).toHaveBeenCalledWith( + { someData: 'value' }, + expect.objectContaining({ + addLegend: true, + addTooltip: true, + legendPosition: 'right', + addTimeMarker: false, + }), + mockState.style + ); + expect(visualizationsPublic.buildPipeline).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.ts b/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.ts index 4481dce24619..7ab8c76848e4 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/area/to_expression.ts @@ -13,17 +13,24 @@ import { AreaOptionsDefaults } from './area_vis_type'; import { getAggExpressionFunctions } from '../../common/expression_helpers'; import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; import { createVis } from '../common/create_vis'; +import { buildPipeline } from '../../../../../visualizations/public'; +import { createVegaSpec } from '../../vega/vega_spec_factory'; +import { executeExpression } from '../../vega/utils/expression_helper'; export const toExpression = async ( { style: styleState, visualization }: VislibRootState, - searchContext: IExpressionLoaderParams['searchContext'] + searchContext: IExpressionLoaderParams['searchContext'], + useVega: boolean ) => { - const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( - visualization + const { expressionFns, aggConfigs, indexPattern } = await getAggExpressionFunctions( + visualization, + styleState, + useVega, + searchContext ); const { addLegend, addTooltip, legendPosition, type } = styleState; - const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + const vis = await createVis(type, aggConfigs, indexPattern, searchContext); const params = getPipelineParams(); const dimensions = await buildVislibDimensions(vis, params); @@ -39,10 +46,35 @@ export const toExpression = async ( valueAxes, }; - const vislib = buildExpressionFunction('vislib', { - type, - visConfig: JSON.stringify(visConfig), - }); + if (useVega === true) { + const rawDataFn = buildExpressionFunction('rawData', {}); + const dataExpression = buildExpression([...expressionFns, rawDataFn]).toString(); - return buildExpression([...expressionFns, vislib]).toString(); + // Execute the expression to get the raw data + const rawData = await executeExpression(dataExpression, searchContext); + + const vegaSpec = createVegaSpec(rawData, visConfig, styleState); + + const visVega = await createVis('vega', aggConfigs, indexPattern, searchContext); + visVega.params = { + spec: JSON.stringify(vegaSpec), + }; + + const vegaExpression = await buildPipeline(visVega, { + timefilter: params.timefilter, + timeRange: params.timeRange, + abortSignal: undefined, + visLayers: undefined, + visAugmenterConfig: undefined, + }); + + return vegaExpression; + } else { + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + + return buildExpression([...expressionFns, vislib]).toString(); + } }; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/common/create_vis.ts b/src/plugins/vis_builder/public/visualizations/vislib/common/create_vis.ts index 209f4a2a50b8..421fb56bdcad 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/common/create_vis.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/common/create_vis.ts @@ -3,15 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AggConfigs, IndexPattern, TimeRange } from '../../../../../data/public'; +import { AggConfigs, IndexPattern } from '../../../../../data/public'; import { Vis } from '../../../../../visualizations/public'; import { getSearchService } from '../../../plugin_services'; +import { IExpressionLoaderParams } from '../../../../../expressions/public'; export const createVis = async ( type: string, aggConfigs: AggConfigs, indexPattern: IndexPattern, - timeRange?: TimeRange + searchContext: IExpressionLoaderParams['searchContext'] ) => { const vis = new Vis(type); vis.data.aggs = aggConfigs; @@ -20,7 +21,7 @@ export const createVis = async ( const responseAggs = vis.data.aggs.getResponseAggs().filter((agg) => agg.enabled); responseAggs.forEach((agg) => { - agg.params.timeRange = timeRange; + agg.params.timeRange = searchContext?.timeRange; }); return vis; }; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.test.ts b/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.test.ts new file mode 100644 index 000000000000..08d343da5f34 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toExpression } from './to_expression'; +import * as expressionHelpers from '../../common/expression_helpers'; +import * as vegaSpecFactory from '../../vega/vega_spec_factory'; +import * as expressionHelper from '../../vega/utils/expression_helper'; +import * as createVis from '../common/create_vis'; +import * as visualizationsPublic from '../../../../../visualizations/public'; +import * as expressionsPublic from '../../../../../expressions/public'; + +jest.mock('../../common/expression_helpers'); +jest.mock('../../vega/vega_spec_factory'); +jest.mock('../../vega/utils/expression_helper'); +jest.mock('../common/create_vis'); +jest.mock('../../../../../visualizations/public'); +jest.mock('../../../../../expressions/public'); + +jest.mock('../../../plugin_services', () => ({ + getSearchService: jest.fn(() => ({ + aggs: { + createAggConfigs: jest.fn(), + }, + })), + getTimeFilter: jest.fn(() => ({ + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + })), +})); + +describe('histogram/to_expression.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate vislib expression for histogram when useVega is false', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'histogram' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + x: [{ label: 'X-Axis' }], + y: [{ label: 'Y-Axis' }], + }); + + const mockExpression = { + toString: jest.fn().mockReturnValue('vislib | mockFn1 | mockFn2'), + }; + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue(mockExpression); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + const result = await toExpression(mockState as any, mockSearchContext as any, false); + + expect(result).toBe('vislib | mockFn1 | mockFn2'); + expect(expressionsPublic.buildExpression).toHaveBeenCalledWith( + expect.arrayContaining(['mockFn1', 'mockFn2', expect.any(Object)]) + ); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith( + 'vislib', + expect.objectContaining({ + type: 'histogram', + visConfig: expect.stringContaining('"addTimeMarker":false'), + }) + ); + }); + + it('should generate vega expression for histogram when useVega is true', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'histogram' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + (expressionHelper.executeExpression as jest.Mock).mockResolvedValue({ someData: 'value' }); + (vegaSpecFactory.createVegaSpec as jest.Mock).mockReturnValue({ spec: 'mockVegaSpec' }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + x: [{ label: 'X-Axis' }], + y: [{ label: 'Y-Axis' }], + }); + + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue('rawData | mockFn1 | mockFn2'), + }); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + (visualizationsPublic.buildPipeline as jest.Mock).mockResolvedValue('vega | mockVegaSpec'); + + const result = await toExpression(mockState as any, mockSearchContext as any, true); + + expect(result).toBe('vega | mockVegaSpec'); + expect(vegaSpecFactory.createVegaSpec).toHaveBeenCalledWith( + { someData: 'value' }, + expect.objectContaining({ + addLegend: true, + addTooltip: true, + legendPosition: 'right', + addTimeMarker: false, + }), + mockState.style + ); + expect(visualizationsPublic.buildPipeline).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.ts b/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.ts index 2f75ed326913..417497590228 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/histogram/to_expression.ts @@ -13,17 +13,24 @@ import { HistogramOptionsDefaults } from './histogram_vis_type'; import { getAggExpressionFunctions } from '../../common/expression_helpers'; import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; import { createVis } from '../common/create_vis'; +import { buildPipeline } from '../../../../../visualizations/public'; +import { createVegaSpec } from '../../vega/vega_spec_factory'; +import { executeExpression } from '../../vega/utils/expression_helper'; export const toExpression = async ( { style: styleState, visualization }: VislibRootState, - searchContext: IExpressionLoaderParams['searchContext'] + searchContext: IExpressionLoaderParams['searchContext'], + useVega: boolean ) => { - const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( - visualization + const { expressionFns, aggConfigs, indexPattern } = await getAggExpressionFunctions( + visualization, + styleState, + useVega, + searchContext ); const { addLegend, addTooltip, legendPosition, type } = styleState; - const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + const vis = await createVis(type, aggConfigs, indexPattern, searchContext); const params = getPipelineParams(); const dimensions = await buildVislibDimensions(vis, params); @@ -39,10 +46,35 @@ export const toExpression = async ( valueAxes, }; - const vislib = buildExpressionFunction('vislib', { - type, - visConfig: JSON.stringify(visConfig), - }); + if (useVega === true) { + const rawDataFn = buildExpressionFunction('rawData', {}); + const dataExpression = buildExpression([...expressionFns, rawDataFn]).toString(); - return buildExpression([...expressionFns, vislib]).toString(); + // Execute the expression to get the raw data + const rawData = await executeExpression(dataExpression, searchContext); + + const vegaSpec = createVegaSpec(rawData, visConfig, styleState); + + const visVega = await createVis('vega', aggConfigs, indexPattern, searchContext); + visVega.params = { + spec: JSON.stringify(vegaSpec), + }; + + const vegaExpression = await buildPipeline(visVega, { + timefilter: params.timefilter, + timeRange: params.timeRange, + abortSignal: undefined, + visLayers: undefined, + visAugmenterConfig: undefined, + }); + + return vegaExpression; + } else { + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + + return buildExpression([...expressionFns, vislib]).toString(); + } }; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.test.ts b/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.test.ts new file mode 100644 index 000000000000..9136db6ae1fe --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.test.ts @@ -0,0 +1,187 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toExpression } from './to_expression'; +import * as expressionHelpers from '../../common/expression_helpers'; +import * as vegaSpecFactory from '../../vega/vega_spec_factory'; +import * as expressionHelper from '../../vega/utils/expression_helper'; +import * as createVis from '../common/create_vis'; +import * as visualizationsPublic from '../../../../../visualizations/public'; +import * as expressionsPublic from '../../../../../expressions/public'; + +jest.mock('../../common/expression_helpers'); +jest.mock('../../vega/vega_spec_factory'); +jest.mock('../../vega/utils/expression_helper'); +jest.mock('../common/create_vis'); +jest.mock('../../../../../visualizations/public'); +jest.mock('../../../../../expressions/public'); + +jest.mock('../../../plugin_services', () => ({ + getSearchService: jest.fn(() => ({ + aggs: { + createAggConfigs: jest.fn(), + }, + })), + getTimeFilter: jest.fn(() => ({ + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + })), +})); + +describe('line/to_expression.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate vislib expression when useVega is false', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'line' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + y: [{ label: 'Y-Axis' }], + }); + + const mockExpression = { + toString: jest.fn().mockReturnValue('vislib | mockFn1 | mockFn2'), + }; + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue(mockExpression); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + const result = await toExpression(mockState as any, mockSearchContext as any, false); + + expect(result).toBe('vislib | mockFn1 | mockFn2'); + expect(expressionsPublic.buildExpression).toHaveBeenCalledWith( + expect.arrayContaining(['mockFn1', 'mockFn2', expect.any(Object)]) + ); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith( + 'vislib', + expect.any(Object) + ); + }); + + it('should generate vega expression when useVega is true', async () => { + const mockState = { + style: { addLegend: true, addTooltip: true, legendPosition: 'right', type: 'line' }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + (expressionHelper.executeExpression as jest.Mock).mockResolvedValue({ someData: 'value' }); + (vegaSpecFactory.createVegaSpec as jest.Mock).mockReturnValue({ spec: 'mockVegaSpec' }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + y: [{ label: 'Y-Axis' }], + }); + + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue('rawData | mockFn1 | mockFn2'), + }); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + (visualizationsPublic.buildPipeline as jest.Mock).mockResolvedValue('vega | mockVegaSpec'); + + const result = await toExpression(mockState as any, mockSearchContext as any, true); + + expect(result).toBe('vega | mockVegaSpec'); + expect(vegaSpecFactory.createVegaSpec).toHaveBeenCalledWith( + { someData: 'value' }, + expect.objectContaining({ + addLegend: true, + addTooltip: true, + legendPosition: 'right', + }), + mockState.style + ); + expect(visualizationsPublic.buildPipeline).toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const mockState = { + style: { type: 'line' }, + visualization: {}, + }; + const mockSearchContext = {}; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockRejectedValue( + new Error('Mock error') + ); + + await expect(toExpression(mockState as any, mockSearchContext as any, false)).rejects.toThrow( + 'Mock error' + ); + }); + + it('should handle different buildVislibDimensions structures', async () => { + const mockState = { + style: { type: 'line' }, + visualization: {}, + }; + const mockSearchContext = {}; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: [], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.buildVislibDimensions as jest.Mock).mockResolvedValue({ + x: [{ label: 'X-Axis' }], + y: [{ label: 'Y-Axis1' }, { label: 'Y-Axis2' }], + }); + + const mockExpression = { + toString: jest.fn().mockReturnValue('vislib | multipleYAxes'), + }; + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue(mockExpression); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + const result = await toExpression(mockState as any, mockSearchContext as any, false); + + expect(result).toBe('vislib | multipleYAxes'); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith( + 'vislib', + expect.objectContaining({ + visConfig: expect.stringContaining('"valueAxes":[{"id":"ValueAxis-1"'), + }) + ); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.ts b/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.ts index 41a6d505c724..eebede030477 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/line/to_expression.ts @@ -13,17 +13,24 @@ import { LineOptionsDefaults } from './line_vis_type'; import { getAggExpressionFunctions } from '../../common/expression_helpers'; import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; import { createVis } from '../common/create_vis'; +import { buildPipeline } from '../../../../../visualizations/public'; +import { createVegaSpec } from '../../vega/vega_spec_factory'; +import { executeExpression } from '../../vega/utils/expression_helper'; export const toExpression = async ( { style: styleState, visualization }: VislibRootState, - searchContext: IExpressionLoaderParams['searchContext'] + searchContext: IExpressionLoaderParams['searchContext'], + useVega: boolean ) => { - const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( - visualization + const { expressionFns, aggConfigs, indexPattern } = await getAggExpressionFunctions( + visualization, + styleState, + useVega, + searchContext ); const { addLegend, addTooltip, legendPosition, type } = styleState; - const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + const vis = await createVis(type, aggConfigs, indexPattern, searchContext); const params = getPipelineParams(); const dimensions = await buildVislibDimensions(vis, params); @@ -39,10 +46,34 @@ export const toExpression = async ( valueAxes, }; - const vislib = buildExpressionFunction('vislib', { - type, - visConfig: JSON.stringify(visConfig), - }); + if (useVega === true) { + const rawDataFn = buildExpressionFunction('rawData', {}); + const dataExpression = buildExpression([...expressionFns, rawDataFn]).toString(); - return buildExpression([...expressionFns, vislib]).toString(); + // Execute the expression to get the raw data + const rawData = await executeExpression(dataExpression, searchContext); + + const vegaSpec = createVegaSpec(rawData, visConfig, styleState); + + const visVega = await createVis('vega', aggConfigs, indexPattern, searchContext); + visVega.params = { + spec: JSON.stringify(vegaSpec), + }; + + const vegaExpression = await buildPipeline(visVega, { + timefilter: params.timefilter, + timeRange: params.timeRange, + abortSignal: undefined, + visLayers: undefined, + visAugmenterConfig: undefined, + }); + + return vegaExpression; + } else { + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + return buildExpression([...expressionFns, vislib]).toString(); + } }; diff --git a/src/plugins/vis_builder/server/plugin.ts b/src/plugins/vis_builder/server/plugin.ts index d250c21f14ad..4922ec95e4df 100644 --- a/src/plugins/vis_builder/server/plugin.ts +++ b/src/plugins/vis_builder/server/plugin.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; +import { schema } from '@osd/config-schema'; import { PluginInitializerContext, CoreSetup, @@ -14,6 +16,7 @@ import { import { VisBuilderPluginSetup, VisBuilderPluginStart } from './types'; import { capabilitiesProvider } from './capabilities_provider'; import { visBuilderSavedObjectType } from './saved_objects'; +import { VISBUILDER_ENABLE_VEGA_SETTING } from '../common/constants'; export class VisBuilderPlugin implements Plugin { private readonly logger: Logger; @@ -22,7 +25,7 @@ export class VisBuilderPlugin implements Plugin