diff --git a/changelogs/fragments/10697.yml b/changelogs/fragments/10697.yml new file mode 100644 index 000000000000..e6d2b1bcf7e6 --- /dev/null +++ b/changelogs/fragments/10697.yml @@ -0,0 +1,2 @@ +feat: +- Add bar gauge ([#10697](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10697)) \ No newline at end of file diff --git a/src/plugins/explore/public/components/visualizations/bar/to_expression.ts b/src/plugins/explore/public/components/visualizations/bar/to_expression.ts index 3eb7f67871b0..3043a49521e0 100644 --- a/src/plugins/explore/public/components/visualizations/bar/to_expression.ts +++ b/src/plugins/explore/public/components/visualizations/bar/to_expression.ts @@ -120,14 +120,6 @@ export const createBarSpec = ( : undefined, data: { values: transformedData }, layer: layers, - // Add legend configuration if needed, or explicitly set to null if disabled - legend: styles.addLegend - ? { - orient: styles.legendPosition?.toLowerCase() || 'right', - title: styles.legendTitle, - symbolType: styles.legendShape ?? 'circle', - } - : null, }; }; diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.test.tsx b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.test.tsx new file mode 100644 index 000000000000..0613883f0805 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { BarGaugeExclusiveVisOptions } from './bar_gauge_exclusive_vis_options'; +import { ExclusiveBarGaugeConfig } from './bar_gauge_vis_config'; + +jest.mock('@osd/i18n', () => ({ + i18n: { + translate: jest.fn().mockImplementation((id, { defaultMessage }) => defaultMessage), + }, +})); + +describe('BarGaugeExclusiveVisOptions', () => { + const defaultStyles: ExclusiveBarGaugeConfig = { + orientation: 'vertical', + displayMode: 'gradient', + valueDisplay: 'valueColor', + showUnfilledArea: true, + }; + + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call onChange when orientation is changed', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Horizontal')); + + expect(mockOnChange).toHaveBeenCalledWith({ + ...defaultStyles, + orientation: 'horizontal', + }); + }); + + it('should call onChange when display mode is changed', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Stack')); + + expect(mockOnChange).toHaveBeenCalledWith({ + ...defaultStyles, + displayMode: 'stack', + }); + }); + + it('should call onChange when value display is changed', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Text Color')); + + expect(mockOnChange).toHaveBeenCalledWith({ + ...defaultStyles, + valueDisplay: 'textColor', + }); + }); + + it('should call onChange when unfilled area switch is toggled', () => { + const { getByRole } = render( + + ); + + const switchElement = getByRole('switch'); + fireEvent.click(switchElement); + + expect(mockOnChange).toHaveBeenCalledWith({ + ...defaultStyles, + showUnfilledArea: false, + }); + }); + + it('should handle undefined styles with defaults', () => { + const { getByText } = render( + + ); + + fireEvent.click(getByText('Horizontal')); + + expect(mockOnChange).toHaveBeenCalledWith({ + orientation: 'horizontal', + }); + }); +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.tsx b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.tsx new file mode 100644 index 000000000000..d214df284518 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_exclusive_vis_options.tsx @@ -0,0 +1,173 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { EuiSwitch, EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import React from 'react'; +import { BarGaugeChartStyle } from './bar_gauge_vis_config'; +import { StyleAccordion } from '../style_panel/style_accordion'; + +interface BarGaugeVisOptionsProps { + styles: BarGaugeChartStyle['exclusive']; + onChange: (styles: BarGaugeChartStyle['exclusive']) => void; + isXaxisNumerical: boolean; +} + +const displayModeOption = [ + { + id: 'gradient', + label: i18n.translate('explore.vis.barGauge.displayMode.gradient', { + defaultMessage: 'Gradient', + }), + }, + { + id: 'stack', + label: i18n.translate('explore.vis.barGauge.displayMode.stack', { + defaultMessage: 'Stack', + }), + }, + { + id: 'basic', + label: i18n.translate('explore.vis.barGauge.displayMode.basic', { + defaultMessage: 'Basic', + }), + }, +]; + +const valueDisplayOption = [ + { + id: 'valueColor', + label: i18n.translate('explore.vis.barGauge.valueDisplay.valueColor', { + defaultMessage: 'Value Color', + }), + }, + { + id: 'textColor', + label: i18n.translate('explore.vis.barGauge.valueDisplay.textColor', { + defaultMessage: 'Text Color', + }), + }, + { + id: 'hidden', + label: i18n.translate('explore.vis.barGauge.valueDisplay.hidden', { + defaultMessage: 'Hidden', + }), + }, +]; + +export const BarGaugeExclusiveVisOptions = ({ + styles, + onChange, + isXaxisNumerical, +}: BarGaugeVisOptionsProps) => { + const getOrientationOptions = () => { + const horizontalLabel = i18n.translate('explore.vis.barGauge.orientation.horizontal', { + defaultMessage: 'Horizontal', + }); + const verticalLabel = i18n.translate('explore.vis.barGauge.orientation.vertical', { + defaultMessage: 'Vertical', + }); + + // When X-axis is numerical, the labels are swapped + const verticalOptionLabel = isXaxisNumerical ? horizontalLabel : verticalLabel; + const horizontalOptionLabel = isXaxisNumerical ? verticalLabel : horizontalLabel; + + return [ + { id: 'vertical', label: verticalOptionLabel }, + { id: 'horizontal', label: horizontalOptionLabel }, + ]; + }; + + const orientationOption = getOrientationOptions(); + + const updateExclusiveOption = (key: keyof BarGaugeChartStyle['exclusive'], value: any) => { + onChange({ + ...styles, + [key]: value, + }); + }; + + return ( + + + { + updateExclusiveOption('orientation', optionId); + }} + type="single" + idSelected={styles?.orientation ?? 'vertical'} + buttonSize="compressed" + /> + + + + { + updateExclusiveOption('displayMode', optionId); + }} + type="single" + idSelected={styles?.displayMode ?? 'gradient'} + buttonSize="compressed" + /> + + + + { + updateExclusiveOption('valueDisplay', optionId); + }} + type="single" + idSelected={styles?.valueDisplay ?? 'valueColor'} + buttonSize="compressed" + /> + + + + updateExclusiveOption('showUnfilledArea', e.target.checked)} + /> + + + ); +}; diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts new file mode 100644 index 000000000000..e28927e81c5f --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getBarOrientation, + thresholdsToGradient, + symbolOpposite, + getGradientConfig, +} from './bar_gauge_utils'; +import { AxisColumnMappings, Threshold, VisFieldType } from '../types'; +import { BarGaugeChartStyle } from './bar_gauge_vis_config'; + +describe('bar_gauge_utils', () => { + describe('getBarOrientation', () => { + const mockAxisColumnMappings: AxisColumnMappings = { + x: { + id: 1, + name: 'xAxis', + schema: VisFieldType.Categorical, + column: 'x', + validValuesCount: 10, + uniqueValuesCount: 5, + }, + y: { + id: 2, + name: 'yAxis', + schema: VisFieldType.Numerical, + column: 'y', + validValuesCount: 10, + uniqueValuesCount: 8, + }, + }; + + it('should return swapped axes for horizontal orientation', () => { + const styles = { exclusive: { orientation: 'horizontal' as const } } as BarGaugeChartStyle; + + const result = getBarOrientation(styles, mockAxisColumnMappings); + + expect(result.xAxis).toBe(mockAxisColumnMappings.y); + expect(result.yAxis).toBe(mockAxisColumnMappings.x); + expect(result.yAxisStyle).toEqual({ + axis: { tickOpacity: 0, grid: false, title: null, labelAngle: 0, labelOverlap: 'greedy' }, + }); + expect(result.xAxisStyle).toEqual({ + axis: null, + }); + }); + + it('should return normal axes for vertical orientation', () => { + const styles = { exclusive: { orientation: 'vertical' as const } } as BarGaugeChartStyle; + + const result = getBarOrientation(styles, mockAxisColumnMappings); + + expect(result.xAxis).toBe(mockAxisColumnMappings.x); + expect(result.yAxis).toBe(mockAxisColumnMappings.y); + expect(result.xAxisStyle).toEqual({ + axis: { tickOpacity: 0, grid: false, title: null, labelAngle: 0, labelOverlap: 'greedy' }, + }); + expect(result.yAxisStyle).toEqual({ axis: null }); + }); + }); + + describe('thresholdsToGradient', () => { + it('should convert thresholds to gradient format', () => { + const thresholds: Threshold[] = [ + { value: 10, color: '#red' }, + { value: 20, color: '#green' }, + { value: 30, color: '#blue' }, + ]; + + const result = thresholdsToGradient(thresholds); + + expect(result).toEqual([ + { calculate: '10', as: 'threshold0' }, + { calculate: '20', as: 'threshold1' }, + { calculate: '30', as: 'threshold2' }, + ]); + }); + + it('should handle empty thresholds array', () => { + const result = thresholdsToGradient([]); + expect(result).toEqual([]); + }); + }); + + describe('symbolOpposite', () => { + it('should return opposite symbol for horizontal orientation', () => { + expect(symbolOpposite('horizontal', 'x')).toBe('y'); + expect(symbolOpposite('horizontal', 'y')).toBe('x'); + }); + + it('should return same symbol for vertical orientation', () => { + expect(symbolOpposite('vertical', 'x')).toBe('x'); + expect(symbolOpposite('vertical', 'y')).toBe('y'); + }); + }); + + describe('getGradientConfig', () => { + it('returns horizontal gradient for non-numerical x-axis with horizontal orientation', () => { + const result = getGradientConfig('horizontal', 'gradient', false); + expect(result).toEqual({ x1: 0, y1: 0, x2: 1, y2: 0 }); + }); + + it('returns horizontal gradient for numerical x-axis with non-horizontal orientation', () => { + const result = getGradientConfig('vertical', 'gradient', true); + expect(result).toEqual({ x1: 0, y1: 0, x2: 1, y2: 0 }); + }); + + it('returns vertical gradient for numerical x-axis with horizontal orientation', () => { + const result = getGradientConfig('horizontal', 'gradient', true); + expect(result).toEqual({ x1: 1, y1: 1, x2: 1, y2: 0 }); + }); + + it('returns vertical gradient for non-numerical x-axis with non-horizontal orientation', () => { + const result = getGradientConfig('vertical', 'gradient', false); + expect(result).toEqual({ x1: 1, y1: 1, x2: 1, y2: 0 }); + }); + + it('returns undefined for non-gradient display mode', () => { + const result = getGradientConfig('horizontal', 'basic', false); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts new file mode 100644 index 000000000000..b2058503fc36 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts @@ -0,0 +1,159 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AxisColumnMappings, Threshold, VisFieldType } from '../types'; +import { BarGaugeChartStyle } from './bar_gauge_vis_config'; + +export const getBarOrientation = ( + styles: BarGaugeChartStyle, + axisColumnMappings?: AxisColumnMappings +) => { + const xAxis = axisColumnMappings?.x; + const yAxis = axisColumnMappings?.y; + const isHorizontal = styles?.exclusive.orientation === 'horizontal'; + const isXNumerical = xAxis?.schema === VisFieldType.Numerical; + + const axisStyle = { + axis: { tickOpacity: 0, grid: false, title: null, labelAngle: 0, labelOverlap: 'greedy' }, + }; + const nullStyle = { axis: null }; + + if (isHorizontal) { + return { + xAxis: yAxis, + xAxisStyle: isXNumerical ? axisStyle : nullStyle, + yAxis: xAxis, + yAxisStyle: isXNumerical ? nullStyle : axisStyle, + }; + } + + return { + xAxis, + xAxisStyle: isXNumerical ? nullStyle : axisStyle, + yAxis, + yAxisStyle: isXNumerical ? axisStyle : nullStyle, + }; +}; + +export const thresholdsToGradient = (thresholds: Threshold[]) => { + return thresholds.map((threshold: Threshold, index) => { + return { + calculate: `${threshold.value}`, + as: `threshold${index}`, + }; + }); +}; + +export const symbolOpposite = (orientationMode: string, symbol: string) => { + if (orientationMode === 'horizontal') { + return symbol === 'x' ? 'y' : 'x'; + } + return symbol; +}; + +export const getGradientConfig = ( + orientationMode: string, + displayMode: string, + isXaxisNumerical: boolean +) => { + if ( + (!isXaxisNumerical && orientationMode === 'horizontal') || + (isXaxisNumerical && orientationMode !== 'horizontal') + ) { + if (displayMode === 'gradient') + return { + x1: 0, + y1: 0, + x2: 1, + y2: 0, + }; + } + + if (displayMode === 'gradient') + return { + x1: 1, + y1: 1, + x2: 1, + y2: 0, + }; +}; + +export const processThresholds = (thresholds: Threshold[]) => { + const result: Threshold[] = []; + + for (let i = 0; i < thresholds.length; i++) { + const current = thresholds[i]; + const next = thresholds[i + 1]; + + // if the next threshold has the same value, use next + if (next && next.value === current.value) continue; + + result.push(current); + } + + return result; +}; + +export const normalizeData = (data: number, start: number, end: number) => { + if (start === end) return null; + // normalize data value between start and end into 0–1 range + return (data - start) / (end - start); +}; + +export const generateParams = ( + thresholds: Threshold[], + styleOptions: BarGaugeChartStyle, + isXaxisNumerical: boolean +) => { + const result: any[] = []; + + for (let i = 0; i < thresholds.length; i++) { + const start = thresholds[0].value; + + const end = thresholds[i].value; + + if (i === 0) { + result.push({ + name: `gradient${i}`, + value: thresholds[0]?.color, + }); + continue; + } + + const allStops = thresholds.slice(0, i + 1).map((t) => ({ + offset: normalizeData(t.value, start, end), + color: t.color, + })); + + const stops = []; + for (let j = 0; j < allStops.length; j++) { + const curr = allStops[j]; + const prev = allStops[j - 1]; + + if (j === 0 || j === allStops.length - 1 || curr.color !== prev?.color) { + stops.push(curr); + } + } + + if (stops.length > 2 && stops[stops.length - 1].color === stops[stops.length - 2].color) { + stops.splice(stops.length - 2, 1); + } + + result.push({ + name: `gradient${i}`, + value: { + gradient: 'linear', + ...getGradientConfig( + styleOptions.exclusive.orientation, + styleOptions.exclusive.displayMode, + isXaxisNumerical + ), + stops, + }, + }); + } + + return result; +}; diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.test.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.test.ts new file mode 100644 index 000000000000..788cb37c7263 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { createBarGaugeConfig, defaultBarGaugeChartStyles } from './bar_gauge_vis_config'; +import { BarGaugeVisStyleControls } from './bar_gauge_vis_options'; + +// Mock the React.createElement function +jest.mock('react', () => ({ + ...jest.requireActual('react'), + createElement: jest.fn(), +})); + +describe('createBarGaugeConfig', () => { + it('should create a gauge visualization type configuration', () => { + const config = createBarGaugeConfig(); + + // Verify the basic structure + expect(config).toHaveProperty('name', 'bar_gauge'); + expect(config).toHaveProperty('type', 'bar_gauge'); + expect(config).toHaveProperty('ui.style.defaults'); + expect(config).toHaveProperty('ui.style.render'); + }); + + it('should have the correct default style settings', () => { + const config = createBarGaugeConfig(); + const defaults = config.ui.style.defaults; + expect(defaults.thresholdOptions).toMatchObject({ + baseColor: '#00BD6B', + thresholds: [], + }); + expect(defaults.valueCalculation).toBe('last'); + }); + + it('should render the BarGaugeVisStyleControls component with the provided props', () => { + const config = createBarGaugeConfig(); + const renderFunction = config.ui.style.render; + // Mock props + const mockProps = { + styleOptions: defaultBarGaugeChartStyles, + onStyleChange: jest.fn(), + numericalColumns: [], + categoricalColumns: [], + dateColumns: [], + axisColumnMappings: {}, + updateVisualization: jest.fn(), + }; + // Call the render function + renderFunction(mockProps); + // Verify that React.createElement was called with the correct arguments + expect(React.createElement).toHaveBeenCalledWith(BarGaugeVisStyleControls, mockProps); + }); +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.ts new file mode 100644 index 000000000000..ac6451d9c09a --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_config.ts @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { VisualizationType } from '../utils/use_visualization_types'; +import { BarGaugeVisStyleControls } from './bar_gauge_vis_options'; +import { TitleOptions, AxisRole, VisFieldType, ThresholdOptions, TooltipOptions } from '../types'; +import { CalculationMethod } from '../utils/calculation'; +import { getColors } from '../theme/default_colors'; + +export interface ExclusiveBarGaugeConfig { + orientation: 'vertical' | 'horizontal'; + displayMode: 'gradient' | 'stack' | 'basic'; + valueDisplay: 'valueColor' | 'textColor' | 'hidden'; + showUnfilledArea: boolean; +} + +export interface BarGaugeChartStyleOptions { + tooltipOptions?: TooltipOptions; + exclusive?: ExclusiveBarGaugeConfig; + thresholdOptions?: ThresholdOptions; + valueCalculation?: CalculationMethod; + titleOptions?: TitleOptions; + min?: number; + max?: number; + unitId?: string; +} + +export type BarGaugeChartStyle = Required< + Omit +> & + Pick; + +export const defaultBarGaugeChartStyles: BarGaugeChartStyle = { + tooltipOptions: { + mode: 'all', + }, + exclusive: { + orientation: 'vertical', + displayMode: 'gradient', + valueDisplay: 'valueColor', + showUnfilledArea: true, + }, + thresholdOptions: { thresholds: [], baseColor: getColors().statusGreen }, + valueCalculation: 'last', + titleOptions: { + show: false, + titleName: '', + }, +}; + +export const createBarGaugeConfig = (): VisualizationType<'bar_gauge'> => ({ + name: 'bar_gauge', + type: 'bar_gauge', + ui: { + style: { + defaults: defaultBarGaugeChartStyles, + render: (props) => React.createElement(BarGaugeVisStyleControls, props), + }, + availableMappings: [ + { + [AxisRole.Y]: { type: VisFieldType.Numerical, index: 0 }, + [AxisRole.X]: { type: VisFieldType.Categorical, index: 0 }, + }, + { + [AxisRole.X]: { type: VisFieldType.Numerical, index: 0 }, + [AxisRole.Y]: { type: VisFieldType.Categorical, index: 0 }, + }, + ], + }, +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.test.tsx b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.test.tsx new file mode 100644 index 000000000000..654d04be1fb2 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.test.tsx @@ -0,0 +1,223 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { BarGaugeVisStyleControls, BarGaugeVisStyleControlsProps } from './bar_gauge_vis_options'; +import { defaultBarGaugeChartStyles } from './bar_gauge_vis_config'; +import { VisColumn, VisFieldType, AxisRole, AxisColumnMappings } from '../types'; + +const mockNumericalColumns: VisColumn[] = [ + { + id: 1, + name: 'Value', + schema: VisFieldType.Numerical, + column: 'value', + validValuesCount: 6, + uniqueValuesCount: 6, + }, +]; +const mockCategoricalColumns: VisColumn[] = [ + { + id: 2, + name: 'Category', + column: 'category', + schema: VisFieldType.Categorical, + validValuesCount: 100, + uniqueValuesCount: 10, + }, +]; + +const mockAxisColumnMappings: AxisColumnMappings = { + [AxisRole.X]: mockCategoricalColumns[0], + [AxisRole.Y]: mockNumericalColumns[0], +}; + +jest.mock('@osd/i18n', () => ({ + i18n: { + translate: jest.fn().mockImplementation((id, { defaultMessage }) => defaultMessage), + }, +})); + +jest.mock('../style_panel/threshold/threshold_panel', () => ({ + ThresholdPanel: jest.fn(({ thresholdsOptions, onChange }) => ( +
+ +
+ )), +})); + +jest.mock('../style_panel/tooltip/tooltip', () => ({ + TooltipOptionsPanel: jest.fn(({ tooltipOptions, onTooltipOptionsChange }) => ( +
+ +
+ )), +})); + +jest.mock('./bar_gauge_exclusive_vis_options', () => ({ + BarGaugeExclusiveVisOptions: jest.fn(({ onChange }) => ( +
+ +
+ )), +})); + +jest.mock('../style_panel/value/value_calculation_selector', () => ({ + ValueCalculationSelector: jest.fn(({ onChange }) => ( +
+ +
+ )), +})); + +jest.mock('../style_panel/style_accordion', () => ({ + StyleAccordion: jest.fn(({ children }) => ( +
{children}
+ )), +})); + +jest.mock('../style_panel/standard_options/standard_options_panel', () => ({ + StandardOptionsPanel: jest.fn(({ onMinChange, onMaxChange }) => ( +
+ + +
+ )), +})); + +jest.mock('../style_panel/title/title', () => ({ + TitleOptionsPanel: jest.fn(({ titleOptions, onShowTitleChange }) => ( +
+ + onShowTitleChange({ titleName: e.target.value })} + /> +
+ )), +})); + +describe('BarGaugeVisStyleControls', () => { + const defaultProps: BarGaugeVisStyleControlsProps = { + styleOptions: { + ...defaultBarGaugeChartStyles, + titleOptions: { + show: true, + titleName: '', + }, + }, + onStyleChange: jest.fn(), + numericalColumns: mockNumericalColumns, + categoricalColumns: mockCategoricalColumns, + dateColumns: [], + axisColumnMappings: mockAxisColumnMappings, + updateVisualization: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render style panels when axis mappings are selected', () => { + const { getByTestId } = render(); + + expect(getByTestId('mockThresholdOptions')).toBeInTheDocument(); + expect(getByTestId('mockStandardOptionsPanel')).toBeInTheDocument(); + expect(getByTestId('mockBarGaugeExclusiveVisOptions')).toBeInTheDocument(); + expect(getByTestId('mockTitleOptionsPanel')).toBeInTheDocument(); + expect(getByTestId('mockTooltipOptionsPanel')).toBeInTheDocument(); + }); + + it('should call onStyleChange when threshold is updated', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('mockUpdateThreshold')); + + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ + thresholdOptions: { + ...defaultProps.styleOptions.thresholdOptions, + thresholds: [{ value: 50, color: '#FF0000' }], + }, + }); + }); + + it('should call onStyleChange when title options are updated', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('mockTitleModeSwitch')); + + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ + titleOptions: { + ...defaultProps.styleOptions.titleOptions, + show: !defaultProps.styleOptions.titleOptions.show, + }, + }); + }); + + it('should call onStyleChange when tooltip options are updated', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('mockUpdateTooltip')); + + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ + tooltipOptions: { + ...defaultProps.styleOptions.tooltipOptions, + mode: 'hidden', + }, + }); + }); + + it('should call onStyleChange when min/max values are updated', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('mockUpdateMin')); + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ min: 10 }); + + fireEvent.click(getByTestId('mockUpdateMax')); + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ max: 100 }); + }); + + it('should call onStyleChange when orientation options are updated', () => { + const { getByTestId } = render(); + + fireEvent.click(getByTestId('mockUpdateOrientation')); + + expect(defaultProps.onStyleChange).toHaveBeenCalledWith({ + exclusive: { + orientation: 'vertical', + }, + }); + }); +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.tsx b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.tsx new file mode 100644 index 000000000000..be2dd1d70f51 --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_vis_options.tsx @@ -0,0 +1,133 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { BarGaugeChartStyle, BarGaugeChartStyleOptions } from './bar_gauge_vis_config'; +import { StyleControlsProps } from '../utils/use_visualization_types'; +import { AxesSelectPanel } from '../style_panel/axes/axes_selector'; +import { TitleOptionsPanel } from '../style_panel/title/title'; +import { ThresholdPanel } from '../style_panel/threshold/threshold_panel'; +import { TooltipOptionsPanel } from '../style_panel/tooltip/tooltip'; +import { BarGaugeExclusiveVisOptions } from './bar_gauge_exclusive_vis_options'; +import { ValueCalculationSelector } from '../style_panel/value/value_calculation_selector'; +import { StyleAccordion } from '../style_panel/style_accordion'; +import { StandardOptionsPanel } from '../style_panel/standard_options/standard_options_panel'; +import { AxisRole, VisFieldType } from '../types'; + +export type BarGaugeVisStyleControlsProps = StyleControlsProps; + +export const BarGaugeVisStyleControls: React.FC = ({ + styleOptions, + onStyleChange, + numericalColumns = [], + categoricalColumns = [], + dateColumns = [], + availableChartTypes = [], + selectedChartType, + axisColumnMappings, + updateVisualization, +}) => { + const updateStyleOption = ( + key: K, + value: BarGaugeChartStyleOptions[K] + ) => { + onStyleChange({ [key]: value }); + }; + + const hasMappingSelected = !isEmpty(axisColumnMappings); + const isXaxisNumerical = axisColumnMappings[AxisRole.X]?.schema === VisFieldType.Numerical; + + return ( + + + + + {hasMappingSelected && ( + <> + + + + updateStyleOption('valueCalculation', value)} + /> + + + + + + updateStyleOption('thresholdOptions', options)} + showThresholdStyle={false} + /> + + + updateStyleOption('min', value)} + onMaxChange={(value) => updateStyleOption('max', value)} + unit={styleOptions.unitId} + onUnitChange={(value) => updateStyleOption('unitId', value)} + /> + + + + updateStyleOption('exclusive', options)} + isXaxisNumerical={isXaxisNumerical} + /> + + + + { + updateStyleOption('titleOptions', { + ...styleOptions.titleOptions, + ...titleOptions, + }); + }} + /> + + + + + updateStyleOption('tooltipOptions', { + ...styleOptions.tooltipOptions, + ...tooltipOptions, + }) + } + /> + + + )} + + ); +}; diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.test.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.test.ts new file mode 100644 index 000000000000..c0924cca737e --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createBarGaugeSpec } from './to_expression'; +import { BarGaugeChartStyle } from './bar_gauge_vis_config'; +import { VisColumn, VisFieldType, AxisRole, AxisColumnMappings } from '../types'; + +jest.mock('../utils/calculation', () => ({ + calculateValue: jest.fn((values, method) => { + if (method === 'last') return values[values.length - 1]; + if (method === 'max') return Math.max(...values); + return values[0]; + }), +})); + +jest.mock('../theme/default_colors', () => ({ + darkenColor: jest.fn((color) => '#00000'), + getColors: jest.fn(() => ({ text: 'black', statusGreen: 'green', backgroundShade: 'grey' })), +})); + +jest.mock('../utils/utils', () => ({ + getSchemaByAxis: jest.fn((axis) => axis?.schema || 'nominal'), +})); + +describe('createBarGaugeSpec', () => { + const mockNumericalColumn: VisColumn = { + id: 1, + name: 'Value', + schema: VisFieldType.Numerical, + column: 'value', + validValuesCount: 5, + uniqueValuesCount: 5, + }; + + const mockCategoricalColumn: VisColumn = { + id: 2, + name: 'Category', + schema: VisFieldType.Categorical, + column: 'category', + validValuesCount: 3, + uniqueValuesCount: 3, + }; + + const mockAxisColumnMappings: AxisColumnMappings = { + [AxisRole.X]: mockCategoricalColumn, + [AxisRole.Y]: mockNumericalColumn, + }; + + const mockStyleOptions: BarGaugeChartStyle = { + tooltipOptions: { mode: 'all' }, + exclusive: { + orientation: 'vertical', + displayMode: 'gradient', + valueDisplay: 'valueColor', + showUnfilledArea: true, + }, + thresholdOptions: { + baseColor: '#green', + thresholds: [ + { value: 50, color: '#yellow' }, + { value: 80, color: '#red' }, + ], + }, + valueCalculation: 'last', + titleOptions: { show: true, titleName: 'Test Chart' }, + }; + + const mockTransformedData = [ + { category: 'A', value: 30 }, + { category: 'A', value: 40 }, + { category: 'B', value: 60 }, + { category: 'B', value: 70 }, + { category: 'C', value: 90 }, + ]; + + it('should create basic bar gauge spec', () => { + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + mockStyleOptions, + mockAxisColumnMappings + ); + + expect(spec).toHaveProperty('$schema'); + expect(spec).toHaveProperty('data.values'); + expect(spec).toHaveProperty('layer'); + expect(spec.title).toBe('Test Chart'); + }); + + it('should handle horizontal orientation', () => { + const horizontalStyle = { + ...mockStyleOptions, + exclusive: { + ...mockStyleOptions.exclusive, + orientation: 'horizontal' as any, + }, + }; + + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + horizontalStyle, + mockAxisColumnMappings + ); + + expect(spec.encoding.x.field).toBe('value'); + }); + + it('should handle hidden tooltips', () => { + const noTooltipStyle = { + ...mockStyleOptions, + tooltipOptions: { mode: 'hidden' as any }, + }; + + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + noTooltipStyle, + mockAxisColumnMappings + ); + + expect(spec.encoding.tooltip).toBeUndefined(); + }); + + it('should show unfilled area', () => { + const noUnfilledStyle = { + ...mockStyleOptions, + exclusive: { ...mockStyleOptions.exclusive, showUnfilledArea: true }, + }; + + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + noUnfilledStyle, + mockAxisColumnMappings + ); + + expect(spec.layer[0]).toMatchObject({ + mark: { + type: 'bar', + fill: 'grey', + }, + encoding: { + y: { + type: 'quantitative', + field: 'maxVal', + }, + }, + }); + }); + + it('should handle auto name placement', () => { + const hiddenNameStyle = { + ...mockStyleOptions, + exclusive: { ...mockStyleOptions.exclusive, namePlacement: 'auto' as any }, + }; + + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + hiddenNameStyle, + mockAxisColumnMappings + ); + + expect(spec.layer[spec.layer.length - 1].mark.type).toBe('text'); + }); + + it('should handle no title', () => { + const noTitleStyle = { + ...mockStyleOptions, + titleOptions: { show: false, titleName: '' }, + }; + + const spec = createBarGaugeSpec( + mockTransformedData, + [mockNumericalColumn], + [mockCategoricalColumn], + [], + noTitleStyle, + mockAxisColumnMappings + ); + + expect(spec.title).toBeUndefined(); + }); +}); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts new file mode 100644 index 000000000000..3ec00998c83b --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts @@ -0,0 +1,372 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { groupBy } from 'lodash'; +import { BarGaugeChartStyle } from './bar_gauge_vis_config'; +import { VisColumn, AxisColumnMappings, VEGASCHEMA, Threshold, VisFieldType } from '../types'; +import { calculateValue } from '../utils/calculation'; +import { getColors } from '../theme/default_colors'; +import { getSchemaByAxis } from '../utils/utils'; +import { + getBarOrientation, + thresholdsToGradient, + symbolOpposite, + processThresholds, + generateParams, +} from './bar_gauge_utils'; +import { getUnitById, showDisplayValue } from '../style_panel/unit/collection'; + +export const createBarGaugeSpec = ( + transformedData: Array>, + numericalColumns: VisColumn[], + categoricalColumns: VisColumn[], + dateColumns: VisColumn[], + styleOptions: BarGaugeChartStyle, + axisColumnMappings?: AxisColumnMappings +): any => { + const { xAxis, xAxisStyle, yAxis, yAxisStyle } = getBarOrientation( + styleOptions, + axisColumnMappings + ); + + const isXaxisNumerical = axisColumnMappings?.x?.schema === 'numerical'; + const adjustEncoding = xAxis?.schema === VisFieldType.Numerical; + + const processedSymbol = [ + `${symbolOpposite(styleOptions.exclusive.orientation, `${isXaxisNumerical ? 'x' : 'y'}`)}`, + ]; + + const numericalMapping = xAxis?.schema === 'numerical' ? xAxis : yAxis; + const numericField = numericalMapping?.column; + const catField = xAxis?.schema === 'numerical' ? yAxis?.column : xAxis?.column; + const catCounts = + xAxis?.schema === 'numerical' ? yAxis?.uniqueValuesCount : xAxis?.uniqueValuesCount; + + const groups = catField ? groupBy(transformedData, (item) => item[catField]) : []; + + const newRecord = []; + let maxNumber: number = 0; + let maxTextLength: number = 0; + + const selectedUnit = getUnitById(styleOptions?.unitId); + + for (const g1 of Object.values(groups)) { + if (numericField) { + const calculate = calculateValue( + g1.map((d) => d[numericField]), + styleOptions.valueCalculation + ); + if (calculate) { + maxNumber = Math.max(maxNumber, calculate); + } + + const isValidNumber = + calculate !== undefined && typeof calculate === 'number' && !isNaN(calculate); + + const displayValue = showDisplayValue(isValidNumber, selectedUnit, calculate); + maxTextLength = Math.max(maxTextLength, String(displayValue).length); + newRecord.push({ + ...g1[0], + [numericField]: calculate, + displayValue, + }); + } + } + + const validThresholds = [ + { value: styleOptions?.min ?? 0, color: styleOptions.thresholdOptions.baseColor } as Threshold, + ...(styleOptions.thresholdOptions.thresholds?.filter( + (t) => + t.value <= maxNumber && + t.value >= (styleOptions.min ?? 0) && + t.value <= (styleOptions.max ?? Infinity) + ) ?? []), + ] as Threshold[]; + + // transfer value to threshold + const valueToThreshold = []; + + for (const record of newRecord) { + for (let i = validThresholds.length - 1; i >= 0; i--) { + if (numericField && record[numericField] >= validThresholds[i].value) { + valueToThreshold.push({ value: record[numericField], color: validThresholds[i].color }); + break; + } + } + } + + // only use value-based thresholds in gradient mode + const finalThreshold = styleOptions?.exclusive.displayMode === 'gradient' ? valueToThreshold : []; + + const completeThreshold = [ + ...validThresholds, + ...((styleOptions?.max && styleOptions?.min && styleOptions.max <= styleOptions.min) || + (styleOptions?.min && styleOptions.min > maxNumber) + ? [] + : finalThreshold), + ].sort((a, b) => a.value - b.value); + + const processedThresholds = processThresholds(completeThreshold); + + const gradientParams = generateParams(processedThresholds, styleOptions, isXaxisNumerical); + + const maxBase = styleOptions?.max ?? maxNumber; + const minBase = styleOptions?.min ?? 0; + + const invalidCase = minBase >= maxBase || minBase > maxNumber; + + const scaleType = !invalidCase + ? { + scale: { + domainMin: { expr: 'minBase' }, + }, + } + : {}; + + const params = [ + { name: 'fontColor', value: getColors().text }, + { + name: 'maxBase', + value: maxBase, + }, + { + name: 'minBase', + value: minBase, + }, + { + name: 'fontFactor', + expr: !adjustEncoding + ? `width/${catCounts}/${maxTextLength}/8` + : `height/${catCounts}/${maxTextLength}/10`, + }, + ...gradientParams, + ]; + + const transformLayer = [ + { + calculate: 'maxBase', + as: 'maxVal', + }, + { + calculate: 'minBase', + as: 'minVal', + }, + ...thresholdsToGradient(processedThresholds), + ]; + + const encodingLayer = { + y: { + field: yAxis?.column, + type: getSchemaByAxis(yAxis), + ...yAxisStyle, + ...(!adjustEncoding ? scaleType : {}), + }, + x: { + field: xAxis?.column, + type: getSchemaByAxis(xAxis), + ...xAxisStyle, + ...(adjustEncoding ? scaleType : {}), + }, + ...(styleOptions.tooltipOptions?.mode !== 'hidden' && { + tooltip: [ + { + field: yAxis?.column, + type: getSchemaByAxis(yAxis), + title: yAxis?.name, + }, + { + field: xAxis?.column, + type: getSchemaByAxis(xAxis), + title: xAxis?.name, + }, + ], + }), + }; + + const layers: any[] = []; + + if (styleOptions.exclusive.showUnfilledArea) { + layers.push({ + mark: { + type: 'bar', + clip: true, + fill: getColors().backgroundShade, + }, + encoding: { + [`${processedSymbol}`]: { + type: 'quantitative', + field: 'maxVal', + }, + }, + }); + } + + const generateTrans = (thresholds: Threshold[]) => { + let expression = ''; + + for (let i = thresholds.length - 1; i >= 1; i--) { + expression += `datum['${numericField}'] >= datum.threshold${i} ? gradient${i} : `; + } + + expression += `gradient0`; + + return expression; + }; + + let bars = [] as any; + + // Handle invalid domain cases (minBase >= maxBase or minBase > maxNumber) + // invalid cases will not add the layer . + if (invalidCase) { + bars = []; + } else if (styleOptions.exclusive.displayMode === 'stack') { + bars = processedThresholds.map((threshold, index) => { + return { + transform: [ + { calculate: `datum.threshold${index}`, as: 'gradStart' }, + { + calculate: `datum.threshold${index + 1}|| maxBase`, + as: 'gradEndTemp', + }, + { + calculate: `datum['${numericField}'] < datum.gradEndTemp ? datum['${numericField}']: datum.gradEndTemp`, + as: 'gradEnd', + }, + { filter: `datum['${numericField}'] >= datum.threshold${index}` }, + ], + layer: [ + { + mark: { + type: 'bar', + clip: true, + color: threshold.color, + }, + encoding: { + [`${processedSymbol}`]: { + type: 'quantitative', + field: 'gradEnd', + }, + + [`${processedSymbol}2`]: { + field: 'gradStart', + }, + }, + }, + ], + }; + }); + } else if (styleOptions.exclusive.displayMode === 'gradient') { + bars = [ + { + mark: { type: 'bar', clip: true }, + transform: [ + { + calculate: `datum['${numericField}']>=datum.maxVal?datum.maxVal:datum['${numericField}']`, + as: 'barEnd', + }, + { filter: `datum['${numericField}'] >= datum.minVal` }, + ], + encoding: { + [`${processedSymbol}`]: { + type: 'quantitative', + field: 'barEnd', + }, + color: { + value: { + expr: generateTrans(processedThresholds), + }, + }, + }, + }, + ]; + } else if (styleOptions.exclusive.displayMode === 'basic') { + bars = [ + { + mark: { type: 'bar', clip: true }, + transform: [ + { + calculate: `datum['${numericField}']>=datum.maxVal?datum.maxVal:datum['${numericField}']`, + as: 'barEnd', + }, + { filter: `datum['${numericField}'] >= datum.minVal` }, + ], + encoding: { + [`${processedSymbol}`]: { + type: 'quantitative', + field: 'barEnd', + }, + color: { + field: numericField, + type: 'quantitative', + scale: { + type: 'threshold', + // last threshold which is just for max value capping in gradient mode + domain: processedThresholds.map((t) => t.value), + range: [getColors().backgroundShade, ...processedThresholds.map((t) => t.color)], + }, + legend: null, + }, + }, + }, + ]; + } + + layers.push(...bars); + + if (styleOptions.exclusive.valueDisplay !== 'hidden') { + const nameLayer = { + mark: { + type: 'text', + ...(adjustEncoding + ? { + align: 'left', + baseline: 'middle', + } + : { baseline: 'bottom' }), + + dx: adjustEncoding ? { expr: 'fontFactor*3' } : 0, + dy: adjustEncoding ? 0 : { expr: '-fontFactor*3' }, + fontSize: { expr: 'fontFactor * 10' }, + ...(styleOptions.exclusive?.valueDisplay === 'textColor' + ? { fill: { expr: 'fontColor' } } + : {}), + }, + encoding: { + [`${processedSymbol}`]: { + type: 'quantitative', + field: 'maxVal', + }, + text: { + field: 'displayValue', + }, + color: { + field: numericField, + type: 'quantitative', + scale: { + type: 'threshold', + domain: processedThresholds.map((t) => t.value), + range: [getColors().backgroundShade, ...processedThresholds.map((t) => t.color)], + }, + legend: null, + }, + }, + }; + layers.push(nameLayer); + } + + const baseSpec = { + $schema: VEGASCHEMA, + title: styleOptions.titleOptions?.show + ? styleOptions.titleOptions?.titleName || `${yAxis?.name} by ${xAxis?.name}` + : undefined, + data: { values: newRecord }, + params, + transform: transformLayer, + encoding: encodingLayer, + layer: layers, + }; + + return baseSpec; +}; diff --git a/src/plugins/explore/public/components/visualizations/constants.ts b/src/plugins/explore/public/components/visualizations/constants.ts index 6dc151445a51..a8a9d96c9ffa 100644 --- a/src/plugins/explore/public/components/visualizations/constants.ts +++ b/src/plugins/explore/public/components/visualizations/constants.ts @@ -18,6 +18,7 @@ export const CHART_METADATA: Record = { table: { type: 'table', name: 'Table', icon: 'visTable' }, gauge: { type: 'gauge', name: 'Gauge', icon: 'visGauge' }, state_timeline: { type: 'state_timeline', name: 'State timeline', icon: 'visBarHorizontal' }, + bar_gauge: { type: 'bar_gauge', name: 'Bar Gauge', icon: 'visBarHorizontal' }, }; // Map both OSD_FIELD_TYPES and OPENSEARCH_FIELD_TYPES to VisFieldType diff --git a/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.test.tsx b/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.test.tsx index 027f7df767da..a2dd2905f2a6 100644 --- a/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.test.tsx +++ b/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.test.tsx @@ -14,16 +14,6 @@ jest.mock('@osd/i18n', () => ({ }, })); -jest.mock('../style_panel/unit/unit_panel', () => ({ - UnitPanel: jest.fn(({ unitId, onUnitChange }) => ( -
- -
- )), -})); - jest.mock('../style_panel/threshold/threshold_panel', () => ({ ThresholdPanel: jest.fn(({ thresholdsOptions, onChange }) => ( <> @@ -42,7 +32,7 @@ jest.mock('../style_panel/threshold/threshold_panel', () => ({ })); jest.mock('../style_panel/standard_options/standard_options_panel', () => ({ - StandardOptionsPanel: jest.fn(({ min, onMinChange, max, onMaxChange }) => ( + StandardOptionsPanel: jest.fn(({ min, onMinChange, max, onMaxChange, unit, onUnitChange }) => (
({ data-test-subj="thresholdMaxBase" onChange={(e) => onMaxChange(Number(e.target.value))} /> +
+ +
)), })); @@ -182,11 +177,6 @@ describe('GaugeVisStyleControls', () => { }); }); - it('renders unit panel', () => { - render(); - expect(screen.getByTestId('mockGaugeUnitPanel')).toBeInTheDocument(); - }); - it('calls onStyleChange when unit is changed', () => { render(); const unitSelect = screen.getByTestId('changeUnit'); diff --git a/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.tsx b/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.tsx index 28ce3f2e2847..cec360b907cb 100644 --- a/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.tsx +++ b/src/plugins/explore/public/components/visualizations/gauge/gauge_vis_options.tsx @@ -15,7 +15,6 @@ import { StyleAccordion } from '../style_panel/style_accordion'; import { AxesSelectPanel } from '../style_panel/axes/axes_selector'; import { DebouncedFieldText } from '../style_panel/utils'; import { ValueCalculationSelector } from '../style_panel/value/value_calculation_selector'; -import { UnitPanel } from '../style_panel/unit/unit_panel'; import { StandardOptionsPanel } from '../style_panel/standard_options/standard_options_panel'; export type GaugeVisStyleControlsProps = StyleControlsProps; @@ -76,12 +75,6 @@ export const GaugeVisStyleControls: React.FC = ({ - - updateStyleOption('unitId', value)} - /> - = ({ max={styleOptions.max} onMinChange={(value) => updateStyleOption('min', value)} onMaxChange={(value) => updateStyleOption('max', value)} + unit={styleOptions.unitId} + onUnitChange={(value) => updateStyleOption('unitId', value)} /> diff --git a/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.test.tsx b/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.test.tsx index 10fa6ec41dd8..3a6a6062816b 100644 --- a/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.test.tsx +++ b/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.test.tsx @@ -14,12 +14,22 @@ jest.mock('@osd/i18n', () => ({ }, })); -jest.mock('../style_panel/unit/unit_panel', () => ({ - UnitPanel: jest.fn(({ unitId, onUnitChange }) => ( -
- +jest.mock('../style_panel/standard_options/standard_options_panel', () => ({ + StandardOptionsPanel: jest.fn(({ min, onMinChange, max, onMaxChange, unit, onUnitChange }) => ( +
+ onMinChange(Number(e.target.value))} + /> + onMaxChange(Number(e.target.value))} + /> +
+ +
)), })); @@ -173,9 +183,9 @@ describe('MetricVisStyleControls', () => { expect(titleInput).toHaveValue(''); }); - it('renders unit panel', () => { + it('renders standard panel', () => { render(); - expect(screen.getByTestId('mockMetricUnitPanel')).toBeInTheDocument(); + expect(screen.getByTestId('mockStandardPanel')).toBeInTheDocument(); }); it('calls onStyleChange when unit is changed', () => { diff --git a/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.tsx b/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.tsx index adc72a084d71..da5070a551a1 100644 --- a/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.tsx +++ b/src/plugins/explore/public/components/visualizations/metric/metric_vis_options.tsx @@ -15,7 +15,6 @@ import { StyleAccordion } from '../style_panel/style_accordion'; import { AxesSelectPanel } from '../style_panel/axes/axes_selector'; import { ValueCalculationSelector } from '../style_panel/value/value_calculation_selector'; import { PercentageSelector } from '../style_panel/percentage/percentage_selector'; -import { UnitPanel } from '../style_panel/unit/unit_panel'; import { ThresholdPanel } from '../style_panel/threshold/threshold_panel'; import { StandardOptionsPanel } from '../style_panel/standard_options/standard_options_panel'; @@ -79,12 +78,6 @@ export const MetricVisStyleControls: React.FC = ({ - - updateStyleOption('unitId', value)} - /> - = ({ max={styleOptions.max} onMinChange={(value) => updateStyleOption('min', value)} onMaxChange={(value) => updateStyleOption('max', value)} + unit={styleOptions.unitId} + onUnitChange={(value) => updateStyleOption('unitId', value)} /> diff --git a/src/plugins/explore/public/components/visualizations/rule_repository.ts b/src/plugins/explore/public/components/visualizations/rule_repository.ts index 7ddb3c030603..1cce04a0f97e 100644 --- a/src/plugins/explore/public/components/visualizations/rule_repository.ts +++ b/src/plugins/explore/public/components/visualizations/rule_repository.ts @@ -45,6 +45,7 @@ import { GaugeChartStyle } from './gauge/gauge_vis_config'; import { LineChartStyle } from './line/line_vis_config'; import { MetricChartStyle } from './metric/metric_vis_config'; import { PieChartStyle } from './pie/pie_vis_config'; +import { BarGaugeChartStyle } from './bar_gauge/bar_gauge_vis_config'; import { ScatterChartStyle } from './scatter/scatter_vis_config'; import { HeatmapChartStyle } from './heatmap/heatmap_vis_config'; import { StateTimeLineChartStyle } from './state_timeline/state_timeline_config'; @@ -53,6 +54,7 @@ import { createCategoricalStateTimeline, createSingleCategoricalStateTimeline, } from './state_timeline/to_expression'; +import { createBarGaugeSpec } from './bar_gauge/to_expression'; type RuleMatchIndex = [number, number, number]; @@ -479,9 +481,10 @@ const oneMetricOneCateRule: VisualizationRule = { compare([1, 1, 0], [numerical.length, categorical.length, date.length]), chartTypes: [ { ...CHART_METADATA.bar, priority: 100 }, - { ...CHART_METADATA.pie, priority: 80 }, - { ...CHART_METADATA.line, priority: 60 }, - { ...CHART_METADATA.area, priority: 40 }, + { ...CHART_METADATA.bar_gauge, priority: 80 }, + { ...CHART_METADATA.pie, priority: 60 }, + { ...CHART_METADATA.line, priority: 40 }, + { ...CHART_METADATA.area, priority: 20 }, ], toSpec: ( transformedData, @@ -493,6 +496,15 @@ const oneMetricOneCateRule: VisualizationRule = { axisColumnMappings ) => { switch (chartType) { + case 'bar_gauge': + return createBarGaugeSpec( + transformedData, + numericalColumns, + categoricalColumns, + dateColumns, + styleOptions as BarGaugeChartStyle, + axisColumnMappings + ); case 'bar': return createBarSpec( transformedData, diff --git a/src/plugins/explore/public/components/visualizations/style_panel/standard_options/standard_options_panel.tsx b/src/plugins/explore/public/components/visualizations/style_panel/standard_options/standard_options_panel.tsx index 48470506a5e3..b960dcd95c93 100644 --- a/src/plugins/explore/public/components/visualizations/style_panel/standard_options/standard_options_panel.tsx +++ b/src/plugins/explore/public/components/visualizations/style_panel/standard_options/standard_options_panel.tsx @@ -5,14 +5,19 @@ import React from 'react'; import { i18n } from '@osd/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; + import { MinMaxControls } from './min_max_control'; import { StyleAccordion } from '../style_accordion'; +import { UnitPanel } from '../unit/unit_panel'; export interface StandardOptionsPanelProps { min?: number; max?: number; onMinChange: (min: number | undefined) => void; onMaxChange: (max: number | undefined) => void; + unit?: string; + onUnitChange: (unit: string | undefined) => void; } export const StandardOptionsPanel = ({ @@ -20,6 +25,8 @@ export const StandardOptionsPanel = ({ max, onMaxChange, onMinChange, + unit, + onUnitChange, }: StandardOptionsPanelProps) => { return ( // TODO add unit panel to standardOptions panel @@ -28,9 +35,17 @@ export const StandardOptionsPanel = ({ accordionLabel={i18n.translate('explore.stylePanel.threshold', { defaultMessage: 'Standard options', })} - initialIsOpen={true} + initialIsOpen={false} > - + + + {' '} + + + + + + ); }; diff --git a/src/plugins/explore/public/components/visualizations/style_panel/unit/unit_panel.tsx b/src/plugins/explore/public/components/visualizations/style_panel/unit/unit_panel.tsx index 659b07e80d7f..5aa3ef151183 100644 --- a/src/plugins/explore/public/components/visualizations/style_panel/unit/unit_panel.tsx +++ b/src/plugins/explore/public/components/visualizations/style_panel/unit/unit_panel.tsx @@ -106,32 +106,29 @@ export const UnitPanel = ({ unit, onUnitChange }: UnitPanelProps) => { }, [handleChangeUnit]); return ( - - - setPopover(false)} - panelPaddingSize="none" - anchorPosition="downLeft" - hasArrow={false} - > - - - - + setPopover(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + hasArrow={false} + > + + + ); }; diff --git a/src/plugins/explore/public/components/visualizations/theme/default_colors.ts b/src/plugins/explore/public/components/visualizations/theme/default_colors.ts index 3543ad508553..2a52b9bf59cc 100644 --- a/src/plugins/explore/public/components/visualizations/theme/default_colors.ts +++ b/src/plugins/explore/public/components/visualizations/theme/default_colors.ts @@ -21,6 +21,7 @@ export const getColors = () => { statusRed: '#DB0000', text: '#FFF', grid: '#27252C', + backgroundShade: '#27252C', categories: [ '#7598FF', '#A669E2', @@ -43,6 +44,7 @@ export const getColors = () => { statusRed: '#DB0000', text: '#313131', grid: '#F5F7FF', + backgroundShade: '#f1f1f1ff', categories: [ '#5C7FFF', '#A669E2', @@ -66,6 +68,7 @@ export const getColors = () => { statusRed: '#DB0000', text: euiThemeVars.euiTextColor, grid: euiThemeVars.euiColorChartLines, + backgroundShade: darkMode ? '#27252C' : '#f1f1f1ff', categories: euiPaletteColorBlind(), }; }; diff --git a/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts b/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts index 8bf98cf86770..6c9875dca170 100644 --- a/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts +++ b/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts @@ -21,6 +21,7 @@ import { StateTimeLineChartStyle, StateTimeLineChartStyleOptions, } from '../state_timeline/state_timeline_config'; +import { BarGaugeChartStyle, BarGaugeChartStyleOptions } from '../bar_gauge/bar_gauge_vis_config'; export type ChartType = | 'line' @@ -32,7 +33,8 @@ export type ChartType = | 'area' | 'table' | 'gauge' - | 'state_timeline'; + | 'state_timeline' + | 'bar_gauge'; export interface ChartStylesMapping { line: LineChartStyle; @@ -45,6 +47,7 @@ export interface ChartStylesMapping { table: TableChartStyle; gauge: GaugeChartStyle; state_timeline: StateTimeLineChartStyle; + bar_gauge: BarGaugeChartStyle; } export type StyleOptions = @@ -57,7 +60,8 @@ export type StyleOptions = | AreaChartStyleOptions | TableChartStyleOptions | GaugeChartStyleOptions - | StateTimeLineChartStyleOptions; + | StateTimeLineChartStyleOptions + | BarGaugeChartStyleOptions; export type ChartStyles = ChartStylesMapping[ChartType]; diff --git a/src/plugins/explore/public/components/visualizations/visualization_registry.ts b/src/plugins/explore/public/components/visualizations/visualization_registry.ts index a96b020d80d4..cf9cda960f5a 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_registry.ts +++ b/src/plugins/explore/public/components/visualizations/visualization_registry.ts @@ -25,6 +25,7 @@ import { ChartType } from './utils/use_visualization_types'; import { getColumnsByAxesMapping } from './visualization_builder_utils'; import { createGaugeConfig } from './gauge/gauge_vis_config'; import { createStateTimelineConfig } from './state_timeline/state_timeline_config'; +import { createBarGaugeConfig } from './bar_gauge/bar_gauge_vis_config'; /** * Registry for visualization rules and configurations. @@ -169,6 +170,8 @@ export class VisualizationRegistry { return createGaugeConfig(); case 'state_timeline': return createStateTimelineConfig(); + case 'bar_gauge': + return createBarGaugeConfig(); default: return; }