Skip to content

Commit

Permalink
[VisBuilder-Next] Pie Chart Integration for VisBuilder
Browse files Browse the repository at this point in the history
This PR integrates pie charts into VisBuilder using Vega rendering.

Issue Resolve:
#7607

Signed-off-by: Anan Zhuang <[email protected]>
  • Loading branch information
ananzh committed Aug 20, 2024
1 parent 389ad1b commit 5e59033
Show file tree
Hide file tree
Showing 27 changed files with 1,047 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,6 +40,7 @@ export const DataTab = () => {
data: {
search: { aggs: aggService },
},
notifications: { toasts },
},
} = useOpenSearchDashboards<VisBuilderServices>();

Expand Down Expand Up @@ -76,6 +78,18 @@ 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 (Object.values(FIELD_SELECTOR_ID).includes(sourceSchemaName as FIELD_SELECTOR_ID)) {
if (panelGroups.includes(destinationSchemaName) && !combine) {
addFieldToConfiguration({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -50,5 +51,18 @@ export const validateAggregations = (aggs: AggConfig[]): ValidationResult => {
};
}

const splitSchema = schemas?.find((s) => s.name === 'split');
if (splitSchema && 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 };
};
3 changes: 2 additions & 1 deletion src/plugins/vis_builder/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export interface VisualizationTypeOptions<T = any> {
searchContext: IExpressionLoaderParams['searchContext'],
useVega: boolean
) => Promise<string | undefined>;
readonly hierarchicalData?: boolean | ((vis: { params: T }) => boolean);
}
15 changes: 12 additions & 3 deletions src/plugins/vis_builder/public/visualizations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@
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';

export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) {
const visualizationTypes = [
export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup, useVega: boolean) {
const defaultVisualizationTypes = [
createHistogramConfig,
createLineConfig,
createAreaConfig,
createMetricConfig,
createTableConfig,
];

const visualizationTypes = useVega
? [...defaultVisualizationTypes, createPieConfig]
: defaultVisualizationTypes;

visualizationTypes.forEach((createTypeConfig) => {
typeServiceSetup.createVisualizationType(createTypeConfig());
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { AxisFormats } from '../utils/types';
import { buildAxes } from './axes';
import { buildAxes } from '../axes';

export type VegaMarkType =
| 'line'
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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']);
expect(result).toHaveLength(2);
});

it('should create arc marks with correct properties', () => {
const result = buildArcMarks(['level1']);
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();
});
});
Original file line number Diff line number Diff line change
@@ -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}`,
},
}
: {}),
},
},
}));
};
Loading

0 comments on commit 5e59033

Please sign in to comment.