Skip to content

Commit faaa45c

Browse files
[VisBuilder] Add Capability to generate dynamic vega (opensearch-project#7288)
* [VisBuilder] Add Capability to generate dynamic vega In this PR, we add the capability for Visbuilder to generate dynamic Vega and Vega-Lite specifications based on user settings and aggregation configurations. * developed functions buildVegaSpecViaVega and buildVegaSpecViaVegaLite that can create either Vega or Vega-Lite specifications depending on the complexity of the visualization. * added VegaSpec and VegaLiteSpec interfaces to provide better type checking * broken down the specification building into smaller, reusable components (like buildEncoding, buildMark, buildLegend, buildTooltip) to make the code more maintainable and easier to extend. * added flattenDataHandler to prepare and transform data for use in Vega visualizations Issue Resolve opensearch-project#7067 Signed-off-by: Anan Zhuang <ananzh@amazon.com> * fix PR comments * update file and functions names * fix type errors * fix area chart Signed-off-by: Anan Zhuang <ananzh@amazon.com> * add unit tests Signed-off-by: Anan Zhuang <ananzh@amazon.com> * enable embeddable for useVega Signed-off-by: Anan Zhuang <ananzh@amazon.com> * remove buildVegaScales due to split it to smaller modules Signed-off-by: Anan Zhuang <ananzh@amazon.com> * fix date for vega Signed-off-by: Anan Zhuang <ananzh@amazon.com> * fix test Signed-off-by: Anan Zhuang <ananzh@amazon.com> * Changeset file for PR opensearch-project#7288 created/updated --------- Signed-off-by: Anan Zhuang <ananzh@amazon.com> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent c8496f8 commit faaa45c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2346
-49
lines changed

changelogs/fragments/7288.yml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
feat:
2+
- [VisBuilder] Add Capability to generate dynamic vega ([#7288](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7288))

src/plugins/advanced_settings/public/management_app/lib/get_category_name.ts

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const names: Record<string, string> = {
4848
visualizations: i18n.translate('advancedSettings.categoryNames.visualizationsLabel', {
4949
defaultMessage: 'Visualizations',
5050
}),
51+
visbuilder: i18n.translate('advancedSettings.categoryNames.visbuilderLabel', {
52+
defaultMessage: 'VisBuilder',
53+
}),
5154
discover: i18n.translate('advancedSettings.categoryNames.discoverLabel', {
5255
defaultMessage: 'Discover',
5356
}),

src/plugins/expressions/public/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,4 @@ export {
134134
UnmappedTypeStrings,
135135
ExpressionValueRender as Render,
136136
} from '../common';
137+
export { getExpressionsService } from './services';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export const VISBUILDER_ENABLE_VEGA_SETTING = 'visbuilder:enableVega';

src/plugins/vis_builder/public/application/components/workspace.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { validateSchemaState, validateAggregations } from '../utils/validations'
1313
import { useTypedDispatch, useTypedSelector, setUIStateState } from '../utils/state_management';
1414
import { useAggs, useVisualizationType } from '../utils/use';
1515
import { PersistedState } from '../../../../visualizations/public';
16+
import { VISBUILDER_ENABLE_VEGA_SETTING } from '../../../common/constants';
1617

1718
import hand_field from '../../assets/hand_field.svg';
1819
import fields_bg from '../../assets/fields_bg.svg';
@@ -27,6 +28,7 @@ export const WorkspaceUI = () => {
2728
notifications: { toasts },
2829
data,
2930
uiActions,
31+
uiSettings,
3032
},
3133
} = useOpenSearchDashboards<VisBuilderServices>();
3234
const { toExpression, ui } = useVisualizationType();
@@ -37,6 +39,7 @@ export const WorkspaceUI = () => {
3739
filters: data.query.filterManager.getFilters(),
3840
timeRange: data.query.timefilter.timefilter.getTime(),
3941
});
42+
const useVega = uiSettings.get(VISBUILDER_ENABLE_VEGA_SETTING);
4043
const rootState = useTypedSelector((state) => state);
4144
const dispatch = useTypedDispatch();
4245
// Visualizations require the uiState object to persist even when the expression changes
@@ -81,12 +84,20 @@ export const WorkspaceUI = () => {
8184
return;
8285
}
8386

84-
const exp = await toExpression(rootState, searchContext);
87+
const exp = await toExpression(rootState, searchContext, useVega);
8588
setExpression(exp);
8689
}
8790

8891
loadExpression();
89-
}, [rootState, toExpression, toasts, ui.containerConfig.data.schemas, searchContext, aggConfigs]);
92+
}, [
93+
rootState,
94+
toExpression,
95+
toasts,
96+
ui.containerConfig.data.schemas,
97+
searchContext,
98+
aggConfigs,
99+
useVega,
100+
]);
90101

