From 50a714fde5fa89077a759da9b61d8fa4d32db033 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Wed, 23 Oct 2024 11:38:57 -0700 Subject: [PATCH] [VisBuilder-Next] Pie Chart Integration for VisBuilder (#7752) (#8553) * [VisBuilder-Next] Pie Chart Integration for VisBuilder This PR integrates pie charts into VisBuilder using Vega rendering. Issue Resolve: https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7607 --------- (cherry picked from commit 615d7d48f8a34d89294ada1579f00cff1316476d) Signed-off-by: Anan Zhuang Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] --- .../application/components/data_tab/index.tsx | 13 ++ .../validations/validate_aggregations.test.ts | 76 +++++++++++ .../validations/validate_aggregations.ts | 16 ++- src/plugins/vis_builder/public/plugin.ts | 3 +- .../public/services/type_service/types.ts | 1 + .../public/visualizations/index.ts | 30 +++- .../vega/components/{ => mark}/mark.test.ts | 0 .../vega/components/{ => mark}/mark.ts | 4 +- .../vega/components/mark/mark_slices.test.ts | 56 ++++++++ .../vega/components/mark/mark_slices.ts | 121 +++++++++++++++++ .../vega/utils/expression_helper.test.ts | 11 +- .../vega/utils/expression_helper.ts | 6 +- .../visualizations/vega/utils/helpers.test.ts | 105 +++++++++++++- .../visualizations/vega/utils/helpers.ts | 87 ++++++++++-- .../public/visualizations/vega/utils/types.ts | 5 +- ... => vega_lite_spec_series_builder.test.ts} | 16 +-- ...er.ts => vega_lite_spec_series_builder.ts} | 4 +- .../visualizations/vega/vega_spec_factory.ts | 22 ++- ...st.ts => vega_spec_series_builder.test.ts} | 12 +- ...builder.ts => vega_spec_series_builder.ts} | 8 +- .../vega/vega_spec_slices_builder.test.ts | 75 ++++++++++ .../vega/vega_spec_slices_builder.ts | 89 ++++++++++++ .../visualizations/vislib/area/index.ts | 2 +- .../visualizations/vislib/histogram/index.ts | 2 +- .../public/visualizations/vislib/index.ts | 7 +- .../visualizations/vislib/line/index.ts | 2 +- .../vislib/pie/components/pie_vis_options.tsx | 54 ++++++++ .../public/visualizations/vislib/pie/index.ts | 6 + .../visualizations/vislib/pie/pie_vis_type.ts | 93 +++++++++++++ .../vislib/pie/to_expression.test.ts | 128 ++++++++++++++++++ .../vislib/pie/to_expression.ts | 82 +++++++++++ src/plugins/vis_builder/server/plugin.ts | 1 + 32 files changed, 1084 insertions(+), 53 deletions(-) rename src/plugins/vis_builder/public/visualizations/vega/components/{ => mark}/mark.test.ts (100%) rename src/plugins/vis_builder/public/visualizations/vega/components/{ => mark}/mark.ts (98%) create mode 100644 src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts rename src/plugins/vis_builder/public/visualizations/vega/{vega_lite_spec_builder.test.ts => vega_lite_spec_series_builder.test.ts} (81%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_lite_spec_builder.ts => vega_lite_spec_series_builder.ts} (97%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_spec_builder.test.ts => vega_spec_series_builder.test.ts} (77%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_spec_builder.ts => vega_spec_series_builder.ts} (96%) create mode 100644 src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts diff --git a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx index 51a69950c719..1764b62a4454 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx @@ -20,6 +20,7 @@ import { addFieldToConfiguration } from './drag_drop/add_field_to_configuration' import { replaceFieldInConfiguration } from './drag_drop/replace_field_in_configuration'; import { reorderFieldsWithinSchema } from './drag_drop/reorder_fields_within_schema'; import { moveFieldBetweenSchemas } from './drag_drop/move_field_between_schemas'; +import { validateAggregations } from '../../utils/validations'; export const DATA_TAB_ID = 'data_tab'; @@ -39,6 +40,7 @@ export const DataTab = () => { data: { search: { aggs: aggService }, }, + notifications: { toasts }, }, } = useOpenSearchDashboards(); @@ -77,6 +79,17 @@ export const DataTab = () => { } const panelGroups = Array.from(schemas.all.map((schema) => schema.name)); + // Check schema order + if (destinationSchemaName === 'split') { + const validationResult = validateAggregations(aggProps.aggs, schemas.all); + if (!validationResult.valid) { + toasts.addWarning({ + title: 'vb_invalid_schema', + text: validationResult.errorMsg, + }); + return; + } + } if (destinationSchemaName.startsWith(ADD_PANEL_PREFIX)) { const updatedDestinationSchemaName = destinationSchemaName.split(ADD_PANEL_PREFIX)[1]; diff --git a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts index bec1ae506928..a0b8c7be24f9 100644 --- a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts +++ b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts @@ -96,4 +96,80 @@ describe('validateAggregations', () => { expect(valid).toBe(true); expect(errorMsg).not.toBeDefined(); }); + + test('Split chart should be first in the configuration', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'group', + params: {}, + }, + { + id: '1', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + ]); + + const schemas = [{ name: 'split', mustBeFirst: true }, { name: 'group' }]; + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs, schemas); + + expect(valid).toBe(false); + expect(errorMsg).toMatchInlineSnapshot(`"Split chart must be first in the configuration."`); + }); + + test('Valid configuration with split chart first', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + { + id: '2', + enabled: true, + type: METRIC_TYPES.COUNT, + schema: 'metric', + params: {}, + }, + ]); + + const schemas = [{ name: 'split', mustBeFirst: true }, { name: 'metric' }]; + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs, schemas); + + expect(valid).toBe(true); + expect(errorMsg).toBeUndefined(); + }); + + test('Valid configuration when schemas are not provided', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'group', + params: {}, + }, + { + id: '1', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + ]); + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs); + + expect(valid).toBe(true); + expect(errorMsg).not.toBeDefined(); + }); }); diff --git a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts index 470c83e96895..37dde685a14c 100644 --- a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts +++ b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts @@ -12,9 +12,10 @@ import { ValidationResult } from './types'; /** * Validate if the aggregations to perform are possible * @param aggs Aggregations to be performed + * @param schemas Optional. All available schemas * @returns ValidationResult */ -export const validateAggregations = (aggs: AggConfig[]): ValidationResult => { +export const validateAggregations = (aggs: AggConfig[], schemas?: any[]): ValidationResult => { // Pipeline aggs should have a valid bucket agg const metricAggs = aggs.filter((agg) => agg.schema === 'metric'); const lastParentPipelineAgg = findLast( @@ -50,5 +51,18 @@ export const validateAggregations = (aggs: AggConfig[]): ValidationResult => { }; } + const splitSchema = schemas?.find((s) => s.name === 'split'); + if (splitSchema?.mustBeFirst) { + const firstGroupSchemaIndex = aggs.findIndex((item) => item.schema === 'group'); + if (firstGroupSchemaIndex !== -1) { + return { + valid: false, + errorMsg: i18n.translate('visBuilder.aggregation.splitChartOrderErrorMessage', { + defaultMessage: 'Split chart must be first in the configuration.', + }), + }; + } + } + return { valid: true }; }; diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 20b13281e53b..216feafe187a 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -57,6 +57,7 @@ import { } from '../../opensearch_dashboards_utils/public'; import { opensearchFilters } from '../../data/public'; import { createRawDataVisFn } from './visualizations/vega/utils/expression_helper'; +import { VISBUILDER_ENABLE_VEGA_SETTING } from '../common/constants'; export class VisBuilderPlugin implements @@ -107,7 +108,7 @@ export class VisBuilderPlugin // Register Default Visualizations const typeService = this.typeService; - registerDefaultTypes(typeService.setup()); + registerDefaultTypes(typeService.setup(), core.uiSettings.get(VISBUILDER_ENABLE_VEGA_SETTING)); exp.registerFunction(createRawDataVisFn()); // Register the plugin to core diff --git a/src/plugins/vis_builder/public/services/type_service/types.ts b/src/plugins/vis_builder/public/services/type_service/types.ts index 0c232829431c..7f2a132fa752 100644 --- a/src/plugins/vis_builder/public/services/type_service/types.ts +++ b/src/plugins/vis_builder/public/services/type_service/types.ts @@ -34,4 +34,5 @@ export interface VisualizationTypeOptions { searchContext: IExpressionLoaderParams['searchContext'], useVega: boolean ) => Promise; + readonly hierarchicalData?: boolean | ((vis: { params: T }) => boolean); } diff --git a/src/plugins/vis_builder/public/visualizations/index.ts b/src/plugins/vis_builder/public/visualizations/index.ts index c867e570143e..6efa77dbf869 100644 --- a/src/plugins/vis_builder/public/visualizations/index.ts +++ b/src/plugins/vis_builder/public/visualizations/index.ts @@ -6,10 +6,32 @@ import type { TypeServiceSetup } from '../services/type_service'; import { createMetricConfig } from './metric'; import { createTableConfig } from './table'; -import { createHistogramConfig, createLineConfig, createAreaConfig } from './vislib'; +import { + createHistogramConfig, + createLineConfig, + createAreaConfig, + createPieConfig, +} from './vislib'; +import { VisualizationTypeOptions } from '../services/type_service'; +import { + HistogramOptionsDefaults, + LineOptionsDefaults, + AreaOptionsDefaults, + PieOptionsDefaults, +} from './vislib'; +import { MetricOptionsDefaults } from './metric/metric_viz_type'; +import { TableOptionsDefaults } from './table/table_viz_type'; -export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { - const visualizationTypes = [ +type VisualizationConfigFunction = + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions); + +export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup, useVega: boolean) { + const visualizationTypes: VisualizationConfigFunction[] = [ createHistogramConfig, createLineConfig, createAreaConfig, @@ -17,6 +39,8 @@ export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { createTableConfig, ]; + if (useVega) visualizationTypes.push(createPieConfig); + visualizationTypes.forEach((createTypeConfig) => { typeServiceSetup.createVisualizationType(createTypeConfig()); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.test.ts similarity index 100% rename from src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.test.ts diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts similarity index 98% rename from src/plugins/vis_builder/public/visualizations/vega/components/mark.ts rename to src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts index dd85544dd839..6e717960e7f2 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts @@ -4,7 +4,7 @@ */ import { AxisFormats } from '../utils/types'; -import { buildAxes } from './axes'; +import { buildAxes } from '../axes'; export type VegaMarkType = | 'line' @@ -87,7 +87,7 @@ export const buildMarkForVegaLite = (vegaType: string): VegaLiteMark => { }; /** - * Builds a mark configuration for Vega based on the chart type. + * Builds a mark configuration for Vega useing series data based on the chart type. * * @param {VegaMarkType} chartType - The type of chart to build the mark for. * @param {any} dimensions - The dimensions of the data. diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts new file mode 100644 index 000000000000..b14fe365a14e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildSlicesMarkForVega, buildPieMarks, buildArcMarks } from './mark_slices'; + +describe('buildSlicesMarkForVega', () => { + it('should return a group mark with correct properties', () => { + const result = buildSlicesMarkForVega(['level1', 'level2'], true, true); + expect(result.type).toBe('group'); + expect(result.from).toEqual({ data: 'splits' }); + expect(result.encode.enter.width).toEqual({ signal: 'chartWidth' }); + expect(result.title).toBeDefined(); + expect(result.data).toBeDefined(); + expect(result.marks).toBeDefined(); + }); + + it('should handle non-split case correctly', () => { + const result = buildSlicesMarkForVega(['level1'], false, true); + expect(result.from).toBeNull(); + expect(result.encode.enter.width).toEqual({ signal: 'width' }); + expect(result.title).toBeNull(); + }); +}); + +describe('buildPieMarks', () => { + it('should create correct number of marks', () => { + const result = buildPieMarks(['level1', 'level2'], true); + expect(result).toHaveLength(2); + }); + + it('should create correct transformations', () => { + const result = buildPieMarks(['level1'], true); + expect(result[0].transform).toHaveLength(3); + expect(result[0].transform[0].type).toBe('filter'); + expect(result[0].transform[1].type).toBe('aggregate'); + expect(result[0].transform[2].type).toBe('pie'); + }); +}); + +describe('buildArcMarks', () => { + it('should create correct number of arc marks', () => { + const result = buildArcMarks(['level1', 'level2'], false); + expect(result).toHaveLength(2); + expect(result[0].encode.update.tooltip).toBeUndefined(); + }); + + it('should create arc marks with correct properties', () => { + const result = buildArcMarks(['level1'], true); + expect(result[0].type).toBe('arc'); + expect(result[0].encode.enter.fill).toBeDefined(); + expect(result[0].encode.update.startAngle).toBeDefined(); + expect(result[0].encode.update.tooltip).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts new file mode 100644 index 000000000000..0fba717a2ce2 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Builds a mark configuration for Vega using slices data. + * + * @param {string[]} levels - The array of hierarchy levels. + * @param {boolean} hasSplit - Indicates whether we have split data. + * @returns {Object} An object containing a single group mark configuration. + */ +export const buildSlicesMarkForVega = (levels: string[], hasSplit: boolean, addTooltip) => { + return { + type: 'group', + // If we have splits, use the 'splits' data, otherwise no specific data source + from: hasSplit ? { data: 'splits' } : null, + encode: { + enter: { + // Set width based on whether we have splits or not + width: { signal: hasSplit ? 'chartWidth' : 'width' }, + height: { signal: 'chartHeight' }, + }, + }, + // Define signals for facet dimensions + signals: [ + { name: 'facetWidth', update: hasSplit ? 'chartWidth' : 'width' }, + { name: 'facetHeight', update: 'height' }, + ], + // Add a title if have splits + title: hasSplit + ? { + text: { signal: 'parent.split' }, + frame: 'group', + baseline: 'bottom', // Align the text to the bottom + orient: 'bottom', // Position the title at the bottom + offset: 20, + limit: { signal: 'chartWidth' }, // This limits the title width + ellipsis: '...', // Add ellipsis for truncated text + } + : null, + // Build the data for each level of the pie + data: buildPieMarks(levels, hasSplit), + // Build the arc marks for each level of the pie + marks: buildArcMarks(levels, addTooltip), + }; +}; + +/** + * Builds the data transformations for each level of the pie chart. + * + * @param {string[]} levels - The array of hierarchy levels. + * @param {boolean} hasSplit - Indicates whether we have split data. + * @returns {Object[]} An array of data transformation configurations for each level. + */ +export const buildPieMarks = (levels: string[], hasSplit: boolean) => { + return levels.map((level, index) => ({ + name: `facet_${level}`, + source: 'table', + transform: [ + // Filter data if we have splits + { + type: 'filter', + expr: hasSplit ? `datum.split === parent.split` : 'true', + }, + // Aggregate data for this level + { + type: 'aggregate', + groupby: levels.slice(0, index + 1), + fields: ['value'], + ops: ['sum'], + as: ['sum_value'], + }, + // Create pie layout + { type: 'pie', field: 'sum_value' }, + ], + })); +}; + +/** + * Builds the arc marks for each level of the pie chart. + * + * @param {string[]} levels - The array of hierarchy levels. + * @returns {Object[]} An array of arc mark configurations for each level. + */ +export const buildArcMarks = (levels: string[], addTooltip) => { + return levels.map((level, index) => ({ + type: 'arc', + from: { data: `facet_${level}` }, + encode: { + enter: { + // Set fill color based on the current level + fill: { scale: 'color', field: level }, + // Center the arc + x: { signal: 'facetWidth / 2' }, + y: { signal: 'facetHeight / 2' }, + }, + update: { + // Set arc angles and dimensions + startAngle: { field: 'startAngle' }, + endAngle: { field: 'endAngle' }, + padAngle: { value: 0.01 }, + innerRadius: { signal: `innerRadius + thickness * ${index}` }, + outerRadius: { signal: `innerRadius + thickness * (${index} + 1)` }, + stroke: { value: 'white' }, + strokeWidth: { value: 2 }, + // Create tooltip with all relevant level data + ...(addTooltip + ? { + tooltip: { + signal: `{${levels + .slice(0, index + 1) + .map((l) => `'${l}': datum.${l}`) + .join(', ')}, 'Value': datum.sum_value}`, + }, + } + : {}), + }, + }, + })); +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts index dd26df5219c2..302de1ad727e 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts @@ -5,6 +5,7 @@ import { createRawDataVisFn, executeExpression } from './expression_helper'; import { getExpressionsService } from '../../../../../expressions/public'; +import { ExecutionContext, OpenSearchDashboardsDatatable } from '../../../../../expressions/public'; jest.mock('../../../../../expressions/public', () => ({ getExpressionsService: jest.fn(), @@ -22,7 +23,15 @@ describe('expression_helper.ts', () => { it('should return the input context unchanged', () => { const result = createRawDataVisFn(); const context = { some: 'data' }; - expect(result.fn(context as any)).toBe(context); + const mockArgs = {}; + const mockHandlers: ExecutionContext = { + getInitialInput: jest.fn(), + variables: {}, + types: {}, + abortSignal: new AbortController().signal, + inspectorAdapters: {}, + }; + expect(result.fn(context as any, mockArgs, mockHandlers)).toBe(context); }); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts index 2967fa177983..9b71ce408a0c 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts @@ -27,7 +27,11 @@ export const createRawDataVisFn = (): ExpressionFunctionDefinition< inputTypes: ['opensearch_dashboards_datatable'], help: 'Returns raw data from opensearchaggs without modification', args: {}, - fn(context: OpenSearchDashboardsDatatable): OpenSearchDashboardsDatatable { + fn( + context: OpenSearchDashboardsDatatable, + args?: {}, + handlers?: {} + ): OpenSearchDashboardsDatatable { // Simply return the input context, which should be the opensearchaggs result return context; }, diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts index 92576d1a608b..4b210ea9b497 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts @@ -5,7 +5,7 @@ import { flattenDataHandler, mapFieldTypeToVegaType, mapChartTypeToVegaType } from './helpers'; -// Mock the vislibSeriesResponseHandler +// Mock the vislibSeriesResponseHandler and vislibSlicesResponseHandler jest.mock('../../../../../vis_type_vislib/public', () => ({ vislibSeriesResponseHandler: jest.fn((context, dimensions) => { if (dimensions.splitRow || dimensions.splitColumn) { @@ -18,6 +18,11 @@ jest.mock('../../../../../vis_type_vislib/public', () => ({ }; } }), + vislibSlicesResponseHandler: jest.fn((context) => { + return { + slices: context.slices, + }; + }), })); describe('helpers.ts', () => { @@ -66,13 +71,108 @@ describe('helpers.ts', () => { expect(result.series[0]).toEqual({ x: 1, y: 10, series: 'Series 1' }); expect(result.series[1]).toEqual({ x: 2, y: 20, series: 'Series 1' }); }); + + it('should flatten slice data correctly', () => { + const context = { + slices: { + children: [ + { + name: 'Category A', + children: [ + { name: 'Subcategory 1', size: 10 }, + { name: 'Subcategory 2', size: 20 }, + ], + }, + ], + }, + }; + const dimensions = {}; + const result = flattenDataHandler(context, dimensions, 'slices'); + + expect(result.slices).toHaveLength(2); + expect(result.slices[0]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 1', + value: 10, + }); + expect(result.slices[1]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 2', + value: 20, + }); + expect(result.levels).toEqual(['level1', 'level2']); + }); + + it('should handle slice data with splits', () => { + const context = { + slices: { + children: [ + { + name: 'Category A', + children: [{ name: 'Subcategory 1', size: 10 }], + }, + ], + }, + }; + const dimensions = { splitRow: [{}] }; + const result = flattenDataHandler(context, dimensions, 'slices'); + + expect(result.slices).toHaveLength(1); + expect(result.slices[0]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 1', + value: 10, + split: undefined, + }); + }); + + it('should handle z-values in series data', () => { + const context = { + series: [ + { + label: 'Series 1', + values: [ + { x: 1, y: 10, z: 5 }, + { x: 2, y: 20, z: 10 }, + ], + }, + ], + }; + const dimensions = {}; + const result = flattenDataHandler(context, dimensions); + + expect(result.series).toHaveLength(2); + expect(result.series[0]).toEqual({ x: 1, y: 10, z: 5, series: 'Series 1' }); + expect(result.series[1]).toEqual({ x: 2, y: 20, z: 10, series: 'Series 1' }); + }); + + it('should throw an error if series values are not an array', () => { + const context = { + series: [ + { + label: 'Series 1', + values: 'not an array', + }, + ], + }; + const dimensions = {}; + + expect(() => flattenDataHandler(context, dimensions)).toThrow( + 'Each series must have a "values" array' + ); + }); }); describe('mapFieldTypeToVegaType', () => { it('should map OpenSearch field types to Vega data types', () => { expect(mapFieldTypeToVegaType('number')).toBe('quantitative'); expect(mapFieldTypeToVegaType('date')).toBe('temporal'); + expect(mapFieldTypeToVegaType('time')).toBe('temporal'); + expect(mapFieldTypeToVegaType('terms')).toBe('nominal'); expect(mapFieldTypeToVegaType('keyword')).toBe('nominal'); + expect(mapFieldTypeToVegaType('ip')).toBe('nominal'); + expect(mapFieldTypeToVegaType('boolean')).toBe('nominal'); + expect(mapFieldTypeToVegaType('histogram')).toBe('quantitative'); expect(mapFieldTypeToVegaType('unknown')).toBe('nominal'); }); }); @@ -81,6 +181,9 @@ describe('helpers.ts', () => { it('should map chart types to Vega mark types', () => { expect(mapChartTypeToVegaType('histogram')).toBe('bar'); expect(mapChartTypeToVegaType('line')).toBe('line'); + expect(mapChartTypeToVegaType('area')).toBe('area'); + expect(mapChartTypeToVegaType('bar')).toBe('bar'); + expect(mapChartTypeToVegaType('pie')).toBe('pie'); }); }); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts index e15edf1ac795..e77e1460e7ae 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts @@ -37,7 +37,7 @@ interface Series { values: SeriesValue[]; } -interface FlattenedSeriesItem extends SeriesValue { +export interface FlattenedSeriesItem extends SeriesValue { series: string; split?: string; } @@ -79,30 +79,93 @@ const flattenSeries = ( }); }; +export interface FlattenedSliceItem { + [key: string]: any; + value: number; + split?: string; +} + +export interface FlattenHierarchyResult { + flattenedData: FlattenedSliceItem[]; + levels: string[]; +} + +/** + * Flattens hierarchical slice data into a single array of data points + * @param {any} data - The hierarchical data to flatten + * @param {any[]} group - The group data (rows or columns) if split dimensions exist + * @returns {FlattenedSliceItem[]} Flattened array of data points + */ +const flattenHierarchy = (data, group): FlattenHierarchyResult => { + const flattenedData: FlattenedSliceItem[] = []; + const levelSet = new Set(); + + const flattenSlices = ( + slices: any, + split?: string, + level = 1, + parentLabels: { [key: string]: string } = {} + ) => { + slices.children.forEach((child: any) => { + const currentLabels = { ...parentLabels, [`level${level}`]: child.name }; + levelSet.add(`level${level}`); + + if (Array.isArray(child.children) && child.children.length > 0) { + flattenSlices(child, split, level + 1, currentLabels); + } else { + const dataPoint: FlattenedSliceItem = { + ...currentLabels, + value: child.size !== undefined ? child.size : null, + }; + if (split !== undefined) { + dataPoint.split = split; + } + flattenedData.push(dataPoint); + } + }); + }; + + if (Array.isArray(group) && group.length !== 0) { + group.forEach((splitData) => { + flattenSlices(splitData.slices, splitData.label); + }); + } else { + flattenSlices(data.slices, undefined); + } + + return { flattenedData, levels: Array.from(levelSet) }; +}; + +/** + * Handles the flattening of data for different chart types + * @param {any} context - The context object containing the data + * @param {any} dimensions - The dimensions object defining the chart structure + * @param {'series' | 'slices'} handlerType - The type of chart data to handle + * @returns {any} Converted and flattened data suitable for visualization + */ export const flattenDataHandler = (context, dimensions, handlerType = 'series') => { - // Currently, our vislib only supports 'series' or 'slices' response types. - // This will need to be updated if more types are added in the future. + // TODO: Update this func if more types are added in the future. const handler = handlerType === 'series' ? vislibSeriesResponseHandler : vislibSlicesResponseHandler; const converted = handler(context, dimensions); + const group = dimensions.splitRow + ? converted.rows + : dimensions.splitColumn + ? converted.columns + : []; if (handlerType === 'series') { // Determine the group based on split dimensions - const group = dimensions.splitRow - ? converted.rows - : dimensions.splitColumn - ? converted.columns - : []; - - if (group && group.length !== 0) { + if (Array.isArray(group) && group.length !== 0) { converted.series = group.flatMap((split) => flattenSeries(split.series, split.label)); setAxisProperties(converted, group); } else { converted.series = flattenSeries(converted.series); } } else if (handlerType === 'slices') { - // TODO: Handle slices data, such as pie charts - // This section should be implemented when support for slice-based charts is added + const { flattenedData, levels } = flattenHierarchy(converted, group); + converted.slices = flattenedData; + converted.levels = levels; } return converted; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts index 855d88fd145e..1b5c630788f2 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts @@ -4,7 +4,8 @@ */ import { VegaEncoding } from '../components/encoding'; -import { VegaLiteMark } from '../components/mark'; +import { VegaLiteMark } from '../components/mark/mark'; + export interface AxisFormat { id: string; } @@ -76,7 +77,7 @@ export interface VegaSpec { }>; layout?: { [key: string]: any; - }; + } | null; marks: Array<{ type: string; from?: any; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts similarity index 81% rename from src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts index 0f1f25b1e762..e5c0a2a93fce 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateVegaLiteSpec } from './vega_lite_spec_builder'; +import { generateVegaLiteSpecForSeries } from './vega_lite_spec_series_builder'; -describe('generateVegaLiteSpec', () => { +describe('generateVegaLiteSpecForSeries', () => { it('should generate a basic Vega-Lite specification', () => { const data = { xAxisFormat: { id: 'date' }, @@ -22,7 +22,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.$schema).toBe('https://vega.github.io/schema/vega-lite/v5.json'); expect(result.data).toBeDefined(); @@ -45,10 +45,10 @@ describe('generateVegaLiteSpec', () => { addTooltip: true, }; - const lineResult = generateVegaLiteSpec(data, visConfig, { type: 'line' }); + const lineResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'line' }); expect(lineResult.mark).toEqual({ type: 'line', point: true, tooltip: true }); - const areaResult = generateVegaLiteSpec(data, visConfig, { type: 'area' }); + const areaResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'area' }); expect(areaResult.mark).toEqual({ type: 'area', line: true, @@ -58,7 +58,7 @@ describe('generateVegaLiteSpec', () => { baseline: 0, }); - const barResult = generateVegaLiteSpec(data, visConfig, { type: 'bar' }); + const barResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'bar' }); expect(barResult.mark).toEqual({ type: 'bar', tooltip: true }); }); @@ -78,7 +78,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.config).toBeDefined(); expect(result.config!.legend).toBeDefined(); @@ -101,7 +101,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.encoding!.tooltip).toBeDefined(); expect(result.mark).toHaveProperty('tooltip', true); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts similarity index 97% rename from src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts index 6110b3f75ed9..347f3a5299ba 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts @@ -4,7 +4,7 @@ */ import { buildVegaLiteEncoding } from './components/encoding'; -import { buildMarkForVegaLite, VegaMarkType } from './components/mark'; +import { buildMarkForVegaLite, VegaMarkType } from './components/mark/mark'; import { buildTooltip } from './components/tooltip'; import { buildLegend } from './components/legend'; import { StyleState } from '../../application/utils/state_management'; @@ -19,7 +19,7 @@ import { mapChartTypeToVegaType } from './utils/helpers'; * @param {StyleState} style - The StyleState defined in style slice. * @returns {VegaLiteSpec} The complete Vega-Lite specification. */ -export const generateVegaLiteSpec = ( +export const generateVegaLiteSpecForSeries = ( data: any, visConfig: any, style: StyleState diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts index b1ea168e8279..ba01c48e4ad5 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts @@ -5,8 +5,9 @@ import { StyleState } from '../../application/utils/state_management'; import { flattenDataHandler } from './utils/helpers'; -import { generateVegaLiteSpec } from './vega_lite_spec_builder'; -import { generateVegaSpec } from './vega_spec_builder'; +import { generateVegaLiteSpecForSeries } from './vega_lite_spec_series_builder'; +import { generateVegaSpecForSeries } from './vega_spec_series_builder'; +import { generateVegaSpecForSlices } from './vega_spec_slices_builder'; import { VegaLiteSpec, VegaSpec } from './utils/types'; /** @@ -25,15 +26,26 @@ export const createVegaSpec = ( const { dimensions } = visConfig; // Transform the data using the flattenDataHandler - const transformedData = flattenDataHandler(context, dimensions, 'series'); + const handler = style.type !== 'pie' ? 'series' : 'slices'; + const transformedData = flattenDataHandler(context, dimensions, handler); + return handler === 'series' + ? createVegaSpecForSeriesData(dimensions, transformedData, visConfig, style) + : createVegaSpecForSlicesData(dimensions, transformedData, visConfig, style); +}; + +const createVegaSpecForSeriesData = (dimensions, transformedData, visConfig, style) => { // Determine whether to use Vega or Vega-Lite based on the presence of split dimensions // TODO: Summarize the cases to use Vega. Change this to a better determine function. if (dimensions.splitRow || dimensions.splitColumn) { // Use Vega for more complex, split visualizations - return generateVegaSpec(transformedData, visConfig, style); + return generateVegaSpecForSeries(transformedData, visConfig, style); } else { // Use Vega-Lite for simpler visualizations - return generateVegaLiteSpec(transformedData, visConfig, style); + return generateVegaLiteSpecForSeries(transformedData, visConfig, style); } }; + +const createVegaSpecForSlicesData = (dimensions, transformedData, visConfig, style) => { + return generateVegaSpecForSlices(transformedData, visConfig, style); +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts similarity index 77% rename from src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts index 3197e28ae40a..9b2cc424ea84 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateVegaSpec } from './vega_spec_builder'; +import { generateVegaSpecForSeries } from './vega_spec_series_builder'; -describe('generateVegaSpec', () => { +describe('generateVegaSpecForSeries', () => { const baseData = { xAxisFormat: { id: 'date' }, xAxisLabel: 'Date', @@ -26,7 +26,7 @@ describe('generateVegaSpec', () => { it('should generate a basic Vega specification', () => { const style = { type: 'line' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.$schema).toBe('https://vega.github.io/schema/vega/v5.json'); expect(result.data).toBeDefined(); @@ -38,7 +38,7 @@ describe('generateVegaSpec', () => { it('should handle area charts', () => { const style = { type: 'area' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.data).toBeDefined(); expect(result.data?.some((d) => d.name === 'stacked')).toBe(true); @@ -48,7 +48,7 @@ describe('generateVegaSpec', () => { it('should add legend when specified', () => { const style = { type: 'line' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.legends).toBeDefined(); expect(result.legends?.[0]?.orient).toBe('right'); @@ -61,7 +61,7 @@ describe('generateVegaSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaSpec(baseData, visConfigNoLegend, style); + const result = generateVegaSpecForSeries(baseData, visConfigNoLegend, style); expect(result.legends).toBeUndefined(); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts similarity index 96% rename from src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts index 90181a8e5513..7f77cf85d553 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { buildMarkForVega, VegaMarkType } from './components/mark'; +import { buildMarkForVega, VegaMarkType } from './components/mark/mark'; import { buildLegend } from './components/legend'; import { VegaSpec, AxisFormats } from './utils/types'; import { StyleState } from '../../application/utils/state_management'; @@ -17,7 +17,11 @@ import { mapChartTypeToVegaType } from './utils/helpers'; * @param {StyleState} style - The style configuration for the visualization. * @returns {VegaSpec} The complete Vega specification. */ -export const generateVegaSpec = (data: any, visConfig: any, style: StyleState): VegaSpec => { +export const generateVegaSpecForSeries = ( + data: any, + visConfig: any, + style: StyleState +): VegaSpec => { const { dimensions, addLegend, legendPosition } = visConfig; const { type } = style; const vegaType = mapChartTypeToVegaType(type) as VegaMarkType; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts new file mode 100644 index 000000000000..1f4529e5cd27 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateVegaSpecForSlices } from './vega_spec_slices_builder'; + +// Mock the imported functions +jest.mock('./components/mark/mark_slices', () => ({ + buildSlicesMarkForVega: jest.fn(() => ({ type: 'mock-slices-mark' })), +})); + +jest.mock('./components/legend', () => ({ + buildLegend: jest.fn(() => ({ type: 'mock-legend' })), +})); + +describe('generateVegaSpecForSlices', () => { + const mockData = { + slices: [{ value: 10 }, { value: 20 }], + levels: ['level1', 'level2'], + }; + + const mockVisConfig = { + dimensions: {}, + addLegend: true, + legendPosition: 'right', + }; + + const mockStyle = { + colorSchema: 'custom-schema', + }; + + it('should generate a valid Vega spec for slices', () => { + const result = generateVegaSpecForSlices(mockData, mockVisConfig, mockStyle); + + expect(result.$schema).toBe('https://vega.github.io/schema/vega/v5.json'); + expect(result.padding).toBe(5); + expect(result.signals).toHaveLength(8); + expect(result.data).toHaveLength(2); + expect(result.scales).toHaveLength(1); + expect(result.scales?.[0].range.scheme).toBe('category20'); + expect(result.marks).toEqual([{ type: 'mock-slices-mark' }]); + expect(result.legends).toEqual([{ type: 'mock-legend' }]); + }); + + it('should handle split data correctly', () => { + const splitVisConfig = { + ...mockVisConfig, + dimensions: { splitRow: true }, + }; + + const result = generateVegaSpecForSlices(mockData, splitVisConfig, mockStyle); + + expect(result.signals?.[0].update).toBe("length(data('splits'))"); + expect(result.layout).toBeDefined(); + if (result.layout) expect(result.layout.columns).toEqual({ signal: 'splitCount' }); + }); + + it('should not add legend when addLegend is false', () => { + const noLegendVisConfig = { + ...mockVisConfig, + addLegend: false, + }; + + const result = generateVegaSpecForSlices(mockData, noLegendVisConfig, mockStyle); + + expect(result.legends).toBeUndefined(); + }); + + it('should use default color schema when not provided in style', () => { + const result = generateVegaSpecForSlices(mockData, mockVisConfig, {}); + + expect(result.scales?.[0].range.scheme).toBe('category20'); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts new file mode 100644 index 000000000000..1651bf2882e6 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VegaSpec } from './utils/types'; +import { buildLegend } from './components/legend'; +import { StyleState } from '../../application/utils/state_management'; +import { buildSlicesMarkForVega } from './components/mark/mark_slices'; + +/** + * Generates a Vega specification for a sliced chart (pie/donut chart). + * + * @param {any} data - The data object containing slices and levels information. + * @param {any} visConfig - The visualization configuration object. + * @param {StyleState} style - The style state object. + * @returns {VegaSpec} A Vega specification object for the sliced chart. + */ +export const generateVegaSpecForSlices = ( + data: any, + visConfig: any, + style: StyleState +): VegaSpec => { + const { dimensions, addLegend, addTooltip, legendPosition, isDonut } = visConfig; + const { slices, levels } = data; + const hasSplit = dimensions.splitRow || dimensions.splitColumn; + + const spec: VegaSpec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + padding: 5, + + signals: [ + { name: 'splitCount', update: hasSplit ? `length(data('splits'))` : '1' }, + { name: 'levelCount', update: levels.length.toString() }, + { name: 'chartWidth', update: hasSplit ? 'width / splitCount - 10' : 'width' }, + { name: 'chartHeight', update: 'height' }, + { name: 'radius', update: 'min(chartWidth, chartHeight) / 2' }, + { + name: 'innerRadiusRatio', + update: isDonut ? 'max(0.1, 0.4 - (levelCount - 1) * 0.05)' : '0', + }, + { name: 'innerRadius', update: 'radius * innerRadiusRatio' }, + { name: 'thickness', update: '(radius - innerRadius) / levelCount' }, + ], + + data: [ + { + name: 'table', + values: slices, + transform: [{ type: 'filter', expr: 'datum.value != null' }], + }, + { + name: 'splits', + source: 'table', + transform: [ + { + type: 'aggregate', + groupby: hasSplit ? ['split'] : [], + }, + ], + }, + ], + + scales: [ + { + name: 'color', + type: 'ordinal', + domain: { data: 'table', fields: levels }, + range: { scheme: 'category20' }, + }, + ], + + layout: hasSplit + ? { + columns: { signal: 'splitCount' }, + padding: { row: 40, column: 20 }, + } + : null, + + marks: [buildSlicesMarkForVega(levels, hasSplit, addTooltip)], + }; + + // Add legend if specified + if (addLegend) { + spec.legends = [buildLegend(legendPosition, true)]; + } + + return spec; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts index 7ec1f37a601d..b71df534272c 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createAreaConfig } from './area_vis_type'; +export { createAreaConfig, AreaOptionsDefaults } from './area_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts index bba280de2d77..ad52c3072814 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createHistogramConfig } from './histogram_vis_type'; +export { createHistogramConfig, HistogramOptionsDefaults } from './histogram_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/index.ts index 84dc3e346ef5..4f02715d07e8 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/index.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createHistogramConfig } from './histogram'; -export { createLineConfig } from './line'; -export { createAreaConfig } from './area'; +export { createHistogramConfig, HistogramOptionsDefaults } from './histogram'; +export { createLineConfig, LineOptionsDefaults } from './line'; +export { createAreaConfig, AreaOptionsDefaults } from './area'; +export { createPieConfig, PieOptionsDefaults } from './pie'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts index 721ec7858a7a..ea3448e7a514 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createLineConfig } from './line_vis_type'; +export { createLineConfig, LineOptionsDefaults } from './line_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx b/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx new file mode 100644 index 000000000000..0de536b6e8fd --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import produce, { Draft } from 'immer'; +import { useTypedDispatch, useTypedSelector } from '../../../../application/utils/state_management'; +import { PieOptionsDefaults } from '../pie_vis_type'; +import { setState } from '../../../../application/utils/state_management/style_slice'; +import { Option } from '../../../../application/app'; +import { BasicVisOptions } from '../../common/basic_vis_options'; +import { SwitchOption } from '../../../../../../charts/public'; + +function PieVisOptions() { + const styleState = useTypedSelector((state) => state.style) as PieOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setState(newState)); + }, + [dispatch, styleState] + ); + + return ( + <> + + + ); +} + +export { PieVisOptions }; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts new file mode 100644 index 000000000000..d05188e1316e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createPieConfig, PieOptionsDefaults } from './pie_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts new file mode 100644 index 000000000000..bdaa3abc4dac --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Positions } from '../../../../../vis_type_vislib/public'; +import { AggGroupNames } from '../../../../../data/public'; +import { PieVisOptions } from './components/pie_vis_options'; +import { VisualizationTypeOptions } from '../../../services/type_service'; +import { toExpression } from './to_expression'; +import { BasicOptionsDefaults } from '../common/types'; + +export interface PieOptionsDefaults extends BasicOptionsDefaults { + type: 'pie'; + isDonut: boolean; + showMetricsAtAllLevels: boolean; + labels: { + show: boolean; + values: boolean; + last_level: boolean; + truncate: number; + }; +} + +export const createPieConfig = (): VisualizationTypeOptions => ({ + name: 'pie', + title: 'Pie', + icon: 'visPie', + description: 'Display pie chart', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visBuilder.pie.metricTitle', { + defaultMessage: 'Slice size', + }), + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visBuilder.pie.groupTitle', { + defaultMessage: 'Split slices', + }), + min: 0, + max: Infinity, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visBuilder.pie.splitTitle', { + defaultMessage: 'Split chart', + }), + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: true, + legendPosition: Positions.RIGHT, + isDonut: true, + showMetricsAtAllLevels: true, + type: 'pie', + labels: { + show: false, + values: true, + last_level: true, + truncate: 100, + }, + }, + render: PieVisOptions, + }, + }, + }, + hierarchicalData: true, +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts new file mode 100644 index 000000000000..ca70c19478a7 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toExpression } from './to_expression'; +import * as expressionHelpers from '../../common/expression_helpers'; +import * as vegaSpecFactory from '../../vega/vega_spec_factory'; +import * as expressionHelper from '../../vega/utils/expression_helper'; +import * as createVis from '../common/create_vis'; +import * as visualizationsPublic from '../../../../../visualizations/public'; +import * as expressionsPublic from '../../../../../expressions/public'; + +jest.mock('../../common/expression_helpers'); +jest.mock('../../vega/vega_spec_factory'); +jest.mock('../../vega/utils/expression_helper'); +jest.mock('../common/create_vis'); +jest.mock('../../../../../visualizations/public'); +jest.mock('../../../../../expressions/public'); + +jest.mock('../../../plugin_services', () => ({ + getSearchService: jest.fn(() => ({ + aggs: { + createAggConfigs: jest.fn(), + }, + })), + getTimeFilter: jest.fn(() => ({ + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + })), +})); + +describe('pie/to_expression.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate vega expression for pie chart', async () => { + const mockState = { + style: { + addLegend: true, + addTooltip: true, + showMetricsAtAllLevels: true, + isDonut: false, + legendPosition: 'right', + type: 'pie', + }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.getVisSchemas as jest.Mock).mockReturnValue({ + metric: [{ label: 'Metric' }], + group: [{ label: 'Group' }], + split_row: [{ label: 'Split Row' }], + split_column: [{ label: 'Split Column' }], + }); + + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue('rawData | mockFn1 | mockFn2'), + }); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + (expressionHelper.executeExpression as jest.Mock).mockResolvedValue({ someData: 'value' }); + (vegaSpecFactory.createVegaSpec as jest.Mock).mockReturnValue({ spec: 'mockVegaSpec' }); + + (visualizationsPublic.buildPipeline as jest.Mock).mockResolvedValue('vega | mockVegaSpec'); + + const result = await toExpression(mockState as any, mockSearchContext as any); + + expect(result).toBe('vega | mockVegaSpec'); + expect(expressionHelpers.getAggExpressionFunctions).toHaveBeenCalledWith( + mockState.visualization, + mockState.style, + true, + mockSearchContext + ); + expect(createVis.createVis).toHaveBeenCalledWith('pie', {}, {}, mockSearchContext); + expect(visualizationsPublic.getVisSchemas).toHaveBeenCalled(); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith('rawData', {}); + expect(expressionHelper.executeExpression).toHaveBeenCalledWith( + 'rawData | mockFn1 | mockFn2', + mockSearchContext + ); + expect(vegaSpecFactory.createVegaSpec).toHaveBeenCalledWith( + { someData: 'value' }, + expect.objectContaining({ + addLegend: true, + addTooltip: true, + isDonut: false, + legendPosition: 'right', + dimensions: expect.any(Object), + showMetricsAtAllLevels: true, + }), + mockState.style + ); + expect(visualizationsPublic.buildPipeline).toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const mockState = { + style: { type: 'pie' }, + visualization: {}, + }; + const mockSearchContext = {}; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockRejectedValue( + new Error('Mock error') + ); + + await expect(toExpression(mockState as any, mockSearchContext as any)).rejects.toThrow( + 'Mock error' + ); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts new file mode 100644 index 000000000000..4f70e2cce76b --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVislibDimensions, getVisSchemas } from '../../../../../visualizations/public'; +import { + buildExpression, + buildExpressionFunction, + IExpressionLoaderParams, +} from '../../../../../expressions/public'; +import { PieOptionsDefaults } from './pie_vis_type'; +import { getAggExpressionFunctions } from '../../common/expression_helpers'; +import { VislibRootState, getPipelineParams } from '../common'; +import { createVis } from '../common/create_vis'; +import { buildPipeline } from '../../../../../visualizations/public'; +import { createVegaSpec } from '../../vega/vega_spec_factory'; +import { executeExpression } from '../../vega/utils/expression_helper'; + +export const toExpression = async ( + { style: styleState, visualization }: VislibRootState, + searchContext: IExpressionLoaderParams['searchContext'] +) => { + const { expressionFns, aggConfigs, indexPattern } = await getAggExpressionFunctions( + visualization, + styleState, + true, + searchContext + ); + const { + addLegend, + addTooltip, + showMetricsAtAllLevels, + isDonut, + legendPosition, + type, + } = styleState; + const vis = await createVis(type, aggConfigs, indexPattern, searchContext); + const params = getPipelineParams(); + const schemas = getVisSchemas(vis, { + timeRange: params.timeRange, + timefilter: params.timefilter, + }); + + const dimensions = { + metric: schemas.metric[0], + buckets: schemas.group, + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }; + + const visConfig = { + addLegend, + addTooltip, + isDonut, + legendPosition, + dimensions, + showMetricsAtAllLevels, + }; + + const rawDataFn = buildExpressionFunction('rawData', {}); + const dataExpression = buildExpression([...expressionFns, rawDataFn]).toString(); + // Execute the expression to get the raw data + const rawData = await executeExpression(dataExpression, searchContext); + + const vegaSpec = createVegaSpec(rawData, visConfig, styleState); + + const visVega = await createVis('vega', aggConfigs, indexPattern, searchContext); + visVega.params = { + spec: JSON.stringify(vegaSpec), + }; + + const vegaExpression = await buildPipeline(visVega, { + timefilter: params.timefilter, + timeRange: params.timeRange, + abortSignal: undefined, + visLayers: undefined, + visAugmenterConfig: undefined, + }); + + return vegaExpression; +}; diff --git a/src/plugins/vis_builder/server/plugin.ts b/src/plugins/vis_builder/server/plugin.ts index 35d3f4b89c20..a9e48d1bc2b7 100644 --- a/src/plugins/vis_builder/server/plugin.ts +++ b/src/plugins/vis_builder/server/plugin.ts @@ -44,6 +44,7 @@ export class VisBuilderPlugin implements Plugin