diff --git a/src/plugins/vis_augmenter/README.md b/src/plugins/vis_augmenter/README.md index fb9d4fbdcaae..4ebe2e4b1d4b 100644 --- a/src/plugins/vis_augmenter/README.md +++ b/src/plugins/vis_augmenter/README.md @@ -1 +1 @@ -Contains interfaces and type definitions used for allowing external plugins to augment Visualizations. Registers the relevant saved object types and expression functions. +Contains interfaces, type definitions, helper functions, and services used for allowing external plugins to augment Visualizations. Registers the relevant saved object types and expression functions. diff --git a/src/plugins/vis_augmenter/opensearch_dashboards.json b/src/plugins/vis_augmenter/opensearch_dashboards.json index ac78ec9c0281..490c84f85371 100644 --- a/src/plugins/vis_augmenter/opensearch_dashboards.json +++ b/src/plugins/vis_augmenter/opensearch_dashboards.json @@ -3,5 +3,11 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "savedObjects", "opensearchDashboardsUtils", "expressions"] + "requiredPlugins": [ + "data", + "savedObjects", + "opensearchDashboardsUtils", + "expressions", + "visTypeVega" + ] } diff --git a/src/plugins/vis_augmenter/public/constants.ts b/src/plugins/vis_augmenter/public/constants.ts new file mode 100644 index 000000000000..880a9210c556 --- /dev/null +++ b/src/plugins/vis_augmenter/public/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const VIS_LAYER_COLUMN_TYPE = 'vis_layer'; +export const EVENT_COLOR = 'red'; diff --git a/src/plugins/vis_augmenter/public/index.ts b/src/plugins/vis_augmenter/public/index.ts index 1e4976d21709..d7346662a585 100644 --- a/src/plugins/vis_augmenter/public/index.ts +++ b/src/plugins/vis_augmenter/public/index.ts @@ -25,7 +25,13 @@ export { VisLayerFunctionDefinition, VisLayer, VisLayers, + VisLayerTypes, + PointInTimeEvent, + PointInTimeEventsVisLayer, + isPointInTimeEventsVisLayer, } from './types'; export * from './expressions'; export * from './utils'; +export * from './constants'; +export * from './vega'; diff --git a/src/plugins/vis_augmenter/public/test_constants.ts b/src/plugins/vis_augmenter/public/test_constants.ts new file mode 100644 index 000000000000..5f6dabb76161 --- /dev/null +++ b/src/plugins/vis_augmenter/public/test_constants.ts @@ -0,0 +1,487 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsDatatable } from '../../expressions/public'; +import { VIS_LAYER_COLUMN_TYPE, VisLayerTypes } from './'; + +const TEST_X_AXIS_ID = 'test-x-axis-id'; +const TEST_VALUE_AXIS_ID = 'test-value-axis-id'; +const TEST_X_AXIS_TITLE = 'time'; +const TEST_VALUE_AXIS_TITLE = 'avg value'; +const TEST_PLUGIN = 'test-plugin'; +const TEST_PLUGIN_RESOURCE_TYPE = 'test-resource-type'; +const TEST_PLUGIN_RESOURCE_ID = 'test-resource-id'; +const TEST_PLUGIN_RESOURCE_ID_2 = 'test-resource-id-2'; +const TEST_PLUGIN_RESOURCE_NAME = 'test-resource-name'; +const TEST_PLUGIN_RESOURCE_NAME_2 = 'test-resource-name-2'; +const TEST_PLUGIN_RESOURCE_PATH = `${TEST_PLUGIN}/${TEST_PLUGIN_RESOURCE_TYPE}/${TEST_PLUGIN_RESOURCE_ID}`; +const TEST_PLUGIN_RESOURCE_PATH_2 = `${TEST_PLUGIN}/${TEST_PLUGIN_RESOURCE_TYPE}/${TEST_PLUGIN_RESOURCE_ID_2}`; + +const TEST_VALUES_SINGLE_ROW_NO_VIS_LAYERS = [{ [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }]; + +const TEST_VALUES_SINGLE_ROW_SINGLE_VIS_LAYER = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_RESOURCE_ID]: 3 }, +]; + +const TEST_VALUES_ONLY_VIS_LAYERS = [ + { [TEST_X_AXIS_ID]: 0 }, + { [TEST_X_AXIS_ID]: 5, [TEST_PLUGIN_RESOURCE_ID]: 2 }, + { [TEST_X_AXIS_ID]: 10 }, + { [TEST_X_AXIS_ID]: 15 }, + { [TEST_X_AXIS_ID]: 20 }, + { [TEST_X_AXIS_ID]: 25 }, + { [TEST_X_AXIS_ID]: 30 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_RESOURCE_ID]: 1 }, + { [TEST_X_AXIS_ID]: 40 }, + { [TEST_X_AXIS_ID]: 45 }, + { [TEST_X_AXIS_ID]: 50 }, +]; + +const TEST_VALUES_NO_VIS_LAYERS = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 25 }, + { [TEST_X_AXIS_ID]: 30 }, + { [TEST_X_AXIS_ID]: 35 }, + { [TEST_X_AXIS_ID]: 40 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5 }, +]; + +const TEST_VALUES_SINGLE_VIS_LAYER = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 5, [TEST_VALUE_AXIS_ID]: 10, [TEST_PLUGIN_RESOURCE_ID]: 2 }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 25 }, + { [TEST_X_AXIS_ID]: 30 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_RESOURCE_ID]: 1 }, + { [TEST_X_AXIS_ID]: 40 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5 }, +]; + +const TEST_VALUES_MULTIPLE_VIS_LAYERS = [ + { [TEST_X_AXIS_ID]: 0, [TEST_VALUE_AXIS_ID]: 5 }, + { + [TEST_X_AXIS_ID]: 5, + [TEST_VALUE_AXIS_ID]: 10, + [TEST_PLUGIN_RESOURCE_ID]: 2, + [TEST_PLUGIN_RESOURCE_ID_2]: 1, + }, + { [TEST_X_AXIS_ID]: 10, [TEST_VALUE_AXIS_ID]: 6 }, + { [TEST_X_AXIS_ID]: 15, [TEST_VALUE_AXIS_ID]: 4, [TEST_PLUGIN_RESOURCE_ID_2]: 1 }, + { [TEST_X_AXIS_ID]: 20, [TEST_VALUE_AXIS_ID]: 5 }, + { [TEST_X_AXIS_ID]: 25 }, + { [TEST_X_AXIS_ID]: 30 }, + { [TEST_X_AXIS_ID]: 35, [TEST_PLUGIN_RESOURCE_ID]: 1 }, + { [TEST_X_AXIS_ID]: 40 }, + { [TEST_X_AXIS_ID]: 45, [TEST_VALUE_AXIS_ID]: 3 }, + { [TEST_X_AXIS_ID]: 50, [TEST_VALUE_AXIS_ID]: 5, [TEST_PLUGIN_RESOURCE_ID_2]: 2 }, +]; + +export const TEST_COLUMNS_NO_VIS_LAYERS = [ + { + id: TEST_X_AXIS_ID, + name: TEST_X_AXIS_TITLE, + }, + { + id: TEST_VALUE_AXIS_ID, + name: TEST_VALUE_AXIS_TITLE, + }, +]; + +export const TEST_COLUMNS_SINGLE_VIS_LAYER = [ + ...TEST_COLUMNS_NO_VIS_LAYERS, + { + id: TEST_PLUGIN_RESOURCE_ID, + name: TEST_PLUGIN_RESOURCE_NAME, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }, +]; + +export const TEST_COLUMNS_MULTIPLE_VIS_LAYERS = [ + ...TEST_COLUMNS_SINGLE_VIS_LAYER, + { + id: TEST_PLUGIN_RESOURCE_ID_2, + name: TEST_PLUGIN_RESOURCE_NAME_2, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }, +]; + +export const TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_NO_VIS_LAYERS, + rows: TEST_VALUES_SINGLE_ROW_NO_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_ROW_SINGLE_VIS_LAYER, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_ONLY_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_ONLY_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_NO_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_NO_VIS_LAYERS, + rows: TEST_VALUES_NO_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY = { + ...TEST_DATATABLE_NO_VIS_LAYERS, + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_SINGLE_VIS_LAYER = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_SINGLE_VIS_LAYER, + rows: TEST_VALUES_SINGLE_VIS_LAYER, +} as OpenSearchDashboardsDatatable; + +export const TEST_DATATABLE_MULTIPLE_VIS_LAYERS = { + type: 'opensearch_dashboards_datatable', + columns: TEST_COLUMNS_MULTIPLE_VIS_LAYERS, + rows: TEST_VALUES_MULTIPLE_VIS_LAYERS, +} as OpenSearchDashboardsDatatable; + +const TEST_BASE_CONFIG = { + view: { stroke: null }, + concat: { spacing: 0 }, + legend: { orient: 'right' }, + kibana: { hideWarnings: true }, +}; + +const TEST_BASE_VIS_LAYER = { + mark: { type: 'line', interpolate: 'linear', strokeWidth: 2, point: true }, + encoding: { + x: { + axis: { title: TEST_X_AXIS_TITLE, grid: false }, + field: TEST_X_AXIS_ID, + type: 'temporal', + }, + y: { + axis: { + title: TEST_VALUE_AXIS_TITLE, + grid: '', + orient: 'left', + labels: true, + labelAngle: 0, + }, + field: TEST_VALUE_AXIS_ID, + type: 'quantitative', + }, + tooltip: [ + { field: TEST_X_AXIS_ID, type: 'temporal', title: TEST_VALUE_AXIS_TITLE }, + { field: TEST_VALUE_AXIS_ID, type: 'quantitative', title: TEST_VALUE_AXIS_TITLE }, + ], + color: { datum: TEST_VALUE_AXIS_TITLE }, + }, +}; + +export const TEST_SPEC_NO_VIS_LAYERS = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_NO_VIS_LAYERS, + }, + config: TEST_BASE_CONFIG, + layer: [TEST_BASE_VIS_LAYER], +}; + +export const TEST_SPEC_SINGLE_VIS_LAYER = { + ...TEST_SPEC_NO_VIS_LAYERS, + data: { + ...TEST_SPEC_NO_VIS_LAYERS.data, + values: TEST_VALUES_SINGLE_VIS_LAYER, + }, +}; + +export const TEST_SPEC_MULTIPLE_VIS_LAYERS = { + ...TEST_SPEC_NO_VIS_LAYERS, + data: { + ...TEST_SPEC_NO_VIS_LAYERS.data, + values: TEST_VALUES_MULTIPLE_VIS_LAYERS, + }, +}; + +export const TEST_DIMENSIONS = { + x: { + params: { + interval: 5, + bounds: { + min: 0, + max: 50, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_DIMENSIONS_SINGLE_ROW = { + x: { + params: { + interval: 5, + bounds: { + min: 0, + max: 0, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_DIMENSIONS_INVALID_BOUNDS = { + x: { + params: { + interval: 5, + bounds: { + min: 50, + max: 0, + }, + }, + label: TEST_X_AXIS_TITLE, + }, +}; + +export const TEST_VIS_LAYERS_SINGLE = [ + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID, + name: TEST_PLUGIN_RESOURCE_NAME, + urlPath: TEST_PLUGIN_RESOURCE_PATH, + }, + events: [ + { + timestamp: 4, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 6, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + { + timestamp: 35, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID, + }, + }, + ], + }, +]; + +export const TEST_VIS_LAYERS_MULTIPLE = [ + ...TEST_VIS_LAYERS_SINGLE, + { + originPlugin: TEST_PLUGIN, + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: TEST_PLUGIN_RESOURCE_TYPE, + id: TEST_PLUGIN_RESOURCE_ID_2, + name: TEST_PLUGIN_RESOURCE_NAME_2, + urlPath: TEST_PLUGIN_RESOURCE_PATH_2, + }, + events: [ + { + timestamp: 5, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 15, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 49, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + { + timestamp: 50, + metadata: { + pluginResourceId: TEST_PLUGIN_RESOURCE_ID_2, + }, + }, + ], + }, +]; + +const TEST_RULE_LAYER_SINGLE_VIS_LAYER = { + mark: { type: 'rule', color: 'red', opacity: 1 }, + transform: [{ filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` }], + encoding: { + x: { field: TEST_X_AXIS_ID, type: 'temporal' }, + opacity: { value: 0, condition: { empty: false, param: 'hover', value: 1 } }, + }, +}; + +const TEST_RULE_LAYER_MULTIPLE_VIS_LAYERS = { + ...TEST_RULE_LAYER_SINGLE_VIS_LAYER, + transform: [ + { + filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0 || datum['${TEST_PLUGIN_RESOURCE_ID_2}'] > 0`, + }, + ], +}; + +const TEST_EVENTS_LAYER_SINGLE_VIS_LAYER = { + height: 25, + mark: { + type: 'point', + shape: 'triangle-up', + color: 'red', + filled: true, + opacity: 1, + }, + transform: [{ filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0` }], + params: [{ name: 'hover', select: { type: 'point', on: 'mouseover' } }], + encoding: { + x: { + axis: { + title: TEST_X_AXIS_TITLE, + grid: false, + ticks: true, + orient: 'bottom', + domain: true, + }, + field: TEST_X_AXIS_ID, + type: 'temporal', + scale: { + domain: [ + { + year: 2022, + month: 'December', + date: 1, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }, + { + year: 2023, + month: 'March', + date: 2, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }, + ], + }, + }, + size: { condition: { empty: false, param: 'hover', value: 140 }, value: 100 }, + }, +}; + +const TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS = { + ...TEST_EVENTS_LAYER_SINGLE_VIS_LAYER, + transform: [ + { + filter: `datum['${TEST_PLUGIN_RESOURCE_ID}'] > 0 || datum['${TEST_PLUGIN_RESOURCE_ID_2}'] > 0`, + }, + ], +}; + +export const TEST_RESULT_SPEC_SINGLE_VIS_LAYER = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_SINGLE_VIS_LAYER, + }, + config: { + ...TEST_BASE_CONFIG, + kibana: { + ...TEST_BASE_CONFIG.kibana, + showEvents: true, + }, + }, + vconcat: [ + { + layer: [ + { + ...TEST_BASE_VIS_LAYER, + encoding: { + ...TEST_BASE_VIS_LAYER.encoding, + x: { + ...TEST_BASE_VIS_LAYER.encoding.x, + axis: { + title: null, + grid: false, + labels: false, + }, + }, + }, + }, + TEST_RULE_LAYER_SINGLE_VIS_LAYER, + ], + }, + TEST_EVENTS_LAYER_SINGLE_VIS_LAYER, + ], +}; + +export const TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY = { + ...TEST_RESULT_SPEC_SINGLE_VIS_LAYER, + data: { + values: TEST_VALUES_NO_VIS_LAYERS, + }, +}; + +export const TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: TEST_VALUES_MULTIPLE_VIS_LAYERS, + }, + config: { + ...TEST_BASE_CONFIG, + kibana: { + ...TEST_BASE_CONFIG.kibana, + showEvents: true, + }, + }, + vconcat: [ + { + layer: [ + { + ...TEST_BASE_VIS_LAYER, + encoding: { + ...TEST_BASE_VIS_LAYER.encoding, + x: { + ...TEST_BASE_VIS_LAYER.encoding.x, + axis: { + title: null, + grid: false, + labels: false, + }, + }, + }, + }, + TEST_RULE_LAYER_MULTIPLE_VIS_LAYERS, + ], + }, + TEST_EVENTS_LAYER_MULTIPLE_VIS_LAYERS, + ], +}; diff --git a/src/plugins/vis_augmenter/public/vega/README.md b/src/plugins/vis_augmenter/public/vega/README.md new file mode 100644 index 000000000000..fef45af1777a --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/README.md @@ -0,0 +1 @@ +Contains the helper functions that are optionally used when rendering vega charts that are eligible for rendering with VisLayers. diff --git a/src/plugins/vis_augmenter/public/vega/helpers.test.ts b/src/plugins/vis_augmenter/public/vega/helpers.test.ts new file mode 100644 index 000000000000..95d58e739f41 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/helpers.test.ts @@ -0,0 +1,361 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { + OpenSearchDashboardsDatatable, + OpenSearchDashboardsDatatableColumn, +} from '../../../expressions/public'; +import { + enableEventsInConfig, + isVisLayerColumn, + generateVisLayerFilterString, + addMissingRowsToTableBounds, + addPointInTimeEventsLayersToTable, + addPointInTimeEventsLayersToSpec, +} from './helpers'; +import { VIS_LAYER_COLUMN_TYPE } from '../'; +import { + TEST_DATATABLE_MULTIPLE_VIS_LAYERS, + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DATATABLE_ONLY_VIS_LAYERS, + TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS, + TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER, + TEST_DATATABLE_SINGLE_VIS_LAYER, + TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY, + TEST_DIMENSIONS, + TEST_DIMENSIONS_INVALID_BOUNDS, + TEST_DIMENSIONS_SINGLE_ROW, + TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS, + TEST_RESULT_SPEC_SINGLE_VIS_LAYER, + TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY, + TEST_SPEC_MULTIPLE_VIS_LAYERS, + TEST_SPEC_NO_VIS_LAYERS, + TEST_SPEC_SINGLE_VIS_LAYER, + TEST_VIS_LAYERS_MULTIPLE, + TEST_VIS_LAYERS_SINGLE, +} from '../test_constants'; + +describe('helpers', function () { + describe('enableEventsInConfig()', function () { + it('updates config with undefined showEvents field', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + }, + }; + const updatedConfig = enableEventsInConfig(baseConfig); + // @ts-ignore + baseConfig.kibana.showEvents = true; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + it('updates config with false showEvents field', function () { + const baseConfig = { + kibana: { + hideWarnings: true, + showEvents: false, + }, + }; + const updatedConfig = enableEventsInConfig(baseConfig); + baseConfig.kibana.showEvents = true; + expect(updatedConfig).toStrictEqual(baseConfig); + }); + }); + + describe('isVisLayerColumn()', function () { + it('return false for column with invalid type', function () { + const column = { + id: 'test-id', + name: 'test-name', + meta: { + type: 'invalid-type', + }, + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(false); + }); + it('return false for column with no meta field', function () { + const column = { + id: 'test-id', + name: 'test-name', + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(false); + }); + it('return true for column with valid type', function () { + const column = { + id: 'test-id', + name: 'test-name', + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + } as OpenSearchDashboardsDatatableColumn; + expect(isVisLayerColumn(column)).toBe(true); + }); + }); + + describe('generateVisLayerFilterString()', function () { + it('empty array returns false', function () { + const visLayerColumnIds = [] as string[]; + const filterString = 'false'; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + it('array with one value returns correct filter string', function () { + const visLayerColumnIds = ['test-id-1']; + const filterString = `datum['test-id-1'] > 0`; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + it('array with multiple values returns correct filter string', function () { + const visLayerColumnIds = ['test-id-1', 'test-id-2']; + const filterString = `datum['test-id-1'] > 0 || datum['test-id-2'] > 0`; + expect(generateVisLayerFilterString(visLayerColumnIds)).toStrictEqual(filterString); + }); + }); + + describe('addMissingRowsToTableBounds()', function () { + const columnId = 'test-id'; + const columnName = 'test-name'; + const allRows = [ + { + [columnId]: 1, + }, + { + [columnId]: 2, + }, + { + [columnId]: 3, + }, + { + [columnId]: 4, + }, + { + [columnId]: 5, + }, + ]; + it('adds single row if start/end times are the same', function () { + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: [], + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 1, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: [allRows[0]], + }; + expect(result).toStrictEqual(expectedTable); + }); + it('adds all rows if there is none to begin with', function () { + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: [], + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + it('fill rows at beginning', function () { + const missingRows = cloneDeep(allRows); + missingRows.shift(); + missingRows.shift(); + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: missingRows, + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + it('fill rows at end', function () { + const missingRows = cloneDeep(allRows); + missingRows.pop(); + missingRows.pop(); + const datatable = { + type: 'opensearch_dashboards_datatable', + columns: [ + { + id: columnId, + name: columnName, + }, + ], + rows: missingRows, + } as OpenSearchDashboardsDatatable; + const dimensions = { + x: { + params: { + interval: 1, + bounds: { + min: 1, + max: 5, + }, + }, + label: columnName, + }, + }; + const result = addMissingRowsToTableBounds(datatable, dimensions); + const expectedTable = { + ...datatable, + rows: allRows, + }; + expect(result).toStrictEqual(expectedTable); + }); + }); + + describe('addPointInTimeEventsLayersToTable()', function () { + it('single vis layer is added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_VIS_LAYER); + }); + it('multiple vis layers are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_NO_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_MULTIPLE + ) + ).toStrictEqual(TEST_DATATABLE_MULTIPLE_VIS_LAYERS); + }); + it('invalid bounds adds no row data', function () { + expect( + addPointInTimeEventsLayersToTable( + { + ...TEST_DATATABLE_NO_VIS_LAYERS, + rows: [], + }, + TEST_DIMENSIONS_INVALID_BOUNDS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual({ + ...TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY, + rows: [], + }); + }); + it('vis layers with single row are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + TEST_DATATABLE_SINGLE_ROW_NO_VIS_LAYERS, + TEST_DIMENSIONS_SINGLE_ROW, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_SINGLE_ROW_SINGLE_VIS_LAYER); + }); + it('vis layers with no existing rows/data are added correctly', function () { + expect( + addPointInTimeEventsLayersToTable( + { + ...TEST_DATATABLE_NO_VIS_LAYERS, + rows: [], + }, + TEST_DIMENSIONS, + TEST_VIS_LAYERS_SINGLE + ) + ).toStrictEqual(TEST_DATATABLE_ONLY_VIS_LAYERS); + }); + }); + + describe('addPointInTimeEventsLayersToSpec()', function () { + it('spec with single time series produces correct spec', function () { + let expectedSpec = TEST_RESULT_SPEC_SINGLE_VIS_LAYER; + let returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_SINGLE_VIS_LAYER, + TEST_DIMENSIONS, + TEST_SPEC_SINGLE_VIS_LAYER + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + it('spec with multiple time series produces correct spec', function () { + let expectedSpec = TEST_RESULT_SPEC_MULTIPLE_VIS_LAYERS; + let returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_MULTIPLE_VIS_LAYERS, + TEST_DIMENSIONS, + TEST_SPEC_MULTIPLE_VIS_LAYERS + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + it('spec with vis layers with empty data produces correct spec', function () { + let expectedSpec = TEST_RESULT_SPEC_SINGLE_VIS_LAYER_EMPTY; + let returnSpec = addPointInTimeEventsLayersToSpec( + TEST_DATATABLE_SINGLE_VIS_LAYER_EMPTY, + TEST_DIMENSIONS, + TEST_SPEC_NO_VIS_LAYERS + ); + // deleting the scale fields since this contain generated + // fields based on timezone env it is run in + delete expectedSpec.vconcat[1].encoding.x.scale; + delete returnSpec.vconcat[1].encoding.x.scale; + expect(returnSpec).toEqual(expectedSpec); + }); + }); +}); diff --git a/src/plugins/vis_augmenter/public/vega/helpers.ts b/src/plugins/vis_augmenter/public/vega/helpers.ts new file mode 100644 index 000000000000..80d3ab932315 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/helpers.ts @@ -0,0 +1,322 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep, isEmpty, get } from 'lodash'; +import { OpenSearchDashboardsDatatable } from '../../../expressions/public'; +import { PointInTimeEvent, PointInTimeEventsVisLayer, VIS_LAYER_COLUMN_TYPE } from '../'; +import moment from 'moment'; +import { OpenSearchDashboardsDatatableColumn } from '../../../expressions/public'; +import { EVENT_COLOR } from '../'; + +const EVENT_MARK_SIZE = 100; +const EVENT_MARK_SIZE_ENLARGED = 140; +const EVENT_MARK_SHAPE = 'triangle-up'; +const EVENT_TIMELINE_HEIGHT = 25; + +export const enableEventsInConfig = (config: { kibana: {} }) => { + return { + ...config, + kibana: { + ...config.kibana, + showEvents: true, + }, + }; +}; + +// Get the first xaxis field as only 1 setup of X Axis will be supported and +// there won't be support for split series and split chart +export const getXAxisId = ( + dimensions: any, + columns: OpenSearchDashboardsDatatableColumn[] +): string => { + return columns.filter((column) => column.name === dimensions.x.label)[0].id; +}; + +export const isVisLayerColumn = (column: OpenSearchDashboardsDatatableColumn): boolean => { + return column.meta?.type === VIS_LAYER_COLUMN_TYPE; +}; + +/** + * For temporal domain ranges, there is a bug when passing timestamps in vega lite + * that is still present in the current libraries we are using when developing in a + * dev env. See https://github.com/vega/vega-lite/issues/6060 for bug details. + * So, we convert to a vega-lite Date Time object and pass that instead. + * See https://vega.github.io/vega-lite/docs/datetime.html for details on Date Time. + */ +const convertToDateTimeObj = (timestamp: number): any => { + const momentObj = moment(timestamp); + return { + year: Number(momentObj.format('YYYY')), + month: momentObj.format('MMMM'), + date: momentObj.date(), + hours: momentObj.hours(), + minutes: momentObj.minutes(), + seconds: momentObj.seconds(), + milliseconds: momentObj.milliseconds(), + }; +}; + +export const generateVisLayerFilterString = (visLayerColumnIds: string[]): string => { + if (!isEmpty(visLayerColumnIds)) { + const filterString = visLayerColumnIds.map( + (visLayerColumnId) => `datum['${visLayerColumnId}'] > 0` + ); + return filterString.join(' || '); + } else { + // if there is no VisLayers to display, then filter out everything by always returning false + return 'false'; + } +}; + +/** + * By default, the source datatable will not include rows with empty data. + * For handling events that may belong in missing buckets that are not yet + * created, we need to create them. For more details, see description in + * https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3145 + * + * Note that this may add buckets with start/end times out of the chart bounds. + * This is the current default behavior of histogram aggregations with intervals, + * in order for the bucket keys to have "clean" timestamp keys (e.g., 1/1 @ 12AM). + * For more details, see + * https://opensearch.org/docs/latest/opensearch/bucket-agg/#histogram-date_histogram + * + * Also note this is only adding empty buckets at the beginning/end of a table. We are + * not taking into account missing buckets within source datapoints. Because of this + * limitation, it is possible that charted events may not be put into the most precise + * bucket based on their raw event timestamps, if there is missing / sparse source data. + */ +export const addMissingRowsToTableBounds = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any +): OpenSearchDashboardsDatatable => { + const augmentedTable = cloneDeep(datatable); + const intervalMillis = moment.duration(dimensions.x.params.interval).asMilliseconds(); + const xAxisId = getXAxisId(dimensions, augmentedTable.columns); + const chartStartTime = new Date(dimensions.x.params.bounds.min).valueOf(); + const chartEndTime = new Date(dimensions.x.params.bounds.max).valueOf(); + + if (!isEmpty(augmentedTable.rows)) { + const dataStartTime = augmentedTable.rows[0][xAxisId] as number; + const dataEndTime = augmentedTable.rows[augmentedTable.rows.length - 1][xAxisId] as number; + + let curStartTime = dataStartTime; + while (curStartTime > chartStartTime) { + curStartTime -= intervalMillis; + augmentedTable.rows.unshift({ + [xAxisId]: curStartTime, + }); + } + + let curEndTime = dataEndTime; + while (curEndTime < chartEndTime) { + curEndTime += intervalMillis; + augmentedTable.rows.push({ + [xAxisId]: curEndTime, + }); + } + } else { + // if there's no existing rows, create them all + let curTime = chartStartTime; + while (curTime <= chartEndTime) { + augmentedTable.rows.push({ + [xAxisId]: curTime, + }); + curTime += intervalMillis; + } + } + return augmentedTable; +}; + +/** + * Adding events into the correct x-axis key (the time bucket) + * based on the table. As of now only results from + * PoinInTimeEventsVisLayers are supported + */ +export const addPointInTimeEventsLayersToTable = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any, + visLayers: PointInTimeEventsVisLayer[] +): OpenSearchDashboardsDatatable => { + const augmentedTable = addMissingRowsToTableBounds(datatable, dimensions); + const xAxisId = getXAxisId(dimensions, augmentedTable.columns); + + if (!isEmpty(visLayers)) { + visLayers.every((visLayer: PointInTimeEventsVisLayer) => { + const visLayerColumnId = `${visLayer.pluginResource.id}`; + const visLayerColumnName = `${visLayer.pluginResource.name}`; + augmentedTable.columns.push({ + id: visLayerColumnId, + name: visLayerColumnName, + meta: { + type: VIS_LAYER_COLUMN_TYPE, + }, + }); + + // special case: no rows + if (augmentedTable.rows.length === 0) { + return false; + } + + // special case: only one row - put all timestamps for this annotation + // in the one bucket and move on to the next layer + if (augmentedTable.rows.length === 1) { + augmentedTable.rows[0] = { + ...augmentedTable.rows[0], + [visLayerColumnId]: visLayer.events.length, + }; + return false; + } + + // Bin the timestamps to the closest x-axis key, adding + // an entry for this vis layer ID. Sorting the timestamps first + // so that we will only search a particular row value once, giving us + // O(n) time complexity where n = number of rows + // There could be some optimizations, such as binary search + dynamically + // changing the bounds, but performance benefits would be very minimal + // if any, given the upper bounds limit on n already due to chart constraints. + let rowIndex = 0; + const sortedTimestamps = visLayer.events + .map((event: PointInTimeEvent) => event.timestamp) + .sort((n1: number, n2: number) => n1 - n2) as number[]; + + if (sortedTimestamps.length > 0) { + sortedTimestamps.forEach((timestamp) => { + while (rowIndex < augmentedTable.rows.length - 1) { + const smallerVal = augmentedTable.rows[rowIndex][xAxisId] as number; + const higherVal = augmentedTable.rows[rowIndex + 1][xAxisId] as number; + let rowIndexToInsert: number; + + // timestamp is on the left bounds of the chart + if (timestamp <= smallerVal) { + rowIndexToInsert = rowIndex; + + // timestamp is in between the right 2 buckets. now need to determine which one it is closer to + } else if (timestamp <= higherVal) { + const smallerValDiff = Math.abs(timestamp - smallerVal); + const higherValDiff = Math.abs(timestamp - higherVal); + rowIndexToInsert = smallerValDiff <= higherValDiff ? rowIndex : rowIndex + 1; + } + + // timestamp is on the right bounds of the chart + else if (rowIndex + 1 === augmentedTable.rows.length - 1) { + rowIndexToInsert = rowIndex + 1; + // timestamp is still too small; traverse to next bucket + } else { + rowIndex += 1; + continue; + } + + // inserting the value. increment if the mapping/property already exists + augmentedTable.rows[rowIndexToInsert][visLayerColumnId] = + (get(augmentedTable.rows[rowIndexToInsert], visLayerColumnId, 0) as number) + 1; + break; + } + }); + } + return true; + }); + } + return augmentedTable; +}; + +/** + * Updating the vega lite spec to include layers and marks related to + * PointInTimeEventsVisLayers. It is assumed the datatable has already been + * augmented with columns and row data containing the vis layers. + */ +export const addPointInTimeEventsLayersToSpec = ( + datatable: OpenSearchDashboardsDatatable, + dimensions: any, + spec: object +): object => { + const newSpec = cloneDeep(spec) as any; + newSpec.config = enableEventsInConfig(newSpec.config); + + const xAxisId = getXAxisId(dimensions, datatable.columns); + const xAxisTitle = dimensions.x.label.replaceAll('"', ''); + const bucketStartTime = convertToDateTimeObj(datatable.rows[0][xAxisId] as number); + const bucketEndTime = convertToDateTimeObj( + datatable.rows[datatable.rows.length - 1][xAxisId] as number + ); + const visLayerColumns = datatable.columns.filter((column: OpenSearchDashboardsDatatableColumn) => + isVisLayerColumn(column) + ); + const visLayerColumnIds = visLayerColumns.map((column) => column.id); + const hoverParamName = 'hover'; + + // Hide x axes text on existing chart so they are only visible on the event chart + newSpec.layer.forEach((dataSeries: any) => { + if (get(dataSeries, 'encoding.x.axis', null) !== null) { + dataSeries.encoding.x.axis = { + ...dataSeries.encoding.x.axis, + labels: false, + title: null, + }; + } + }); + + // Add a rule to the existing layer for showing lines on the chart if a dot is hovered on + newSpec.layer.push({ + mark: { + type: 'rule', + color: EVENT_COLOR, + opacity: 1, + }, + transform: [{ filter: generateVisLayerFilterString(visLayerColumnIds) }], + encoding: { + x: { + field: xAxisId, + type: 'temporal', + }, + opacity: { + value: 0, + condition: { empty: false, param: hoverParamName, value: 1 }, + }, + }, + }); + + // Nesting layer into a vconcat field so we can append event chart. + newSpec.vconcat = [] as any[]; + newSpec.vconcat.push({ + layer: newSpec.layer, + }); + delete newSpec.layer; + + // Adding the event timeline chart + newSpec.vconcat.push({ + height: EVENT_TIMELINE_HEIGHT, + mark: { + type: 'point', + shape: EVENT_MARK_SHAPE, + color: EVENT_COLOR, + filled: true, + opacity: 1, + }, + transform: [{ filter: generateVisLayerFilterString(visLayerColumnIds) }], + params: [{ name: hoverParamName, select: { type: 'point', on: 'mouseover' } }], + encoding: { + x: { + axis: { + title: xAxisTitle, + grid: false, + ticks: true, + orient: 'bottom', + domain: true, + }, + field: xAxisId, + type: 'temporal', + scale: { + domain: [bucketStartTime, bucketEndTime], + }, + }, + size: { + condition: { empty: false, param: hoverParamName, value: EVENT_MARK_SIZE_ENLARGED }, + value: EVENT_MARK_SIZE, + }, + }, + }); + + return newSpec; +}; diff --git a/src/plugins/vis_augmenter/public/vega/index.ts b/src/plugins/vis_augmenter/public/vega/index.ts new file mode 100644 index 000000000000..0e8ad44d2bd7 --- /dev/null +++ b/src/plugins/vis_augmenter/public/vega/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './helpers'; diff --git a/src/plugins/vis_type_vega/opensearch_dashboards.json b/src/plugins/vis_type_vega/opensearch_dashboards.json index ca4d7020c2fa..17aee4a97232 100644 --- a/src/plugins/vis_type_vega/opensearch_dashboards.json +++ b/src/plugins/vis_type_vega/opensearch_dashboards.json @@ -4,6 +4,11 @@ "server": true, "ui": true, "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions", "inspector"], - "optionalPlugins": ["home","usageCollection"], - "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "visDefaultEditor"] + "optionalPlugins": ["home", "usageCollection"], + "requiredBundles": [ + "opensearchDashboardsUtils", + "opensearchDashboardsReact", + "visDefaultEditor", + "visAugmenter" + ] } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index d1d5712ec7cc..6fe72e19e55c 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -92,6 +92,7 @@ export class VegaParser { getServiceSettings: () => Promise; filters: Bool; timeCache: TimeCache; + showEvents: boolean; constructor( spec: VegaSpec | string, @@ -102,6 +103,7 @@ export class VegaParser { ) { this.spec = spec as VegaSpec; this.hideWarnings = false; + this.showEvents = false; this.error = undefined; this.warnings = []; @@ -158,6 +160,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never this._config = this._parseConfig(); this.hideWarnings = !!this._config.hideWarnings; + this.showEvents = !!this._config.showEvents; this.useMap = this._config.type === 'map'; this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas'; this.tooltips = this._parseTooltips(); @@ -190,6 +193,15 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never contains: 'padding', }; + // If showEvents is true, it means we are showing a base vis + event vis. + // Because this will be using a vconcat spec, we can autosize the width + // via fit-x. Note the regular 'fit' (to autosize width + height) does not work here. + // See limitations: https://vega.github.io/vega-lite/docs/size.html#limitations + const showEventsAutosize = { + type: 'fit-x', + contains: 'padding', + }; + let autosize = this.spec.autosize; let useResize = true; @@ -224,6 +236,10 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never autosize = defaultAutosize; } + if (this.showEvents) { + autosize = showEventsAutosize; + } + if ( useResize && ((this.spec.width && this.spec.width !== 'container') || @@ -243,7 +259,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never ); } - if (useResize) { + if (useResize && !this.showEvents) { this.spec.width = 'container'; this.spec.height = 'container'; } diff --git a/src/plugins/vis_type_vega/public/expressions/helpers.test.js b/src/plugins/vis_type_vega/public/expressions/helpers.test.js new file mode 100644 index 000000000000..03e7325b5c30 --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/helpers.test.js @@ -0,0 +1,199 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildLayerMark, + buildXAxis, + buildYAxis, + cleanString, + createSpecFromDatatable, + formatDatatable, + setupConfig, +} from './helpers'; + +describe('helpers', function () { + describe('cleanString()', function () { + it('string should not contain "', function () { + const dirtyString = '"someString"'; + expect(cleanString(dirtyString)).toBe('someString'); + }); + }); + + describe('setupConfig()', function () { + it('check all legend positions', function () { + const baseConfig = { + view: { + stroke: null, + }, + concat: { + spacing: 0, + }, + legend: { + orient: null, + }, + kibana: { + hideWarnings: true, + }, + }; + const positions = ['top', 'right', 'left', 'bottom']; + positions.forEach((position) => { + const visParams = { legendPosition: position }; + baseConfig.legend.orient = position; + expect(setupConfig(visParams)).toStrictEqual(baseConfig); + }); + }); + }); + + describe('buildLayerMark()', function () { + const types = ['line', 'area', 'histogram']; + const interpolates = ['linear', 'cardinal', 'step-after']; + const strokeWidths = [-1, 0, 1, 2, 3, 4]; + const showCircles = [false, true]; + + it('check each mark possible value', function () { + const mark = { + type: null, + interpolate: null, + strokeWidth: null, + point: null, + }; + types.forEach((type) => { + mark.type = type; + interpolates.forEach((interpolate) => { + mark.interpolate = interpolate; + strokeWidths.forEach((strokeWidth) => { + mark.strokeWidth = strokeWidth; + showCircles.forEach((showCircle) => { + mark.point = showCircle; + const param = { + type: type, + interpolate: interpolate, + lineWidth: strokeWidth, + showCircles: showCircle, + }; + expect(buildLayerMark(param)).toStrictEqual(mark); + }); + }); + }); + }); + }); + }); + + describe('buildXAxis()', function () { + it('build different XAxis', function () { + const xAxisTitle = 'someTitle'; + const xAxisId = 'someId'; + [true, false].forEach((enableGrid) => { + const visParams = { grid: { categoryLines: enableGrid } }; + const vegaXAxis = { + axis: { + title: xAxisTitle, + grid: enableGrid, + }, + field: xAxisId, + type: 'temporal', + }; + expect(buildXAxis(xAxisTitle, xAxisId, visParams)).toStrictEqual(vegaXAxis); + }); + }); + }); + + describe('buildYAxis()', function () { + it('build different YAxis', function () { + const valueAxis = { + id: 'someId', + labels: { + rotate: 75, + show: false, + }, + position: 'left', + title: { + text: 'someText', + }, + }; + const column = { name: 'columnName', id: 'columnId' }; + const visParams = { grid: { valueAxis: true } }; + const vegaYAxis = { + axis: { + title: 'someText', + grid: true, + orient: 'left', + labels: false, + labelAngle: 75, + }, + field: 'columnId', + type: 'quantitative', + }; + expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); + + valueAxis.title.text = '""'; + vegaYAxis.axis.title = 'columnName'; + expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); + }); + }); + + describe('createSpecFromDatatable()', function () { + it('build simple line chart"', function () { + const datatable = + '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44},{"col-0-2":1672300800000,"col-1-1":150},{"col-0-2":1672387200000,"col-1-1":154},{"col-0-2":1672473600000,"col-1-1":144},{"col-0-2":1672560000000,"col-1-1":133},{"col-0-2":1672646400000,"col-1-1":149},{"col-0-2":1672732800000,"col-1-1":152},{"col-0-2":1672819200000,"col-1-1":144},{"col-0-2":1672905600000,"col-1-1":166},{"col-0-2":1672992000000,"col-1-1":151},{"col-0-2":1673078400000,"col-1-1":143},{"col-0-2":1673164800000,"col-1-1":148},{"col-0-2":1673251200000,"col-1-1":146},{"col-0-2":1673337600000,"col-1-1":137},{"col-0-2":1673424000000,"col-1-1":152},{"col-0-2":1673510400000,"col-1-1":152},{"col-0-2":1673596800000,"col-1-1":151},{"col-0-2":1673683200000,"col-1-1":157},{"col-0-2":1673769600000,"col-1-1":151},{"col-0-2":1673856000000,"col-1-1":152},{"col-0-2":1673942400000,"col-1-1":142},{"col-0-2":1674028800000,"col-1-1":151},{"col-0-2":1674115200000,"col-1-1":163},{"col-0-2":1674201600000,"col-1-1":156},{"col-0-2":1674288000000,"col-1-1":153},{"col-0-2":1674374400000,"col-1-1":162},{"col-0-2":1674460800000,"col-1-1":152},{"col-0-2":1674547200000,"col-1-1":159},{"col-0-2":1674633600000,"col-1-1":165},{"col-0-2":1674720000000,"col-1-1":153},{"col-0-2":1674806400000,"col-1-1":149},{"col-0-2":1674892800000,"col-1-1":94}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}}]}'; + const visParams = + '{"addLegend":true,"addTimeMarker":false,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false},"labels":{},"legendPosition":"right","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":false,"style":"full","value":10,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":0,"show":true,"truncate":100},"name":"LeftAxis-1","position":"left","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; + const dimensions = + '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-18T00:14:09.617Z","max":"2023-02-16T00:14:09.617Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; + const spec = + '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-2":1672214400000,"col-1-1":44},{"col-0-2":1672300800000,"col-1-1":150},{"col-0-2":1672387200000,"col-1-1":154},{"col-0-2":1672473600000,"col-1-1":144},{"col-0-2":1672560000000,"col-1-1":133},{"col-0-2":1672646400000,"col-1-1":149},{"col-0-2":1672732800000,"col-1-1":152},{"col-0-2":1672819200000,"col-1-1":144},{"col-0-2":1672905600000,"col-1-1":166},{"col-0-2":1672992000000,"col-1-1":151},{"col-0-2":1673078400000,"col-1-1":143},{"col-0-2":1673164800000,"col-1-1":148},{"col-0-2":1673251200000,"col-1-1":146},{"col-0-2":1673337600000,"col-1-1":137},{"col-0-2":1673424000000,"col-1-1":152},{"col-0-2":1673510400000,"col-1-1":152},{"col-0-2":1673596800000,"col-1-1":151},{"col-0-2":1673683200000,"col-1-1":157},{"col-0-2":1673769600000,"col-1-1":151},{"col-0-2":1673856000000,"col-1-1":152},{"col-0-2":1673942400000,"col-1-1":142},{"col-0-2":1674028800000,"col-1-1":151},{"col-0-2":1674115200000,"col-1-1":163},{"col-0-2":1674201600000,"col-1-1":156},{"col-0-2":1674288000000,"col-1-1":153},{"col-0-2":1674374400000,"col-1-1":162},{"col-0-2":1674460800000,"col-1-1":152},{"col-0-2":1674547200000,"col-1-1":159},{"col-0-2":1674633600000,"col-1-1":165},{"col-0-2":1674720000000,"col-1-1":153},{"col-0-2":1674806400000,"col-1-1":149},{"col-0-2":1674892800000,"col-1-1":94}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"right"},"kibana":{"hideWarnings":true}},"layer":[{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal"},"y":{"axis":{"title":"Count","orient":"left","labels":true,"labelAngle":0},"field":"col-1-1","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-1-1","type":"quantitative","title":"Count"}],"color":{"datum":"Count"}}}]}'; + + expect( + JSON.stringify( + createSpecFromDatatable( + formatDatatable(JSON.parse(datatable)), + JSON.parse(visParams), + JSON.parse(dimensions) + ) + ) + ).toBe(spec); + }); + + it('build empty chart if no x-axis is defined"', function () { + const datatable = + '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-1":4675}],"columns":[{"id":"col-0-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}}]}'; + const visParams = + '{"type":"line","grid":{"categoryLines":false},"categoryAxes":[{"id":"CategoryAxis-1","type":"category","position":"bottom","show":true,"style":{},"scale":{"type":"linear"},"labels":{"show":true,"filter":true,"truncate":100},"title":{}}],"valueAxes":[{"id":"ValueAxis-1","name":"LeftAxis-1","type":"value","position":"left","show":true,"style":{},"scale":{"type":"linear","mode":"normal"},"labels":{"show":true,"rotate":0,"filter":false,"truncate":100},"title":{"text":"Count"}}],"seriesParams":[{"show":true,"type":"line","mode":"normal","data":{"label":"Count","id":"1"},"valueAxis":"ValueAxis-1","drawLinesBetweenPoints":true,"lineWidth":2,"interpolate":"linear","showCircles":true}],"addTooltip":true,"addLegend":true,"legendPosition":"right","times":[],"addTimeMarker":false,"labels":{},"thresholdLine":{"show":false,"value":10,"width":1,"style":"full","color":"#E7664C"}}'; + const dimensions = + '{"x":null,"y":[{"accessor":0,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; + const spec = + '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-1":4675}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"right"},"kibana":{"hideWarnings":true}},"layer":[]}'; + expect( + JSON.stringify( + createSpecFromDatatable( + formatDatatable(JSON.parse(datatable)), + JSON.parse(visParams), + JSON.parse(dimensions) + ) + ) + ).toBe(spec); + }); + + it('build complicated line chart"', function () { + const datatable = + '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44,"col-2-3":60.9375},{"col-0-2":1672300800000,"col-1-1":150,"col-2-3":82.5},{"col-0-2":1672387200000,"col-1-1":154,"col-2-3":79.5},{"col-0-2":1672473600000,"col-1-1":144,"col-2-3":75.875},{"col-0-2":1672560000000,"col-1-1":133,"col-2-3":259.25},{"col-0-2":1672646400000,"col-1-1":149,"col-2-3":90},{"col-0-2":1672732800000,"col-1-1":152,"col-2-3":79.0625},{"col-0-2":1672819200000,"col-1-1":144,"col-2-3":82.5},{"col-0-2":1672905600000,"col-1-1":166,"col-2-3":85.25},{"col-0-2":1672992000000,"col-1-1":151,"col-2-3":92},{"col-0-2":1673078400000,"col-1-1":143,"col-2-3":90.75},{"col-0-2":1673164800000,"col-1-1":148,"col-2-3":92},{"col-0-2":1673251200000,"col-1-1":146,"col-2-3":83.25},{"col-0-2":1673337600000,"col-1-1":137,"col-2-3":98},{"col-0-2":1673424000000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673510400000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673596800000,"col-1-1":151,"col-2-3":87.4375},{"col-0-2":1673683200000,"col-1-1":157,"col-2-3":63.75},{"col-0-2":1673769600000,"col-1-1":151,"col-2-3":81.5625},{"col-0-2":1673856000000,"col-1-1":152,"col-2-3":100.6875},{"col-0-2":1673942400000,"col-1-1":142,"col-2-3":98},{"col-0-2":1674028800000,"col-1-1":151,"col-2-3":100.8125},{"col-0-2":1674115200000,"col-1-1":163,"col-2-3":83.6875},{"col-0-2":1674201600000,"col-1-1":156,"col-2-3":85.8125},{"col-0-2":1674288000000,"col-1-1":153,"col-2-3":98},{"col-0-2":1674374400000,"col-1-1":162,"col-2-3":75.9375},{"col-0-2":1674460800000,"col-1-1":152,"col-2-3":113.375},{"col-0-2":1674547200000,"col-1-1":159,"col-2-3":73.625},{"col-0-2":1674633600000,"col-1-1":165,"col-2-3":72.8125},{"col-0-2":1674720000000,"col-1-1":153,"col-2-3":113.375},{"col-0-2":1674806400000,"col-1-1":149,"col-2-3":82.5},{"col-0-2":1674892800000,"col-1-1":94,"col-2-3":54}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}},{"id":"col-2-3","name":"Max products.min_price","meta":{"type":"max","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"products.min_price"}}}]}'; + const visParams = + '{"addLegend":true,"addTimeMarker":true,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false,"valueAxis":"ValueAxis-1"},"labels":{},"legendPosition":"bottom","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"},{"data":{"id":"3","label":"Max products.min_price"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":true,"style":"dashed","value":100,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":75,"show":true,"truncate":100},"name":"RightAxis-1","position":"right","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; + const dimensions = + '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-19T03:26:04.730Z","max":"2023-02-17T03:26:04.730Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"},{"accessor":2,"format":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5603","pathname":"/rao/app/visualize","basePath":"/rao"}}},"params":{},"label":"Max products.min_price","aggType":"max"}]}'; + const spec = + '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-2":1672214400000,"col-1-1":44,"col-2-3":60.9375},{"col-0-2":1672300800000,"col-1-1":150,"col-2-3":82.5},{"col-0-2":1672387200000,"col-1-1":154,"col-2-3":79.5},{"col-0-2":1672473600000,"col-1-1":144,"col-2-3":75.875},{"col-0-2":1672560000000,"col-1-1":133,"col-2-3":259.25},{"col-0-2":1672646400000,"col-1-1":149,"col-2-3":90},{"col-0-2":1672732800000,"col-1-1":152,"col-2-3":79.0625},{"col-0-2":1672819200000,"col-1-1":144,"col-2-3":82.5},{"col-0-2":1672905600000,"col-1-1":166,"col-2-3":85.25},{"col-0-2":1672992000000,"col-1-1":151,"col-2-3":92},{"col-0-2":1673078400000,"col-1-1":143,"col-2-3":90.75},{"col-0-2":1673164800000,"col-1-1":148,"col-2-3":92},{"col-0-2":1673251200000,"col-1-1":146,"col-2-3":83.25},{"col-0-2":1673337600000,"col-1-1":137,"col-2-3":98},{"col-0-2":1673424000000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673510400000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673596800000,"col-1-1":151,"col-2-3":87.4375},{"col-0-2":1673683200000,"col-1-1":157,"col-2-3":63.75},{"col-0-2":1673769600000,"col-1-1":151,"col-2-3":81.5625},{"col-0-2":1673856000000,"col-1-1":152,"col-2-3":100.6875},{"col-0-2":1673942400000,"col-1-1":142,"col-2-3":98},{"col-0-2":1674028800000,"col-1-1":151,"col-2-3":100.8125},{"col-0-2":1674115200000,"col-1-1":163,"col-2-3":83.6875},{"col-0-2":1674201600000,"col-1-1":156,"col-2-3":85.8125},{"col-0-2":1674288000000,"col-1-1":153,"col-2-3":98},{"col-0-2":1674374400000,"col-1-1":162,"col-2-3":75.9375},{"col-0-2":1674460800000,"col-1-1":152,"col-2-3":113.375},{"col-0-2":1674547200000,"col-1-1":159,"col-2-3":73.625},{"col-0-2":1674633600000,"col-1-1":165,"col-2-3":72.8125},{"col-0-2":1674720000000,"col-1-1":153,"col-2-3":113.375},{"col-0-2":1674806400000,"col-1-1":149,"col-2-3":82.5},{"col-0-2":1674892800000,"col-1-1":94,"col-2-3":54}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"bottom"},"kibana":{"hideWarnings":true}},"layer":[{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal"},"y":{"axis":{"title":"Count","grid":"ValueAxis-1","orient":"right","labels":true,"labelAngle":75},"field":"col-1-1","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-1-1","type":"quantitative","title":"Count"}],"color":{"datum":"Count"}}},{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal"},"y":{"axis":{"title":"Count","grid":"ValueAxis-1","orient":"right","labels":true,"labelAngle":75},"field":"col-2-3","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-2-3","type":"quantitative","title":"Max products.min_price"}],"color":{"datum":"Max products.min_price"}}},{"mark":"rule","encoding":{"x":{"type":"temporal","field":"now_field"},"color":{"value":"red"},"size":{"value":1}}},{"mark":{"type":"rule","color":"#E7664C","strokeDash":[8,8]},"encoding":{"y":{"datum":100}}}],"transform":[{"calculate":"now()","as":"now_field"}]}'; + expect( + JSON.stringify( + createSpecFromDatatable( + formatDatatable(JSON.parse(datatable)), + JSON.parse(visParams), + JSON.parse(dimensions) + ) + ) + ).toBe(spec); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/expressions/helpers.ts b/src/plugins/vis_type_vega/public/expressions/helpers.ts new file mode 100644 index 000000000000..ca31367bf119 --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/helpers.ts @@ -0,0 +1,238 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + OpenSearchDashboardsDatatable, + OpenSearchDashboardsDatatableColumn, +} from '../../../expressions/public'; +import { VislibDimensions, VisParams } from '../../../visualizations/public'; +import { isVisLayerColumn } from '../../../vis_augmenter/public'; + +// TODO: move this to the visualization plugin that has VisParams once all of these parameters have been better defined +interface ValueAxis { + id: string; + labels: { + filter: boolean; + rotate: number; + show: boolean; + truncate: number; + }; + name: string; + position: string; + scale: { + mode: string; + type: string; + }; + show: true; + style: any; + title: { + text: string; + }; + type: string; +} + +// Get the first xaxis field as only 1 setup of X Axis will be supported and +// there won't be support for split series and split chart +const getXAxisId = (dimensions: any, columns: OpenSearchDashboardsDatatableColumn[]): string => { + return columns.filter((column) => column.name === dimensions.x.label)[0].id; +}; + +export const cleanString = (rawString: string): string => { + return rawString.replaceAll('"', ''); +}; + +export const formatDatatable = ( + datatable: OpenSearchDashboardsDatatable +): OpenSearchDashboardsDatatable => { + datatable.columns.forEach((column) => { + // clean quotation marks from names in columns + column.name = cleanString(column.name); + }); + return datatable; +}; + +export const setupConfig = (visParams: VisParams) => { + const legendPosition = visParams.legendPosition; + return { + view: { + stroke: null, + }, + concat: { + spacing: 0, + }, + legend: { + orient: legendPosition, + }, + // This is parsed in the VegaParser and hides unnecessary warnings. + // For example, 'infinite extent' warnings that cover the chart + // when there is empty data for a time series + kibana: { + hideWarnings: true, + }, + }; +}; + +export const buildLayerMark = (seriesParams: { + type: string; + interpolate: string; + lineWidth: number; + showCircles: boolean; +}) => { + return { + // Possible types are: line, area, histogram. The eligibility checker will + // prevent area and histogram (though area works in vega-lite) + type: seriesParams.type, + // Possible types: linear, cardinal, step-after. All of these types work in vega-lite + interpolate: seriesParams.interpolate, + // The possible values is any number, which matches what vega-lite supports + strokeWidth: seriesParams.lineWidth, + // this corresponds to showing the dots in the visbuilder for each data point + point: seriesParams.showCircles, + }; +}; + +export const buildXAxis = (xAxisTitle: string, xAxisId: string, visParams: VisParams) => { + return { + axis: { + title: xAxisTitle, + grid: visParams.grid.categoryLines, + }, + field: xAxisId, + // Right now, the line charts can only set the x-axis value to be a date attribute, so + // this should always be of type temporal + type: 'temporal', + }; +}; + +export const buildYAxis = ( + column: OpenSearchDashboardsDatatableColumn, + valueAxis: ValueAxis, + visParams: VisParams +) => { + return { + axis: { + title: cleanString(valueAxis.title.text) || column.name, + grid: visParams.grid.valueAxis, + orient: valueAxis.position, + labels: valueAxis.labels.show, + labelAngle: valueAxis.labels.rotate, + }, + field: column.id, + type: 'quantitative', + }; +}; + +const isXAxisColumn = (column: OpenSearchDashboardsDatatableColumn): boolean => { + return column.meta?.aggConfigParams?.interval !== undefined; +}; + +export const createSpecFromDatatable = ( + datatable: OpenSearchDashboardsDatatable, + visParams: VisParams, + dimensions: VislibDimensions +): object => { + // TODO: we can try to use VegaSpec type but it is currently very outdated, where many + // of the fields and sub-fields don't have other optional params that we want for customizing. + // For now, we make this more loosely-typed by just specifying it as a generic object. + const spec = {} as any; + + spec.$schema = 'https://vega.github.io/schema/vega-lite/v5.json'; + spec.data = { + values: datatable.rows, + }; + spec.config = setupConfig(visParams); + + // Get the valueAxes data and generate a map to easily fetch the different valueAxes data + const valueAxis = new Map(); + visParams?.valueAxes?.forEach((yAxis: ValueAxis) => { + valueAxis.set(yAxis.id, yAxis); + }); + + spec.layer = [] as any[]; + + if (datatable.rows.length > 0 && dimensions.x !== null) { + const xAxisId = getXAxisId(dimensions, datatable.columns); + const xAxisTitle = cleanString(dimensions.x.label); + let seriesParamSkipCount = 0; + datatable.columns.forEach((column, index) => { + // Don't add a layer for x axis column + if (isXAxisColumn(column)) { + seriesParamSkipCount++; + // Don't add a layer for vis layer column + } else if (!isVisLayerColumn(column)) { + const currentSeriesParams = visParams.seriesParams[index - seriesParamSkipCount]; + const currentValueAxis = valueAxis.get(currentSeriesParams.valueAxis.toString()); + let tooltip: Array<{ field: string; type: string; title: string }> = []; + if (visParams.addTooltip) { + tooltip = [ + { field: xAxisId, type: 'temporal', title: xAxisTitle }, + { field: column.id, type: 'quantitative', title: column.name }, + ]; + } + spec.layer.push({ + mark: buildLayerMark(currentSeriesParams), + encoding: { + x: buildXAxis(xAxisTitle, xAxisId, visParams), + y: buildYAxis(column, currentValueAxis, visParams), + tooltip, + color: { + // This ensures all the different metrics have their own distinct and unique color + datum: column.name, + }, + }, + }); + } + }); + } + + if (visParams.addTimeMarker) { + spec.transform = [ + { + calculate: 'now()', + as: 'now_field', + }, + ]; + + spec.layer.push({ + mark: 'rule', + encoding: { + x: { + type: 'temporal', + field: 'now_field', + }, + // The time marker on vislib is red, so keeping this consistent + color: { + value: 'red', + }, + size: { + value: 1, + }, + }, + }); + } + + if (visParams.thresholdLine.show as boolean) { + const layer = { + mark: { + type: 'rule', + color: visParams.thresholdLine.color, + strokeDash: [1, 0], + }, + encoding: { + y: { + datum: visParams.thresholdLine.value, + }, + }, + }; + + // Can only support making a threshold line with full or dashed style, but not dot-dashed + // due to vega-lite limitations + if (visParams.thresholdLine.style !== 'full') { + layer.mark.strokeDash = [8, 8]; + } + spec.layer.push(layer); + } + return spec; +}; diff --git a/src/plugins/vis_type_vega/public/expressions/index.ts b/src/plugins/vis_type_vega/public/expressions/index.ts new file mode 100644 index 000000000000..ca40f92fdfbe --- /dev/null +++ b/src/plugins/vis_type_vega/public/expressions/index.ts @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { LineVegaSpecExpressionFunctionDefinition } from './line_vega_spec_fn'; +export { VegaExpressionFunctionDefinition } from './vega_fn'; diff --git a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.test.js b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.test.js deleted file mode 100644 index 2260c46b841a..000000000000 --- a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.test.js +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - buildLayerMark, - buildXAxis, - buildYAxis, - cleanString, - createSpecFromDatatable, - formatDataTable, - setupConfig, -} from './line_vega_spec_fn'; - -describe('cleanString()', function () { - it('string should not contain "', function () { - const dirtyString = '"someString"'; - expect(cleanString(dirtyString)).toBe('someString'); - }); -}); - -describe('setupConfig()', function () { - it('check all legend positions', function () { - const baseConfig = { - view: { - stroke: null, - }, - concat: { - spacing: 0, - }, - legend: { - orient: null, - }, - }; - const positions = ['top', 'right', 'left', 'bottom']; - positions.forEach((position) => { - const visParams = { legendPosition: position }; - baseConfig.legend.orient = position; - expect(setupConfig(visParams)).toStrictEqual(baseConfig); - }); - }); -}); - -describe('buildLayerMark()', function () { - const types = ['line', 'area', 'histogram']; - const interpolates = ['linear', 'cardinal', 'step-after']; - const strokeWidths = [-1, 0, 1, 2, 3, 4]; - const showCircles = [false, true]; - - it('check each mark possible value', function () { - const mark = { - type: null, - interpolate: null, - strokeWidth: null, - point: null, - }; - types.forEach((type) => { - mark.type = type; - interpolates.forEach((interpolate) => { - mark.interpolate = interpolate; - strokeWidths.forEach((strokeWidth) => { - mark.strokeWidth = strokeWidth; - showCircles.forEach((showCircle) => { - mark.point = showCircle; - const param = { - type: type, - interpolate: interpolate, - lineWidth: strokeWidth, - showCircles: showCircle, - }; - expect(buildLayerMark(param)).toStrictEqual(mark); - }); - }); - }); - }); - }); -}); - -describe('buildXAxis()', function () { - it('build different XAxis', function () { - const xAxisTitle = 'someTitle'; - const xAxisId = 'someId'; - const startTime = 1676596400; - const endTime = 1676796400; - [true, false].forEach((enableGrid) => { - const visParams = { grid: { categoryLines: enableGrid } }; - const vegaXAxis = { - axis: { - title: xAxisTitle, - grid: enableGrid, - }, - field: xAxisId, - type: 'temporal', - scale: { - domain: [startTime, endTime], - }, - }; - expect(buildXAxis(xAxisTitle, xAxisId, startTime, endTime, visParams)).toStrictEqual( - vegaXAxis - ); - }); - }); -}); - -describe('buildYAxis()', function () { - it('build different YAxis', function () { - const valueAxis = { - id: 'someId', - labels: { - rotate: 75, - show: false, - }, - position: 'left', - title: { - text: 'someText', - }, - }; - const column = { name: 'columnName', id: 'columnId' }; - const visParams = { grid: { valueAxis: true } }; - const vegaYAxis = { - axis: { - title: 'someText', - grid: true, - orient: 'left', - labels: false, - labelAngle: 75, - }, - field: 'columnId', - type: 'quantitative', - }; - expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); - - valueAxis.title.text = '""'; - vegaYAxis.axis.title = 'columnName'; - expect(buildYAxis(column, valueAxis, visParams)).toStrictEqual(vegaYAxis); - }); -}); - -describe('createSpecFromDatatable()', function () { - it('build simple line chart"', function () { - const datatable = - '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44},{"col-0-2":1672300800000,"col-1-1":150},{"col-0-2":1672387200000,"col-1-1":154},{"col-0-2":1672473600000,"col-1-1":144},{"col-0-2":1672560000000,"col-1-1":133},{"col-0-2":1672646400000,"col-1-1":149},{"col-0-2":1672732800000,"col-1-1":152},{"col-0-2":1672819200000,"col-1-1":144},{"col-0-2":1672905600000,"col-1-1":166},{"col-0-2":1672992000000,"col-1-1":151},{"col-0-2":1673078400000,"col-1-1":143},{"col-0-2":1673164800000,"col-1-1":148},{"col-0-2":1673251200000,"col-1-1":146},{"col-0-2":1673337600000,"col-1-1":137},{"col-0-2":1673424000000,"col-1-1":152},{"col-0-2":1673510400000,"col-1-1":152},{"col-0-2":1673596800000,"col-1-1":151},{"col-0-2":1673683200000,"col-1-1":157},{"col-0-2":1673769600000,"col-1-1":151},{"col-0-2":1673856000000,"col-1-1":152},{"col-0-2":1673942400000,"col-1-1":142},{"col-0-2":1674028800000,"col-1-1":151},{"col-0-2":1674115200000,"col-1-1":163},{"col-0-2":1674201600000,"col-1-1":156},{"col-0-2":1674288000000,"col-1-1":153},{"col-0-2":1674374400000,"col-1-1":162},{"col-0-2":1674460800000,"col-1-1":152},{"col-0-2":1674547200000,"col-1-1":159},{"col-0-2":1674633600000,"col-1-1":165},{"col-0-2":1674720000000,"col-1-1":153},{"col-0-2":1674806400000,"col-1-1":149},{"col-0-2":1674892800000,"col-1-1":94}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}}]}'; - const visParams = - '{"addLegend":true,"addTimeMarker":false,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false},"labels":{},"legendPosition":"right","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":false,"style":"full","value":10,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":0,"show":true,"truncate":100},"name":"LeftAxis-1","position":"left","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; - const dimensions = - '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-18T00:14:09.617Z","max":"2023-02-16T00:14:09.617Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; - const spec = - '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-2":1672214400000,"col-1-1":44},{"col-0-2":1672300800000,"col-1-1":150},{"col-0-2":1672387200000,"col-1-1":154},{"col-0-2":1672473600000,"col-1-1":144},{"col-0-2":1672560000000,"col-1-1":133},{"col-0-2":1672646400000,"col-1-1":149},{"col-0-2":1672732800000,"col-1-1":152},{"col-0-2":1672819200000,"col-1-1":144},{"col-0-2":1672905600000,"col-1-1":166},{"col-0-2":1672992000000,"col-1-1":151},{"col-0-2":1673078400000,"col-1-1":143},{"col-0-2":1673164800000,"col-1-1":148},{"col-0-2":1673251200000,"col-1-1":146},{"col-0-2":1673337600000,"col-1-1":137},{"col-0-2":1673424000000,"col-1-1":152},{"col-0-2":1673510400000,"col-1-1":152},{"col-0-2":1673596800000,"col-1-1":151},{"col-0-2":1673683200000,"col-1-1":157},{"col-0-2":1673769600000,"col-1-1":151},{"col-0-2":1673856000000,"col-1-1":152},{"col-0-2":1673942400000,"col-1-1":142},{"col-0-2":1674028800000,"col-1-1":151},{"col-0-2":1674115200000,"col-1-1":163},{"col-0-2":1674201600000,"col-1-1":156},{"col-0-2":1674288000000,"col-1-1":153},{"col-0-2":1674374400000,"col-1-1":162},{"col-0-2":1674460800000,"col-1-1":152},{"col-0-2":1674547200000,"col-1-1":159},{"col-0-2":1674633600000,"col-1-1":165},{"col-0-2":1674720000000,"col-1-1":153},{"col-0-2":1674806400000,"col-1-1":149},{"col-0-2":1674892800000,"col-1-1":94}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"right"}},"layer":[{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal","scale":{"domain":[1668730449617,1676506449617]}},"y":{"axis":{"title":"Count","orient":"left","labels":true,"labelAngle":0},"field":"col-1-1","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-1-1","type":"quantitative","title":"Count"}],"color":{"datum":"Count"}}}]}'; - expect( - JSON.stringify( - createSpecFromDatatable( - formatDataTable(JSON.parse(datatable)), - JSON.parse(visParams), - JSON.parse(dimensions) - ) - ) - ).toBe(spec); - }); - - it('build empty chart if no x-axis is defined"', function () { - const datatable = - '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-1":4675}],"columns":[{"id":"col-0-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}}]}'; - const visParams = - '{"type":"line","grid":{"categoryLines":false},"categoryAxes":[{"id":"CategoryAxis-1","type":"category","position":"bottom","show":true,"style":{},"scale":{"type":"linear"},"labels":{"show":true,"filter":true,"truncate":100},"title":{}}],"valueAxes":[{"id":"ValueAxis-1","name":"LeftAxis-1","type":"value","position":"left","show":true,"style":{},"scale":{"type":"linear","mode":"normal"},"labels":{"show":true,"rotate":0,"filter":false,"truncate":100},"title":{"text":"Count"}}],"seriesParams":[{"show":true,"type":"line","mode":"normal","data":{"label":"Count","id":"1"},"valueAxis":"ValueAxis-1","drawLinesBetweenPoints":true,"lineWidth":2,"interpolate":"linear","showCircles":true}],"addTooltip":true,"addLegend":true,"legendPosition":"right","times":[],"addTimeMarker":false,"labels":{},"thresholdLine":{"show":false,"value":10,"width":1,"style":"full","color":"#E7664C"}}'; - const dimensions = - '{"x":null,"y":[{"accessor":0,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"}]}'; - const spec = - '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-1":4675}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"right"}},"layer":[]}'; - expect( - JSON.stringify( - createSpecFromDatatable( - formatDataTable(JSON.parse(datatable)), - JSON.parse(visParams), - JSON.parse(dimensions) - ) - ) - ).toBe(spec); - }); - - it('build complicated line chart"', function () { - const datatable = - '{"type":"opensearch_dashboards_datatable","rows":[{"col-0-2":1672214400000,"col-1-1":44,"col-2-3":60.9375},{"col-0-2":1672300800000,"col-1-1":150,"col-2-3":82.5},{"col-0-2":1672387200000,"col-1-1":154,"col-2-3":79.5},{"col-0-2":1672473600000,"col-1-1":144,"col-2-3":75.875},{"col-0-2":1672560000000,"col-1-1":133,"col-2-3":259.25},{"col-0-2":1672646400000,"col-1-1":149,"col-2-3":90},{"col-0-2":1672732800000,"col-1-1":152,"col-2-3":79.0625},{"col-0-2":1672819200000,"col-1-1":144,"col-2-3":82.5},{"col-0-2":1672905600000,"col-1-1":166,"col-2-3":85.25},{"col-0-2":1672992000000,"col-1-1":151,"col-2-3":92},{"col-0-2":1673078400000,"col-1-1":143,"col-2-3":90.75},{"col-0-2":1673164800000,"col-1-1":148,"col-2-3":92},{"col-0-2":1673251200000,"col-1-1":146,"col-2-3":83.25},{"col-0-2":1673337600000,"col-1-1":137,"col-2-3":98},{"col-0-2":1673424000000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673510400000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673596800000,"col-1-1":151,"col-2-3":87.4375},{"col-0-2":1673683200000,"col-1-1":157,"col-2-3":63.75},{"col-0-2":1673769600000,"col-1-1":151,"col-2-3":81.5625},{"col-0-2":1673856000000,"col-1-1":152,"col-2-3":100.6875},{"col-0-2":1673942400000,"col-1-1":142,"col-2-3":98},{"col-0-2":1674028800000,"col-1-1":151,"col-2-3":100.8125},{"col-0-2":1674115200000,"col-1-1":163,"col-2-3":83.6875},{"col-0-2":1674201600000,"col-1-1":156,"col-2-3":85.8125},{"col-0-2":1674288000000,"col-1-1":153,"col-2-3":98},{"col-0-2":1674374400000,"col-1-1":162,"col-2-3":75.9375},{"col-0-2":1674460800000,"col-1-1":152,"col-2-3":113.375},{"col-0-2":1674547200000,"col-1-1":159,"col-2-3":73.625},{"col-0-2":1674633600000,"col-1-1":165,"col-2-3":72.8125},{"col-0-2":1674720000000,"col-1-1":153,"col-2-3":113.375},{"col-0-2":1674806400000,"col-1-1":149,"col-2-3":82.5},{"col-0-2":1674892800000,"col-1-1":94,"col-2-3":54}],"columns":[{"id":"col-0-2","name":"order_date per day","meta":{"type":"date_histogram","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"order_date","timeRange":{"from":"now-90d","to":"now"},"useNormalizedOpenSearchInterval":true,"scaleMetricValues":false,"interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}}},{"id":"col-1-1","name":"Count","meta":{"type":"count","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{}}},{"id":"col-2-3","name":"Max products.min_price","meta":{"type":"max","indexPatternId":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","aggConfigParams":{"field":"products.min_price"}}}]}'; - const visParams = - '{"addLegend":true,"addTimeMarker":true,"addTooltip":true,"categoryAxes":[{"id":"CategoryAxis-1","labels":{"filter":true,"show":true,"truncate":100},"position":"bottom","scale":{"type":"linear"},"show":true,"style":{},"title":{},"type":"category"}],"grid":{"categoryLines":false,"valueAxis":"ValueAxis-1"},"labels":{},"legendPosition":"bottom","seriesParams":[{"data":{"id":"1","label":"Count"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"},{"data":{"id":"3","label":"Max products.min_price"},"drawLinesBetweenPoints":true,"interpolate":"linear","lineWidth":2,"mode":"normal","show":true,"showCircles":true,"type":"line","valueAxis":"ValueAxis-1"}],"thresholdLine":{"color":"#E7664C","show":true,"style":"dashed","value":100,"width":1},"times":[],"type":"line","valueAxes":[{"id":"ValueAxis-1","labels":{"filter":false,"rotate":75,"show":true,"truncate":100},"name":"RightAxis-1","position":"right","scale":{"mode":"normal","type":"linear"},"show":true,"style":{},"title":{"text":"Count"},"type":"value"}]}'; - const dimensions = - '{"x":{"accessor":0,"format":{"id":"date","params":{"pattern":"YYYY-MM-DD"}},"params":{"date":true,"interval":"P1D","intervalESValue":1,"intervalESUnit":"d","format":"YYYY-MM-DD","bounds":{"min":"2022-11-19T03:26:04.730Z","max":"2023-02-17T03:26:04.730Z"}},"label":"order_date per day","aggType":"date_histogram"},"y":[{"accessor":1,"format":{"id":"number"},"params":{},"label":"Count","aggType":"count"},{"accessor":2,"format":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5603","pathname":"/rao/app/visualize","basePath":"/rao"}}},"params":{},"label":"Max products.min_price","aggType":"max"}]}'; - const spec = - '{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"col-0-2":1672214400000,"col-1-1":44,"col-2-3":60.9375},{"col-0-2":1672300800000,"col-1-1":150,"col-2-3":82.5},{"col-0-2":1672387200000,"col-1-1":154,"col-2-3":79.5},{"col-0-2":1672473600000,"col-1-1":144,"col-2-3":75.875},{"col-0-2":1672560000000,"col-1-1":133,"col-2-3":259.25},{"col-0-2":1672646400000,"col-1-1":149,"col-2-3":90},{"col-0-2":1672732800000,"col-1-1":152,"col-2-3":79.0625},{"col-0-2":1672819200000,"col-1-1":144,"col-2-3":82.5},{"col-0-2":1672905600000,"col-1-1":166,"col-2-3":85.25},{"col-0-2":1672992000000,"col-1-1":151,"col-2-3":92},{"col-0-2":1673078400000,"col-1-1":143,"col-2-3":90.75},{"col-0-2":1673164800000,"col-1-1":148,"col-2-3":92},{"col-0-2":1673251200000,"col-1-1":146,"col-2-3":83.25},{"col-0-2":1673337600000,"col-1-1":137,"col-2-3":98},{"col-0-2":1673424000000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673510400000,"col-1-1":152,"col-2-3":83.6875},{"col-0-2":1673596800000,"col-1-1":151,"col-2-3":87.4375},{"col-0-2":1673683200000,"col-1-1":157,"col-2-3":63.75},{"col-0-2":1673769600000,"col-1-1":151,"col-2-3":81.5625},{"col-0-2":1673856000000,"col-1-1":152,"col-2-3":100.6875},{"col-0-2":1673942400000,"col-1-1":142,"col-2-3":98},{"col-0-2":1674028800000,"col-1-1":151,"col-2-3":100.8125},{"col-0-2":1674115200000,"col-1-1":163,"col-2-3":83.6875},{"col-0-2":1674201600000,"col-1-1":156,"col-2-3":85.8125},{"col-0-2":1674288000000,"col-1-1":153,"col-2-3":98},{"col-0-2":1674374400000,"col-1-1":162,"col-2-3":75.9375},{"col-0-2":1674460800000,"col-1-1":152,"col-2-3":113.375},{"col-0-2":1674547200000,"col-1-1":159,"col-2-3":73.625},{"col-0-2":1674633600000,"col-1-1":165,"col-2-3":72.8125},{"col-0-2":1674720000000,"col-1-1":153,"col-2-3":113.375},{"col-0-2":1674806400000,"col-1-1":149,"col-2-3":82.5},{"col-0-2":1674892800000,"col-1-1":94,"col-2-3":54}]},"config":{"view":{"stroke":null},"concat":{"spacing":0},"legend":{"orient":"bottom"}},"layer":[{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal","scale":{"domain":[1668828364730,1676604364730]}},"y":{"axis":{"title":"Count","grid":"ValueAxis-1","orient":"right","labels":true,"labelAngle":75},"field":"col-1-1","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-1-1","type":"quantitative","title":"Count"}],"color":{"datum":"Count"}}},{"mark":{"type":"line","interpolate":"linear","strokeWidth":2,"point":true},"encoding":{"x":{"axis":{"title":"order_date per day","grid":false},"field":"col-0-2","type":"temporal","scale":{"domain":[1668828364730,1676604364730]}},"y":{"axis":{"title":"Count","grid":"ValueAxis-1","orient":"right","labels":true,"labelAngle":75},"field":"col-2-3","type":"quantitative"},"tooltip":[{"field":"col-0-2","type":"temporal","title":"order_date per day"},{"field":"col-2-3","type":"quantitative","title":"Max products.min_price"}],"color":{"datum":"Max products.min_price"}}},{"mark":"rule","encoding":{"x":{"type":"temporal","field":"now_field"},"color":{"value":"red"},"size":{"value":1}}},{"mark":{"type":"rule","color":"#E7664C","strokeDash":[8,8]},"encoding":{"y":{"datum":100}}}],"transform":[{"calculate":"now()","as":"now_field"}]}'; - expect( - JSON.stringify( - createSpecFromDatatable( - formatDataTable(JSON.parse(datatable)), - JSON.parse(visParams), - JSON.parse(dimensions) - ) - ) - ).toBe(spec); - }); -}); diff --git a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts index b0f440e59647..fe908c36f773 100644 --- a/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts +++ b/src/plugins/vis_type_vega/public/expressions/line_vega_spec_fn.ts @@ -3,15 +3,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEmpty } from 'lodash'; import { i18n } from '@osd/i18n'; import { ExpressionFunctionDefinition, OpenSearchDashboardsDatatable, - OpenSearchDashboardsDatatableColumn, } from '../../../expressions/public'; -import { VegaVisualizationDependencies } from '../plugin'; import { VislibDimensions, VisParams } from '../../../visualizations/public'; +import { + VisLayer, + VisLayers, + PointInTimeEventsVisLayer, + isPointInTimeEventsVisLayer, + addPointInTimeEventsLayersToTable, + addPointInTimeEventsLayersToSpec, +} from '../../../vis_augmenter/public'; +import { formatDatatable, createSpecFromDatatable } from './helpers'; +import { VegaVisualizationDependencies } from '../plugin'; type Input = OpenSearchDashboardsDatatable; type Output = Promise; @@ -29,236 +37,6 @@ export type LineVegaSpecExpressionFunctionDefinition = ExpressionFunctionDefinit Output >; -// TODO: move this to the visualization plugin that has VisParams once all of these parameters have been better defined -interface ValueAxis { - id: string; - labels: { - filter: boolean; - rotate: number; - show: boolean; - truncate: number; - }; - name: string; - position: string; - scale: { - mode: string; - type: string; - }; - show: true; - style: any; - title: { - text: string; - }; - type: string; -} - -// Get the first xaxis field as only 1 setup of X Axis will be supported and -// there won't be support for split series and split chart -const getXAxisId = (dimensions: any, columns: OpenSearchDashboardsDatatableColumn[]): string => { - return columns.filter((column) => column.name === dimensions.x.label)[0].id; -}; - -export const cleanString = (rawString: string): string => { - return rawString.replaceAll('"', ''); -}; - -export const formatDataTable = ( - datatable: OpenSearchDashboardsDatatable -): OpenSearchDashboardsDatatable => { - datatable.columns.forEach((column) => { - // clean quotation marks from names in columns - column.name = cleanString(column.name); - }); - return datatable; -}; - -export const setupConfig = (visParams: VisParams) => { - const legendPosition = visParams.legendPosition; - return { - view: { - stroke: null, - }, - concat: { - spacing: 0, - }, - legend: { - orient: legendPosition, - }, - }; -}; - -export const buildLayerMark = (seriesParams: { - type: string; - interpolate: string; - lineWidth: number; - showCircles: boolean; -}) => { - return { - // Possible types are: line, area, histogram. The eligibility checker will - // prevent area and histogram (though area works in vega-lite) - type: seriesParams.type, - // Possible types: linear, cardinal, step-after. All of these types work in vega-lite - interpolate: seriesParams.interpolate, - // The possible values is any number, which matches what vega-lite supports - strokeWidth: seriesParams.lineWidth, - // this corresponds to showing the dots in the visbuilder for each data point - point: seriesParams.showCircles, - }; -}; - -export const buildXAxis = ( - xAxisTitle: string, - xAxisId: string, - startTime: number, - endTime: number, - visParams: VisParams -) => { - return { - axis: { - title: xAxisTitle, - grid: visParams.grid.categoryLines, - }, - field: xAxisId, - // Right now, the line charts can only set the x-axis value to be a date attribute, so - // this should always be of type temporal - type: 'temporal', - scale: { - domain: [startTime, endTime], - }, - }; -}; - -export const buildYAxis = ( - column: OpenSearchDashboardsDatatableColumn, - valueAxis: ValueAxis, - visParams: VisParams -) => { - return { - axis: { - title: cleanString(valueAxis.title.text) || column.name, - grid: visParams.grid.valueAxis, - orient: valueAxis.position, - labels: valueAxis.labels.show, - labelAngle: valueAxis.labels.rotate, - }, - field: column.id, - type: 'quantitative', - }; -}; - -export const createSpecFromDatatable = ( - datatable: OpenSearchDashboardsDatatable, - visParams: VisParams, - dimensions: VislibDimensions -): object => { - // TODO: we can try to use VegaSpec type but it is currently very outdated, where many - // of the fields and sub-fields don't have other optional params that we want for customizing. - // For now, we make this more loosely-typed by just specifying it as a generic object. - const spec = {} as any; - - spec.$schema = 'https://vega.github.io/schema/vega-lite/v5.json'; - spec.data = { - values: datatable.rows, - }; - spec.config = setupConfig(visParams); - - // Get the valueAxes data and generate a map to easily fetch the different valueAxes data - const valueAxis = new Map(); - visParams?.valueAxes?.forEach((yAxis: ValueAxis) => { - valueAxis.set(yAxis.id, yAxis); - }); - - spec.layer = [] as any[]; - - if (datatable.rows.length > 0 && dimensions.x !== null) { - const xAxisId = getXAxisId(dimensions, datatable.columns); - const xAxisTitle = cleanString(dimensions.x.label); - // get x-axis bounds for the chart - const startTime = new Date(dimensions.x.params.bounds.min).valueOf(); - const endTime = new Date(dimensions.x.params.bounds.max).valueOf(); - let skip = 0; - datatable.columns.forEach((column, index) => { - // Check if it's not xAxis column data - if (column.meta?.aggConfigParams?.interval !== undefined) { - skip++; - } else { - const currentSeriesParams = visParams.seriesParams[index - skip]; - const currentValueAxis = valueAxis.get(currentSeriesParams.valueAxis.toString()); - let tooltip: Array<{ field: string; type: string; title: string }> = []; - if (visParams.addTooltip) { - tooltip = [ - { field: xAxisId, type: 'temporal', title: xAxisTitle }, - { field: column.id, type: 'quantitative', title: column.name }, - ]; - } - spec.layer.push({ - mark: buildLayerMark(currentSeriesParams), - encoding: { - x: buildXAxis(xAxisTitle, xAxisId, startTime, endTime, visParams), - y: buildYAxis(column, currentValueAxis, visParams), - tooltip, - color: { - // This ensures all the different metrics have their own distinct and unique color - datum: column.name, - }, - }, - }); - } - }); - } - - if (visParams.addTimeMarker) { - spec.transform = [ - { - calculate: 'now()', - as: 'now_field', - }, - ]; - - spec.layer.push({ - mark: 'rule', - encoding: { - x: { - type: 'temporal', - field: 'now_field', - }, - // The time marker on vislib is red, so keeping this consistent - color: { - value: 'red', - }, - size: { - value: 1, - }, - }, - }); - } - - if (visParams.thresholdLine.show as boolean) { - const layer = { - mark: { - type: 'rule', - color: visParams.thresholdLine.color, - strokeDash: [1, 0], - }, - encoding: { - y: { - datum: visParams.thresholdLine.value, - }, - }, - }; - - // Can only support making a threshold line with full or dashed style, but not dot-dashed - // due to vega-lite limitations - if (visParams.thresholdLine.style !== 'full') { - layer.mark.strokeDash = [8, 8]; - } - - spec.layer.push(layer); - } - - return spec; -}; - export const createLineVegaSpecFn = ( dependencies: VegaVisualizationDependencies ): LineVegaSpecExpressionFunctionDefinition => ({ @@ -286,14 +64,28 @@ export const createLineVegaSpecFn = ( }, }, async fn(input, args, context) { - const table = cloneDeep(input); + let table = formatDatatable(cloneDeep(input)); + + const visParams = JSON.parse(args.visParams) as VisParams; + const dimensions = JSON.parse(args.dimensions) as VislibDimensions; + const allVisLayers = (args.visLayers + ? (JSON.parse(args.visLayers) as VisLayers) + : []) as VisLayers; + + // currently only supporting PointInTimeEventsVisLayer type + const pointInTimeEventsVisLayers = allVisLayers.filter((visLayer: VisLayer) => + isPointInTimeEventsVisLayer(visLayer) + ) as PointInTimeEventsVisLayer[]; - // creating initial vega spec from table - const spec = createSpecFromDatatable( - formatDataTable(table), - JSON.parse(args.visParams), - JSON.parse(args.dimensions) - ); + if (!isEmpty(pointInTimeEventsVisLayers)) { + table = addPointInTimeEventsLayersToTable(table, dimensions, pointInTimeEventsVisLayers); + } + + let spec = createSpecFromDatatable(table, visParams, dimensions); + + if (!isEmpty(pointInTimeEventsVisLayers)) { + spec = addPointInTimeEventsLayersToSpec(table, dimensions, spec); + } return JSON.stringify(spec); }, }); diff --git a/src/plugins/vis_type_vega/public/expressions/vega_fn.ts b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts index f5ab178cbd74..4cd713741f8a 100644 --- a/src/plugins/vis_type_vega/public/expressions/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/expressions/vega_fn.ts @@ -92,6 +92,8 @@ export const createVegaFn = ( visParams: { spec: args.spec }, }); + //console.log('spec: ', JSON.stringify(args.spec)); + return { type: 'render', as: 'visualization', diff --git a/src/plugins/vis_type_vega/public/index.ts b/src/plugins/vis_type_vega/public/index.ts index ed0c794837dd..da9b8b396dba 100644 --- a/src/plugins/vis_type_vega/public/index.ts +++ b/src/plugins/vis_type_vega/public/index.ts @@ -36,5 +36,4 @@ export function plugin(initializerContext: PluginInitializerContext { const vegaSpecFn = buildExpressionFunction( 'line_vega_spec', { - visLayers: JSON.stringify([]), + visLayers: JSON.stringify(params.visLayers), visParams: JSON.stringify(vis.params), dimensions: JSON.stringify(dimensions), } diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index e09f789f9a68..8c0033e532e2 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -40,7 +40,7 @@ import { IContainer, ErrorEmbeddable } from '../../../embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; import { getSavedVisualizationsLoader, - getSavedAugmentVisLoader, + //getSavedAugmentVisLoader, getUISettings, getHttp, getTimeFilter, @@ -89,7 +89,7 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe const editable = getCapabilities().visualize.save as boolean; - const savedAugmentVisLoader = getSavedAugmentVisLoader(); + //const savedAugmentVisLoader = getSavedAugmentVisLoader(); return new VisualizeEmbeddable( getTimeFilter(), @@ -104,7 +104,7 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe input, attributeService, savedVisualizationsLoader, - savedAugmentVisLoader, + //savedAugmentVisLoader, parent ); } catch (e) { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index d8bb68417dc9..267c264a9d30 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -68,11 +68,12 @@ import { SavedAugmentVisLoader, ExprVisLayers, VisLayers, - isEligibleForVisLayers, + VisLayerTypes, getAugmentVisSavedObjs, buildPipelineFromAugmentVisSavedObjs, } from '../../../vis_augmenter/public'; import { VisSavedObject } from '../types'; +import { PointInTimeEventsVisLayer } from 'src/plugins/vis_augmenter/public/types'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -147,7 +148,7 @@ export class VisualizeEmbeddable VisualizeByReferenceInput >, savedVisualizationsLoader?: SavedVisualizationsLoader, - savedAugmentVisLoader?: SavedAugmentVisLoader, + //savedAugmentVisLoader?: SavedAugmentVisLoader, parent?: IContainer ) { super( @@ -170,7 +171,7 @@ export class VisualizeEmbeddable this.vis.uiState.on('reload', this.reload); this.attributeService = attributeService; this.savedVisualizationsLoader = savedVisualizationsLoader; - this.savedAugmentVisLoader = savedAugmentVisLoader; + //this.savedAugmentVisLoader = savedAugmentVisLoader; this.autoRefreshFetchSubscription = timefilter .getAutoRefreshFetch$() .subscribe(this.updateHandler.bind(this)); @@ -404,20 +405,56 @@ export class VisualizeEmbeddable this.abortController = new AbortController(); const abortController = this.abortController; - let exprVisLayers = {} as ExprVisLayers; - // TODO: final eligibility will be defined as part of a separate effort. - // This includes not fetching any layers / not showing any layers, when in the - // edit context of the vis - // See https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3268 - if (isEligibleForVisLayers(this.vis)) { - exprVisLayers = await this.fetchVisLayers(expressionParams, abortController); - } + // let exprVisLayers = {} as ExprVisLayers; + // // TODO: final eligibility will be defined as part of a separate effort. + // // This includes not fetching any layers / not showing any layers, when in the + // // edit context of the vis + // // See https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3268 + // if (isEligibleForVisLayers(this.vis)) { + // exprVisLayers = await this.fetchVisLayers(expressionParams, abortController); + // } + + // TODO: remove later. testing dummy vis layers that may be returned from the set + // of expressions functinos ran by the plugins + const dummyVisLayers = [ + { + originPlugin: 'Anomaly Detection', + type: VisLayerTypes.PointInTimeEvents, + pluginResource: { + type: 'Anomaly Detectors', + id: 'detector-1-id', + name: 'detector-1', + urlPath: 'anomaly-detection-dashboards#/detectors/anomaly-detector-1-id/configurations', + }, + events: [ + { + timestamp: 1672214400000, + metadata: { + pluginResourceId: 'detector-1-id', + }, + }, + { + timestamp: 1672214400005, + metadata: { + pluginResourceId: 'detector-1-id', + }, + }, + { + timestamp: 1675324800000, + metadata: { + pluginResourceId: 'detector-1-id', + }, + }, + ], + }, + ] as PointInTimeEventsVisLayer[]; this.expression = await buildPipeline(this.vis, { timefilter: this.timefilter, timeRange: this.timeRange, abortSignal: this.abortController!.signal, - visLayers: !isEmpty(exprVisLayers) ? exprVisLayers.layers : ([] as VisLayers), + // comment out to produce in vislib, leave in for vega + visLayers: dummyVisLayers, }); if (this.handler && !abortController.signal.aborted) { @@ -487,36 +524,36 @@ export class VisualizeEmbeddable ); }; - /** - * Collects any VisLayers from plugin expressions functions - * by fetching all AugmentVisSavedObjects that match the vis - * saved object ID - */ - fetchVisLayers = async ( - expressionParams: IExpressionLoaderParams, - abortController: AbortController - ): Promise => { - let exprVisLayers = {} as ExprVisLayers; - const augmentVisSavedObjs = await getAugmentVisSavedObjs( - this.vis.id, - this.savedAugmentVisLoader - ); - if (!isEmpty(augmentVisSavedObjs) && !abortController.signal.aborted) { - const visLayersPipeline = buildPipelineFromAugmentVisSavedObjs(augmentVisSavedObjs); - // The initial input for the pipeline will just be an empty arr of VisLayers. As plugin - // expression functions are ran, they will incrementally append their generated VisLayers to it. - const visLayersPipelineInput = { - type: 'vis_layers', - layers: [] as VisLayers, - }; - // We cannot use this.handler in this case, since it does not support the run() cmd - // we need here. So, we consume the expressions service to run this instead. - exprVisLayers = (await getExpressions().run( - visLayersPipeline, - visLayersPipelineInput, - expressionParams as Record - )) as ExprVisLayers; - } - return exprVisLayers; - }; + // /** + // * Collects any VisLayers from plugin expressions functions + // * by fetching all AugmentVisSavedObjects that match the vis + // * saved object ID + // */ + // fetchVisLayers = async ( + // expressionParams: IExpressionLoaderParams, + // abortController: AbortController + // ): Promise => { + // let exprVisLayers = {} as ExprVisLayers; + // const augmentVisSavedObjs = await getAugmentVisSavedObjs( + // this.vis.id, + // this.savedAugmentVisLoader + // ); + // if (!isEmpty(augmentVisSavedObjs) && !abortController.signal.aborted) { + // const visLayersPipeline = buildPipelineFromAugmentVisSavedObjs(augmentVisSavedObjs); + // // The initial input for the pipeline will just be an empty arr of VisLayers. As plugin + // // expression functions are ran, they will incrementally append their generated VisLayers to it. + // const visLayersPipelineInput = { + // type: 'vis_layers', + // layers: [] as VisLayers, + // }; + // // We cannot use this.handler in this case, since it does not support the run() cmd + // // we need here. So, we consume the expressions service to run this instead. + // exprVisLayers = (await getExpressions().run( + // visLayersPipeline, + // visLayersPipelineInput, + // expressionParams as Record + // )) as ExprVisLayers; + // } + // return exprVisLayers; + // }; }