91102
useLayoutEffect(() => {
92103
const subscription = data.query.state$.subscribe(({ state }) => {

src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx

+12-5
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ import {
3434
getIndexPatterns,
3535
getTypeService,
3636
getUIActions,
37+
getUISettings,
3738
} from '../plugin_services';
3839
import { PersistedState, prepareJson } from '../../../visualizations/public';
3940
import { VisBuilderSavedVis } from '../saved_visualizations/transforms';
4041
import { handleVisEvent } from '../application/utils/handle_vis_event';
4142
import { VisBuilderEmbeddableFactoryDeps } from './vis_builder_embeddable_factory';
43+
import { VISBUILDER_ENABLE_VEGA_SETTING } from '../../common/constants';
4244

4345
// Apparently this needs to match the saved object type for the clone and replace panel actions to work
4446
export const VISBUILDER_EMBEDDABLE = VISBUILDER_SAVED_OBJECT;
@@ -150,11 +152,16 @@ export class VisBuilderEmbeddable extends Embeddable<VisBuilderInput, VisBuilder
150152

151153
if (!valid && errorMsg) throw new Error(errorMsg);
152154

153-
const exp = await toExpression(renderState, {
154-
filters: this.filters,
155-
query: this.query,
156-
timeRange: this.timeRange,
157-
});
155+
const useVega = getUISettings().get(VISBUILDER_ENABLE_VEGA_SETTING);
156+
const exp = await toExpression(
157+
renderState,
158+
{
159+
filters: this.filters,
160+
query: this.query,
161+
timeRange: this.timeRange,
162+
},
163+
useVega
164+
);
158165
return exp;
159166
} catch (error) {
160167
this.onContainerError(error as Error);

src/plugins/vis_builder/public/plugin.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { dataPluginMock } from '../../data/public/mocks';
99
import { embeddablePluginMock } from '../../embeddable/public/mocks';
1010
import { navigationPluginMock } from '../../navigation/public/mocks';
1111
import { visualizationsPluginMock } from '../../visualizations/public/mocks';
12+
import { expressionsPluginMock } from '../../expressions/public/mocks';
1213
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
1314
import { VisBuilderPlugin } from './plugin';
1415

@@ -29,6 +30,7 @@ describe('VisBuilderPlugin', () => {
2930
visualizations: visualizationsPluginMock.createSetupContract(),
3031
embeddable: embeddablePluginMock.createSetupContract(),
3132
data: dataPluginMock.createSetupContract(),
33+
expressions: expressionsPluginMock.createSetupContract(), // Add this line
3234
};
3335

3436
const setup = plugin.setup(coreSetup, setupDeps);
@@ -41,6 +43,7 @@ describe('VisBuilderPlugin', () => {
4143
aliasApp: PLUGIN_ID,
4244
})
4345
);
46+
expect(setupDeps.expressions.registerFunction).toHaveBeenCalled(); // Add this expectation
4447
});
4548
});
4649
});

src/plugins/vis_builder/public/plugin.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
withNotifyOnErrors,
5757
} from '../../opensearch_dashboards_utils/public';
5858
import { opensearchFilters } from '../../data/public';
59+
import { createRawDataVisFn } from './visualizations/vega/utils/expression_helper';
5960

6061
export class VisBuilderPlugin
6162
implements
@@ -74,7 +75,7 @@ export class VisBuilderPlugin
7475

7576
public setup(
7677
core: CoreSetup<VisBuilderPluginStartDependencies, VisBuilderStart>,
77-
{ embeddable, visualizations, data }: VisBuilderPluginSetupDependencies
78+
{ embeddable, visualizations, data, expressions: exp }: VisBuilderPluginSetupDependencies
7879
) {
7980
const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({
8081
baseUrl: core.http.basePath.prepend(`/app/${PLUGIN_ID}`),
@@ -107,6 +108,7 @@ export class VisBuilderPlugin
107108
// Register Default Visualizations
108109
const typeService = this.typeService;
109110
registerDefaultTypes(typeService.setup());
111+
exp.registerFunction(createRawDataVisFn());
110112

111113
// Register the plugin to core
112114
core.application.register({

src/plugins/vis_builder/public/services/type_service/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface VisualizationTypeOptions<T = any> {
3131
};
3232
readonly toExpression: (
3333
state: RenderState,
34-
searchContext: IExpressionLoaderParams['searchContext']
34+
searchContext: IExpressionLoaderParams['searchContext'],
35+
useVega: boolean
3536
) => Promise<string | undefined>;
3637
}

src/plugins/vis_builder/public/services/type_service/visualization_type.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export class VisualizationType implements IVisualizationType {
1818
public readonly ui: IVisualizationType['ui'];
1919
public readonly toExpression: (
2020
state: RenderState,
21-
searchContext: IExpressionLoaderParams['searchContext']
21+
searchContext: IExpressionLoaderParams['searchContext'],
22+
useVega: boolean
2223
) => Promise<string | undefined>;
2324

2425
constructor(options: VisualizationTypeOptions) {

src/plugins/vis_builder/public/types.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { SavedObject, SavedObjectsStart } from '../../saved_objects/public';
88
import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public';
99
import { DashboardStart } from '../../dashboard/public';
1010
import { VisualizationsSetup } from '../../visualizations/public';
11-
import { ExpressionsStart } from '../../expressions/public';
11+
import { ExpressionsStart, ExpressionsPublicPlugin } from '../../expressions/public';
1212
import { NavigationPublicPluginStart } from '../../navigation/public';
1313
import { DataPublicPluginStart } from '../../data/public';
1414
import { TypeServiceSetup, TypeServiceStart } from './services/type_service';
@@ -18,6 +18,7 @@ import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public';
1818
import { DataPublicPluginSetup } from '../../data/public';
1919
import { UiActionsStart } from '../../ui_actions/public';
2020
import { Capabilities } from '../../../core/public';
21+
import { IUiSettingsClient } from '../../../core/public';
2122

2223
export type VisBuilderSetup = TypeServiceSetup;
2324
export interface VisBuilderStart extends TypeServiceStart {
@@ -28,6 +29,7 @@ export interface VisBuilderPluginSetupDependencies {
2829
embeddable: EmbeddableSetup;
2930
visualizations: VisualizationsSetup;
3031
data: DataPublicPluginSetup;
32+
expressions: ReturnType<ExpressionsPublicPlugin['setup']>;
3133
}
3234
export interface VisBuilderPluginStartDependencies {
3335
embeddable: EmbeddableStart;
@@ -37,6 +39,7 @@ export interface VisBuilderPluginStartDependencies {
3739
dashboard: DashboardStart;
3840
expressions: ExpressionsStart;
3941
uiActions: UiActionsStart;
42+
uiSettings: IUiSettingsClient;
4043
}
4144

4245
export interface VisBuilderServices extends CoreStart {

src/plugins/vis_builder/public/visualizations/common/expression_helpers.ts

+17-6
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@ import { cloneDeep } from 'lodash';
77
import { OpenSearchaggsExpressionFunctionDefinition } from '../../../../data/public';
88
import { ExpressionFunctionOpenSearchDashboards } from '../../../../expressions';
99
import { buildExpressionFunction } from '../../../../expressions/public';
10-
import { VisualizationState } from '../../application/utils/state_management';
10+
import { VisualizationState, StyleState } from '../../application/utils/state_management';
1111
import { getSearchService, getIndexPatterns } from '../../plugin_services';
12-
import { StyleState } from '../../application/utils/state_management';
12+
import { IExpressionLoaderParams } from '../../../../expressions/public';
1313

1414
export const getAggExpressionFunctions = async (
1515
visualization: VisualizationState,
16-
style?: StyleState
16+
style?: StyleState,
17+
useVega: boolean = false,
18+
searchContext?: IExpressionLoaderParams['searchContext']
1719
) => {
1820
const { activeVisualization, indexPattern: indexId = '' } = visualization;
1921
const { aggConfigParams } = activeVisualization || {};
2022

2123
const indexPatternsService = getIndexPatterns();
2224
const indexPattern = await indexPatternsService.get(indexId);
23-
// aggConfigParams is the serealizeable aggConfigs that need to be reconstructed here using the agg servce
2425
const aggConfigs = getSearchService().aggs.createAggConfigs(
2526
indexPattern,
2627
cloneDeep(aggConfigParams)
@@ -31,7 +32,6 @@ export const getAggExpressionFunctions = async (
3132
{}
3233
);
3334

34-
// soon this becomes: const opensearchaggs = vis.data.aggs!.toExpressionAst();
3535
const opensearchaggs = buildExpressionFunction<OpenSearchaggsExpressionFunctionDefinition>(
3636
'opensearchaggs',
3737
{
@@ -43,9 +43,20 @@ export const getAggExpressionFunctions = async (
4343
}
4444
);
4545

46+
let expressionFns = [opensearchDashboards, opensearchaggs];
47+
48+
if (useVega === true && searchContext) {
49+
const opensearchDashboardsContext = buildExpressionFunction('opensearch_dashboards_context', {
50+
timeRange: JSON.stringify(searchContext.timeRange || {}),
51+
filters: JSON.stringify(searchContext.filters || []),
52+
query: JSON.stringify(searchContext.query || []),
53+
});
54+
expressionFns = [opensearchDashboards, opensearchDashboardsContext, opensearchaggs];
55+
}
56+
4657
return {
4758
aggConfigs,
4859
indexPattern,
49-
expressionFns: [opensearchDashboards, opensearchaggs],
60+
expressionFns,
5061
};
5162
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { buildAxes } from './axes';
7+
8+
describe('axes.ts', () => {
9+
describe('buildAxes', () => {
10+
it('should return correct axis configurations for date x-axis', () => {
11+
const dimensions = {
12+
x: { format: { id: 'date' } },
13+
y: [{ label: 'Y Axis' }],
14+
};
15+
const formats = {
16+
xAxisLabel: 'X Axis',
17+
yAxisLabel: 'Custom Y Axis',
18+
};
19+
20+
const result = buildAxes(dimensions, formats);
21+
22+
expect(result).toHaveLength(2);
23+
expect(result[0]).toEqual({
24+
orient: 'bottom',
25+
scale: 'x',
26+
labelAngle: -90,
27+
labelAlign: 'right',
28+
labelBaseline: 'middle',
29+
title: 'X Axis',
30+
format: '%Y-%m-%d %H:%M',
31+
});
32+
expect(result[1]).toEqual({
33+
orient: 'left',
34+
scale: 'y',
35+
title: 'Custom Y Axis',
36+
});
37+
});
38+
39+
it('should not add format when x is not date', () => {
40+
const dimensions = {
41+
x: { format: { id: 'number' } },
42+
y: [{ label: 'Y Axis' }],
43+
};
44+
const result = buildAxes(dimensions, 'X', 'Y');
45+
46+
expect(result[0]).not.toHaveProperty('format');
47+
});
48+
49+
it('should use default labels when not provided', () => {
50+
const dimensions = {
51+
x: {},
52+
y: [{ label: 'Default Y' }],
53+
};
54+
const result = buildAxes(dimensions, '', '');
55+
56+
expect(result[0].title).toBe('_all');
57+
expect(result[1].title).toBe('Default Y');
58+
});
59+
});
60+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { AxisFormats } from '../utils/types';
7+
8+
export interface AxisConfig {
9+
orient?: string;
10+
scale?: string;
11+
labelAngle?: number;
12+
labelAlign?: string;
13+
labelBaseline?: string;
14+
title: any;
15+
format?: string; // property for date format
16+
}
17+
18+
/**
19+
* Builds the axes configuration for a chart.
20+
*
21+
* Note: This axis configuration is currently tailored for specific use cases.
22+
* In the future, we plan to expand and generalize this function to accommodate
23+
* a wider range of chart types and axis configurations.
24+
* @param {any} dimensions - The dimensions of the data.
25+
* @param {AxisFormats} formats - The formatting information for axes.
26+
*/
27+
28+
export const buildAxes = (dimensions: any, formats: AxisFormats): AxisConfig[] => {
29+
const { xAxisLabel, yAxisLabel } = formats;
30+
const xAxis: AxisConfig = {
31+
orient: 'bottom',
32+
scale: 'x',
33+
labelAngle: -90,
34+
labelAlign: 'right',
35+
labelBaseline: 'middle',
36+
title: xAxisLabel || '_all',
37+
};
38+
39+
// Add date format if x dimension is a date type
40+
if (dimensions.x && dimensions.x.format && dimensions.x.format.id === 'date') {
41+
xAxis.format = '%Y-%m-%d %H:%M';
42+
}
43+
44+
const yAxis: AxisConfig = {
45+
orient: 'left',
46+
scale: 'y',
47+
title: yAxisLabel ? yAxisLabel : dimensions.y && dimensions.y[0] ? dimensions.y[0].label : '',
48+
};
49+
50+
return [xAxis, yAxis];
51+
};

0 commit comments

Comments
 (0)