Merge pull request #1133 from buster-so/big-nate-bus-1933-negative-data-being-drawn-outside-of-chart

Big nate bus 1933 negative data being drawn outside of chart
This commit is contained in:
Nate Kelley 2025-09-24 22:04:05 -06:00 committed by GitHub
commit 6309dff5f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1311 additions and 65 deletions

View File

@ -1,3 +0,0 @@
export const MAX_NUMBER_OF_ITEMS_ON_DASHBOARD = 25;
export const NUMBER_OF_COLUMNS = 12;
export const MAX_NUMBER_OF_ITEMS = 4;

View File

@ -1,6 +1,6 @@
import { MAX_NUMBER_OF_ITEMS, NUMBER_OF_COLUMNS } from '@buster/server-shared/dashboards';
import { v4 as uuidv4 } from 'uuid';
import type { BusterDashboard } from '@/api/asset_interfaces/dashboard';
import { MAX_NUMBER_OF_ITEMS, NUMBER_OF_COLUMNS } from '../../../asset_interfaces/dashboard/config';
export const addMetricToDashboardConfig = (
metricIds: string[],

View File

@ -1,5 +1,5 @@
import { NUMBER_OF_COLUMNS } from '@buster/server-shared/dashboards';
import type { BusterDashboard } from '@/api/asset_interfaces/dashboard';
import { NUMBER_OF_COLUMNS } from '../../../asset_interfaces/dashboard/config';
export const removeMetricFromDashboardConfig = (
metricIds: string[],

View File

@ -1,7 +1,7 @@
import { MAX_NUMBER_OF_ITEMS_ON_DASHBOARD } from '@buster/server-shared/dashboards';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { create } from 'mutative';
import type { BusterDashboardResponse } from '@/api/asset_interfaces/dashboard';
import { MAX_NUMBER_OF_ITEMS_ON_DASHBOARD } from '@/api/asset_interfaces/dashboard/config';
import { metricsQueryKeys } from '@/api/query_keys/metric';
import { createDashboardFullConfirmModal } from '@/components/features/modals/createDashboardFullConfirmModal';
import { useBusterNotifications } from '@/context/BusterNotifications';

View File

@ -1,4 +1,4 @@
import { MAX_NUMBER_OF_ITEMS_ON_DASHBOARD } from '@/api/asset_interfaces/dashboard/config';
import { MAX_NUMBER_OF_ITEMS_ON_DASHBOARD } from '@buster/server-shared/dashboards';
import { cn } from '@/lib/classMerge';
import { Text } from '../../ui/typography/Text';
import { Title } from '../../ui/typography/Title';

View File

@ -141,6 +141,7 @@ export const useOptions = ({
y2AxisScaleType,
y2AxisStartAxisAtZero,
columnMetadata,
yAxis,
});
const isHorizontalBar = useMemo(() => {

View File

@ -22,6 +22,10 @@ describe('useY2Axis', () => {
y2AxisScaleType: 'linear' as const,
columnMetadata: [],
columnSettings: {},
yAxis: {
min: undefined,
max: undefined,
},
} as Parameters<typeof useY2Axis>[0];
it('should return undefined display when chart type is not Combo', () => {

View File

@ -7,7 +7,13 @@ import {
DEFAULT_CHART_CONFIG,
DEFAULT_COLUMN_LABEL_FORMAT,
} from '@buster/server-shared/metrics';
import type { Scale, ScaleChartOptions } from 'chart.js';
import { fa } from '@faker-js/faker';
import type {
CartesianScaleTypeRegistry,
Scale,
ScaleChartOptions,
ScaleOptionsByType,
} from 'chart.js';
import { useMemo } from 'react';
import type { DeepPartial } from 'utility-types';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
@ -15,7 +21,7 @@ import type { BusterChartProps } from '../../../BusterChart.types';
import { formatYAxisLabel, yAxisSimilar } from '../../../commonHelpers';
import { useY2AxisTitle } from './axisHooks/useY2AxisTitle';
export const DEFAULT_Y2_AXIS_COUNT = 7;
export const DEFAULT_Y2_AXIS_COUNT = 9;
export const useY2Axis = ({
columnLabelFormats,
@ -26,6 +32,7 @@ export const useY2Axis = ({
y2AxisShowAxisLabel,
y2AxisStartAxisAtZero,
y2AxisScaleType,
yAxis,
}: {
columnLabelFormats: NonNullable<ChartConfigProps['columnLabelFormats']>;
selectedAxis: ChartEncodes;
@ -36,9 +43,12 @@ export const useY2Axis = ({
y2AxisStartAxisAtZero: BusterChartProps['y2AxisStartAxisAtZero'];
y2AxisScaleType: BusterChartProps['y2AxisScaleType'];
columnMetadata: NonNullable<BusterChartProps['columnMetadata']>;
yAxis: DeepPartial<ScaleOptionsByType<keyof CartesianScaleTypeRegistry> | undefined>;
}): DeepPartial<ScaleChartOptions<'bar'>['scales']['y2']> | undefined => {
const selectedAxis = selectedAxisProp as ComboChartAxis;
const y2AxisKeys = selectedAxis.y2 || [];
const yAxisMinValue = yAxis?.min;
const yAxisMaxValue = yAxis?.max;
const y2AxisKeysString = useMemo(() => {
return y2AxisKeys.join(',');
@ -107,6 +117,8 @@ export const useY2Axis = ({
callback: tickCallback,
includeBounds: true,
},
min: yAxisMinValue,
max: yAxisMaxValue,
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
@ -116,6 +128,7 @@ export const useY2Axis = ({
}, [
tickCallback,
title,
yAxisMinValue,
y2AxisKeysString,
isSupportedType,
y2AxisShowAxisLabel,

View File

@ -5,7 +5,6 @@ import {
type ColumnLabelFormat,
type ComboChartAxis,
DEFAULT_COLUMN_LABEL_FORMAT,
DEFAULT_COLUMN_SETTINGS,
} from '@buster/server-shared/metrics';
import type { GridLineOptions, Scale, ScaleChartOptions } from 'chart.js';
import { useMemo } from 'react';
@ -16,6 +15,7 @@ import { formatYAxisLabel, yAxisSimilar } from '../../../commonHelpers';
import { useYAxisTitle } from './axisHooks/useYAxisTitle';
import { useIsStacked } from './useIsStacked';
import { DEFAULT_Y2_AXIS_COUNT } from './useY2Axis';
import { useYTickValues } from './useYTickValues';
export const useYAxis = ({
columnLabelFormats,
@ -30,7 +30,6 @@ export const useYAxis = ({
yAxisScaleType,
gridLines,
columnMetadata,
columnSettings,
}: {
columnLabelFormats: NonNullable<ChartConfigProps['columnLabelFormats']>;
selectedAxis: ChartEncodes;
@ -50,51 +49,23 @@ export const useYAxis = ({
const y2AxisKeys = (selectedAxis as ComboChartAxis)?.y2 || [];
const hasY2Axis = y2AxisKeys.length > 0;
const useMinValue = useMemo(() => {
if (!hasY2Axis) return false;
if (selectedChartType !== 'combo') return false;
if (!columnMetadata) return false;
const checkVales = [...yAxisKeys, ...y2AxisKeys];
// Create lookup map for O(1) column access
const columnMap = new Map(columnMetadata.map((col) => [col.name, col]));
let allBarValues = true;
let hasNegativeValues = false;
// Single pass to check both conditions
for (const key of checkVales) {
const column = columnMap.get(key);
if (!column) {
allBarValues = false;
continue;
}
// Check if this column is a bar
const visualization =
columnSettings[column.name]?.columnVisualization ||
DEFAULT_COLUMN_SETTINGS.columnVisualization;
if (visualization !== 'bar') {
allBarValues = false;
}
// Check if this column has negative values
if (Number(column.min_value ?? 0) < 0) {
hasNegativeValues = true;
}
}
if (allBarValues) return true;
if (hasNegativeValues) return false;
return false;
}, [hasY2Axis, yAxisKeys, y2AxisKeys, selectedChartType, columnMetadata, columnSettings]);
const isSupportedType = useMemo(() => {
return selectedChartType !== 'pie';
}, [selectedChartType]);
const { minTickValue, maxTickValue } = useYTickValues({
hasY2Axis,
columnMetadata,
selectedChartType,
yAxisKeys,
y2AxisKeys,
columnLabelFormats,
});
const defaultTickCount = useMemo(() => {
if (y2AxisKeys.length > 0 && minTickValue !== undefined) return DEFAULT_Y2_AXIS_COUNT;
}, [minTickValue]);
const grid: DeepPartial<GridLineOptions> | undefined = useMemo(() => {
return {
display: gridLines,
@ -157,11 +128,9 @@ export const useYAxis = ({
const memoizedYAxisOptions: DeepPartial<ScaleChartOptions<'bar'>['scales']['y']> | undefined =
useMemo(() => {
if (!isSupportedType) return undefined;
const baseConfig = {
return {
type,
grid,
max: usePercentageModeAxis ? 100 : undefined,
beginAtZero: yAxisStartAxisAtZero !== false,
stacked,
title: {
@ -171,16 +140,15 @@ export const useYAxis = ({
ticks: {
display: yAxisShowAxisLabel,
callback: tickCallback,
count: useMinValue ? DEFAULT_Y2_AXIS_COUNT : undefined,
count: defaultTickCount,
includeBounds: true,
},
min: useMinValue ? 0 : undefined,
min: usePercentageModeAxis ? 0 : minTickValue,
max: usePercentageModeAxis ? 100 : maxTickValue,
border: {
display: yAxisShowAxisLabel,
},
} as DeepPartial<ScaleChartOptions<'bar'>['scales']['y']>;
return baseConfig;
}, [
tickCallback,
type,
@ -191,6 +159,9 @@ export const useYAxis = ({
yAxisStartAxisAtZero,
yAxisShowAxisLabel,
usePercentageModeAxis,
maxTickValue,
minTickValue,
defaultTickCount,
]);
return memoizedYAxisOptions;

View File

@ -0,0 +1,495 @@
import type { ColumnMetaData } from '@buster/server-shared/metrics';
import { DEFAULT_COLUMN_LABEL_FORMAT } from '@buster/server-shared/metrics';
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useYTickValues } from './useYTickValues';
describe('useYTickValues', () => {
const mockColumnMetadata: ColumnMetaData[] = [
{
name: 'revenue',
min_value: 1000,
max_value: 10000,
unique_values: 100,
simple_type: 'number',
type: 'float',
},
{
name: 'conversion_rate',
min_value: 0.1,
max_value: 0.9,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
{
name: 'profit_margin',
min_value: -0.05,
max_value: 0.25,
unique_values: 25,
simple_type: 'number',
type: 'float',
},
];
const defaultProps = {
hasY2Axis: true,
columnMetadata: mockColumnMetadata,
selectedChartType: 'combo' as const,
yAxisKeys: ['revenue'],
y2AxisKeys: ['conversion_rate'],
columnLabelFormats: {
revenue: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const, suffix: '$' },
conversion_rate: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
profit_margin: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
},
};
describe('Basic conditions - should return undefined values', () => {
it('should return undefined values when hasY2Axis is false', () => {
const props = {
...defaultProps,
hasY2Axis: false,
};
const { result } = renderHook(() => useYTickValues(props));
expect(result.current.minTickValue).toBeUndefined();
expect(result.current.maxTickValue).toBeUndefined();
});
it('should return undefined values when selectedChartType is not combo', () => {
const props = {
...defaultProps,
selectedChartType: 'line' as const,
};
const { result } = renderHook(() => useYTickValues(props));
expect(result.current.minTickValue).toBeUndefined();
expect(result.current.maxTickValue).toBeUndefined();
});
it('should return undefined values when columnMetadata is undefined', () => {
const props = {
...defaultProps,
columnMetadata: undefined,
};
const { result } = renderHook(() => useYTickValues(props));
expect(result.current.minTickValue).toBeUndefined();
expect(result.current.maxTickValue).toBeUndefined();
});
});
describe('Percentage value scenarios', () => {
it('should handle all Y values being percentages', () => {
const props = {
...defaultProps,
yAxisKeys: ['conversion_rate'],
y2AxisKeys: ['profit_margin'],
columnLabelFormats: {
conversion_rate: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
profit_margin: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// When all values are percentages, it should return the lowest min value
expect(result.current.minTickValue).toBe(-0.05); // profit_margin min_value
// And the highest max value, but at least 1
expect(result.current.maxTickValue).toBe(1); // Should be at least 1 for percentages
});
it('should handle scenario with one percentage value', () => {
const similarRangeMetadata: ColumnMetaData[] = [
{
name: 'revenue',
min_value: 100,
max_value: 180, // Closer range to percentage values
unique_values: 50,
simple_type: 'number',
type: 'float',
},
{
name: 'conversion_rate',
min_value: 60, // Similar range to revenue (60-120 vs 100-180)
max_value: 120,
unique_values: 60,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: similarRangeMetadata,
yAxisKeys: ['revenue'],
y2AxisKeys: ['conversion_rate'],
columnLabelFormats: {
revenue: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const, suffix: '$' },
conversion_rate: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// With one percentage value and similar ranges, should apply percentage rounding
// Min ratio: 100/60 = 1.67 < 2 ✓
// Max ratio: 180/120 = 1.5 < 2 ✓
expect(result.current.minTickValue).toBeDefined();
expect(result.current.maxTickValue).toBeDefined();
});
it('should handle percentage values with positive minimum', () => {
const positivePercentageMetadata: ColumnMetaData[] = [
{
name: 'conversion_rate',
min_value: 0.15,
max_value: 0.85,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: positivePercentageMetadata,
yAxisKeys: ['conversion_rate'],
y2AxisKeys: ['conversion_rate'],
columnLabelFormats: {
conversion_rate: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should return 0 for positive percentage minimums
expect(result.current.minTickValue).toBe(0);
});
});
describe('Range calculations', () => {
it('should calculate Y-axis and Y2-axis ranges correctly', () => {
const multipleColumnsMetadata: ColumnMetaData[] = [
{
name: 'revenue',
min_value: 1000,
max_value: 2000,
unique_values: 100,
simple_type: 'number',
type: 'float',
},
{
name: 'profit',
min_value: 500,
max_value: 1500,
unique_values: 80,
simple_type: 'number',
type: 'float',
},
{
name: 'conversion_rate',
min_value: 600, // Similar range to Y-axis columns
max_value: 1800,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: multipleColumnsMetadata,
yAxisKeys: ['revenue', 'profit'], // Y-axis range: min=500, max=2000
y2AxisKeys: ['conversion_rate'], // Y2-axis range: min=600, max=1800
columnLabelFormats: {
revenue: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
profit: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
conversion_rate: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should calculate ranges based on multiple columns
// Min ratio: 600/500 = 1.2 < 2 ✓
// Max ratio: 2000/1800 = 1.11 < 2 ✓
expect(result.current.minTickValue).toBeDefined();
expect(result.current.maxTickValue).toBeDefined();
});
it('should handle missing column metadata gracefully', () => {
const props = {
...defaultProps,
yAxisKeys: ['nonexistent_column'],
y2AxisKeys: ['another_nonexistent'],
};
const { result } = renderHook(() => useYTickValues(props));
// When columns don't exist, ranges become infinity initially, then get reset to 0
// Since both axes have same values (0,0), they're considered similar and return aligned values
expect(result.current.minTickValue).toBe(0);
expect(result.current.maxTickValue).toBe(0);
});
});
describe('Similarity calculations and axis alignment', () => {
it('should align axes when values are similar', () => {
const similarRangeMetadata: ColumnMetaData[] = [
{
name: 'metric1',
min_value: 100,
max_value: 1000,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
{
name: 'metric2',
min_value: 110, // Within 2x range
max_value: 1100, // Within 2x range
unique_values: 60,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: similarRangeMetadata,
yAxisKeys: ['metric1'],
y2AxisKeys: ['metric2'],
columnLabelFormats: {
metric1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
metric2: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should align axes when ranges are similar
expect(result.current.minTickValue).toBeDefined();
expect(result.current.maxTickValue).toBeDefined();
// Values should be adjusted with MIN_OFFSET and MAX_OFFSET
expect(result.current.minTickValue).toBe(Math.round(100 * 1.1));
expect(result.current.maxTickValue).toBe(Math.round(1100 * 1.1));
});
it('should not align axes when values are dissimilar', () => {
const dissimilarRangeMetadata: ColumnMetaData[] = [
{
name: 'metric1',
min_value: 1,
max_value: 10,
unique_values: 10,
simple_type: 'number',
type: 'float',
},
{
name: 'metric2',
min_value: 1000, // Much larger, beyond 2x range
max_value: 10000, // Much larger, beyond 2x range
unique_values: 50,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: dissimilarRangeMetadata,
yAxisKeys: ['metric1'],
y2AxisKeys: ['metric2'],
columnLabelFormats: {
metric1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
metric2: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should not align axes when ranges are too different
expect(result.current.minTickValue).toBeUndefined();
expect(result.current.maxTickValue).toBeUndefined();
});
});
describe('Edge cases and boundary conditions', () => {
it('should handle zero values correctly', () => {
const zeroValueMetadata: ColumnMetaData[] = [
{
name: 'metric1',
min_value: 0,
max_value: 0,
unique_values: 1,
simple_type: 'number',
type: 'float',
},
{
name: 'metric2',
min_value: 0,
max_value: 0,
unique_values: 1,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: zeroValueMetadata,
yAxisKeys: ['metric1'],
y2AxisKeys: ['metric2'],
columnLabelFormats: {
metric1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
metric2: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should handle zero values appropriately
expect(result.current.minTickValue).toBe(0);
expect(result.current.maxTickValue).toBe(0);
});
it('should handle negative values correctly', () => {
const negativeValueMetadata: ColumnMetaData[] = [
{
name: 'metric1',
min_value: -1000,
max_value: -100,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
{
name: 'metric2',
min_value: -1100,
max_value: -110,
unique_values: 60,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: negativeValueMetadata,
yAxisKeys: ['metric1'],
y2AxisKeys: ['metric2'],
columnLabelFormats: {
metric1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
metric2: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should handle negative values and apply offsets
expect(result.current.minTickValue).toBeDefined();
expect(result.current.maxTickValue).toBeDefined();
expect(result.current.minTickValue).toBeLessThan(0);
expect(result.current.maxTickValue).toBeLessThan(0);
});
it('should handle empty axis keys', () => {
const props = {
...defaultProps,
yAxisKeys: [],
y2AxisKeys: [],
};
const { result } = renderHook(() => useYTickValues(props));
expect(result.current.minTickValue).toBeUndefined();
expect(result.current.maxTickValue).toBeUndefined();
});
});
describe('Percentage rounding logic', () => {
it('should round percentage values to nearest 5', () => {
const percentageMetadata: ColumnMetaData[] = [
{
name: 'conversion1',
min_value: 120, // Scale up so ranges are similar
max_value: 870,
unique_values: 50,
simple_type: 'number',
type: 'float',
},
{
name: 'revenue1',
min_value: 130, // Similar range to trigger alignment
max_value: 850,
unique_values: 60,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: percentageMetadata,
yAxisKeys: ['conversion1'],
y2AxisKeys: ['revenue1'],
columnLabelFormats: {
conversion1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'percent' as const },
revenue1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should round to nearest 5 when there's one percentage value and ranges are similar
// Min: Math.floor(120 * 1.1 / 5) * 5 = Math.floor(26.4) * 5 = 130
// Max: Math.ceil(870 * 1.1 / 5) * 5 = Math.ceil(191.4) * 5 = 960
expect(result.current.minTickValue).toBe(130);
expect(result.current.maxTickValue).toBe(960);
});
it('should apply regular rounding for non-percentage values', () => {
const nonPercentageMetadata: ColumnMetaData[] = [
{
name: 'revenue1',
min_value: 1234,
max_value: 5678,
unique_values: 100,
simple_type: 'number',
type: 'float',
},
{
name: 'revenue2',
min_value: 1345,
max_value: 6789,
unique_values: 120,
simple_type: 'number',
type: 'float',
},
];
const props = {
...defaultProps,
columnMetadata: nonPercentageMetadata,
yAxisKeys: ['revenue1'],
y2AxisKeys: ['revenue2'],
columnLabelFormats: {
revenue1: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const, suffix: '$' },
revenue2: { ...DEFAULT_COLUMN_LABEL_FORMAT, style: 'number' as const, suffix: '$' },
},
};
const { result } = renderHook(() => useYTickValues(props));
// Should apply regular rounding (round to nearest integer)
expect(result.current.minTickValue).toBe(Math.round(1234 * 1.1));
expect(result.current.maxTickValue).toBe(Math.round(6789 * 1.1));
});
});
});

View File

@ -0,0 +1,206 @@
/** biome-ignore-all lint/style/noNonNullAssertion: false positive */
import round from 'lodash/round';
import { useMemo } from 'react';
import type { BusterChartProps } from '../../../BusterChart.types';
const MIN_PERCENT_DIFFERENCE = 2;
const MAX_PERCENT_DIFFERENCE = 2;
const MIN_OFFSET = 1.1;
const MAX_OFFSET = 1.1;
const PERCENTAGE_STEP = 5;
export const useYTickValues = ({
hasY2Axis,
columnMetadata,
selectedChartType,
yAxisKeys,
y2AxisKeys,
columnLabelFormats,
}: {
hasY2Axis: boolean;
columnMetadata: BusterChartProps['columnMetadata'];
selectedChartType: BusterChartProps['selectedChartType'];
yAxisKeys: string[];
y2AxisKeys: string[];
columnLabelFormats: NonNullable<BusterChartProps['columnLabelFormats']>;
}) => {
const shouldUseMinAndMaxValues = hasY2Axis && columnMetadata && selectedChartType === 'combo';
const checkValues = useMemo(() => {
if (!shouldUseMinAndMaxValues) return [];
return [...yAxisKeys, ...y2AxisKeys];
}, [yAxisKeys, y2AxisKeys, shouldUseMinAndMaxValues]);
const hasOnePercentageValue = useMemo(() => {
if (!shouldUseMinAndMaxValues) return false;
return checkValues.some((key) => {
const columnFormat = columnLabelFormats[key];
return columnFormat?.style === 'percent';
});
}, [checkValues, columnLabelFormats, shouldUseMinAndMaxValues]);
const allYValuesArePercentage = useMemo(() => {
if (!shouldUseMinAndMaxValues) return false;
return checkValues.every((key) => {
const columnFormat = columnLabelFormats[key];
return columnFormat?.style === 'percent';
});
}, [checkValues, columnLabelFormats, shouldUseMinAndMaxValues]);
const columnMap = useMemo(() => {
if (!columnMetadata || !shouldUseMinAndMaxValues) return new Map();
return new Map(columnMetadata.map((col) => [col.name, col]));
}, [columnMetadata, shouldUseMinAndMaxValues]);
// Calculate min/max ranges for y-axis columns
const yAxisRange = useMemo(() => {
if (!shouldUseMinAndMaxValues) return { min: Infinity, max: -Infinity };
return yAxisKeys.reduce(
(acc, key) => {
const column = columnMap.get(key);
if (!column) return acc;
const min = Number(column.min_value ?? 0);
const max = Number(column.max_value ?? 0);
return {
min: Math.min(acc.min, min),
max: Math.max(acc.max, max),
};
},
{ min: Infinity, max: -Infinity }
);
}, [yAxisKeys, columnMap, shouldUseMinAndMaxValues]);
// Calculate min/max ranges for y2-axis columns
const y2AxisRange = useMemo(() => {
if (!shouldUseMinAndMaxValues) return { min: Infinity, max: -Infinity };
return y2AxisKeys.reduce(
(acc, key) => {
const column = columnMap.get(key);
if (!column) return acc;
const min = Number(column.min_value ?? 0);
const max = Number(column.max_value ?? 0);
return {
min: Math.min(acc.min, min),
max: Math.max(acc.max, max),
};
},
{ min: Infinity, max: -Infinity }
);
}, [y2AxisKeys, columnMap, shouldUseMinAndMaxValues]);
const minTickValue: number | undefined = useMemo(() => {
if (!shouldUseMinAndMaxValues) return undefined;
// If all Y values are percentages, return the lowest value
if (allYValuesArePercentage) {
const lowestValue = checkValues.reduce((min, key) => {
const column = columnMap.get(key);
return Math.min(min, Number(column?.min_value ?? 0));
}, Infinity);
if (lowestValue === Infinity) return undefined;
if (lowestValue > 0) return 0;
return lowestValue;
}
// Reset infinities if no valid data found
if (yAxisRange.min === Infinity) yAxisRange.min = 0;
if (yAxisRange.max === -Infinity) yAxisRange.max = 0;
if (y2AxisRange.min === Infinity) y2AxisRange.min = 0;
if (y2AxisRange.max === -Infinity) y2AxisRange.max = 0;
// Check if min values are within 130% of each other
const minValuesAreSimilar =
Math.abs(yAxisRange.min) > 0 && Math.abs(y2AxisRange.min) > 0
? Math.max(yAxisRange.min, y2AxisRange.min) / Math.min(yAxisRange.min, y2AxisRange.min) <=
MIN_PERCENT_DIFFERENCE
: yAxisRange.min === y2AxisRange.min;
// Check if max values are within 130% of each other
const maxValuesAreSimilar =
Math.abs(yAxisRange.max) > 0 && Math.abs(y2AxisRange.max) > 0
? Math.max(yAxisRange.max, y2AxisRange.max) / Math.min(yAxisRange.max, y2AxisRange.max) <=
MIN_PERCENT_DIFFERENCE
: yAxisRange.max === y2AxisRange.max;
// If both min and max values are similar, use the lowest min value
if (minValuesAreSimilar && maxValuesAreSimilar) {
if (hasOnePercentageValue) {
// If there's at least one percentage value, round the min to the nearest lower multiple of 5
const min = Math.min(yAxisRange.min, y2AxisRange.min) * MIN_OFFSET;
return Math.floor(min / PERCENTAGE_STEP) * PERCENTAGE_STEP;
}
return round(Math.min(yAxisRange.min, y2AxisRange.min) * MIN_OFFSET, 0);
}
}, [
hasOnePercentageValue,
columnLabelFormats,
yAxisRange,
y2AxisRange,
shouldUseMinAndMaxValues,
columnMap,
checkValues,
allYValuesArePercentage,
]);
const maxTickValue: number | undefined = useMemo(() => {
if (!shouldUseMinAndMaxValues) return undefined;
// If all Y values are percentages, return the highest value
if (allYValuesArePercentage) {
const highestValue = checkValues.reduce((max, key) => {
const column = columnMap.get(key);
return Math.max(max, Number(column?.max_value ?? 0));
}, -Infinity);
if (highestValue === -Infinity) return undefined;
if (highestValue < 1) return 1;
return highestValue;
}
// Reset infinities if no valid data found
if (yAxisRange.min === Infinity) yAxisRange.min = 0;
if (yAxisRange.max === -Infinity) yAxisRange.max = 0;
if (y2AxisRange.min === Infinity) y2AxisRange.min = 0;
if (y2AxisRange.max === -Infinity) y2AxisRange.max = 0;
// Check if min values are within 150% of each other
const minValuesAreSimilar =
Math.abs(yAxisRange.min) > 0 && Math.abs(y2AxisRange.min) > 0
? Math.max(yAxisRange.min, y2AxisRange.min) / Math.min(yAxisRange.min, y2AxisRange.min) <=
MIN_PERCENT_DIFFERENCE
: yAxisRange.min === y2AxisRange.min;
// Check if max values are within 200% of each other
const maxValuesAreSimilar =
Math.abs(yAxisRange.max) > 0 && Math.abs(y2AxisRange.max) > 0
? Math.max(yAxisRange.max, y2AxisRange.max) / Math.min(yAxisRange.max, y2AxisRange.max) <=
MAX_PERCENT_DIFFERENCE
: yAxisRange.max === y2AxisRange.max;
// If both min and max values are similar, use the highest max value
if (minValuesAreSimilar && maxValuesAreSimilar) {
if (hasOnePercentageValue) {
// If there's at least one percentage value, round the max to the nearest higher multiple of 5
const max = Math.max(yAxisRange.max, y2AxisRange.max) * MAX_OFFSET;
return Math.ceil(max / PERCENTAGE_STEP) * PERCENTAGE_STEP;
}
return round(Math.max(yAxisRange.max, y2AxisRange.max) * MAX_OFFSET, 0);
}
}, [
hasOnePercentageValue,
columnLabelFormats,
yAxisRange,
y2AxisRange,
shouldUseMinAndMaxValues,
columnMap,
checkValues,
allYValuesArePercentage,
]);
return {
minTickValue,
maxTickValue,
};
};

View File

@ -1476,3 +1476,542 @@ export const ComboChartWithNegativeNumbers2: Story = {
],
},
};
export const ComboChartWithNegativeNumbers3: Story = {
args: {
colors: [
'#B399FD',
'#FC8497',
'#FBBC30',
'#279EFF',
'#E83562',
'#41F8FF',
'#F3864F',
'#C82184',
'#31FCB4',
'#E83562',
],
barLayout: 'vertical',
barSortBy: [],
goalLines: [],
gridLines: true,
pieSortBy: 'value',
showLegend: null,
trendlines: [],
scatterAxis: {
x: [],
y: [],
size: [],
tooltip: null,
category: [],
},
barGroupType: 'group',
metricHeader: null,
pieChartAxis: {
x: [],
y: [],
tooltip: null,
},
lineGroupType: null,
pieDonutWidth: 40,
xAxisDataZoom: false,
barAndLineAxis: {
x: [],
y: [],
tooltip: null,
category: [],
},
columnSettings: {},
comboChartAxis: {
x: ['month', 'year'],
y: ['absolute_change'],
y2: ['percent_change'],
tooltip: null,
category: [],
},
disableTooltip: false,
metricColumnId: '',
scatterDotSize: [3, 15],
xAxisAxisTitle: null,
yAxisAxisTitle: null,
yAxisScaleType: 'linear',
metricSubHeader: null,
y2AxisAxisTitle: null,
y2AxisScaleType: 'linear',
metricValueLabel: null,
pieLabelPosition: 'none',
tableColumnOrder: null,
barShowTotalAtTop: false,
categoryAxisTitle: null,
pieDisplayLabelAs: 'number',
pieShowInnerLabel: true,
selectedChartType: 'combo',
tableColumnWidths: null,
xAxisTimeInterval: null,
columnLabelFormats: {
year: {
isUTC: false,
style: 'number',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: '',
compactNumbers: false,
convertNumberTo: null,
useRelativeTime: false,
numberSeparatorStyle: null,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
month: {
isUTC: false,
style: 'date',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'MMM',
multiplier: 1,
displayName: '',
compactNumbers: false,
convertNumberTo: 'month_of_year',
useRelativeTime: false,
numberSeparatorStyle: null,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
offline_orders: {
isUTC: false,
style: 'number',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: 'Offline Orders',
compactNumbers: false,
convertNumberTo: null,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
percent_change: {
isUTC: false,
style: 'percent',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: 'Percent Change',
compactNumbers: false,
convertNumberTo: null,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 1,
minimumFractionDigits: 1,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
absolute_change: {
isUTC: false,
style: 'number',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: 'Absolute Change',
compactNumbers: false,
convertNumberTo: null,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
prev_month_orders: {
isUTC: false,
style: 'number',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: 'Previous Month Orders',
compactNumbers: false,
convertNumberTo: null,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
makeLabelHumanReadable: true,
replaceMissingDataWith: 0,
},
},
pieInnerLabelTitle: null,
showLegendHeadline: false,
xAxisLabelRotation: 'auto',
xAxisShowAxisLabel: true,
xAxisShowAxisTitle: true,
yAxisShowAxisLabel: true,
yAxisShowAxisTitle: true,
y2AxisShowAxisLabel: true,
y2AxisShowAxisTitle: true,
metricValueAggregate: 'sum',
tableColumnFontColor: null,
tableHeaderFontColor: null,
yAxisStartAxisAtZero: null,
y2AxisStartAxisAtZero: true,
pieInnerLabelAggregate: 'sum',
pieMinimumSlicePercentage: 0,
tableHeaderBackgroundColor: null,
columnMetadata: [
{
name: 'year',
min_value: 2022,
max_value: 2025,
unique_values: 4,
simple_type: 'number',
type: 'numeric',
},
{
name: 'month',
min_value: 1,
max_value: 12,
unique_values: 12,
simple_type: 'number',
type: 'numeric',
},
{
name: 'offline_orders',
min_value: 37,
max_value: 186,
unique_values: 32,
simple_type: 'number',
type: 'int8',
},
{
name: 'prev_month_orders',
min_value: 37,
max_value: 186,
unique_values: 32,
simple_type: 'number',
type: 'int8',
},
{
name: 'absolute_change',
min_value: -81,
max_value: 91,
unique_values: 30,
simple_type: 'number',
type: 'int8',
},
{
name: 'percent_change',
min_value: -46.3,
max_value: 129.7,
unique_values: 34,
simple_type: 'number',
type: 'numeric',
},
],
data: [
{
year: 2022,
month: 9,
offline_orders: 75,
prev_month_orders: 38,
absolute_change: 37,
percent_change: 97.4,
},
{
year: 2022,
month: 10,
offline_orders: 60,
prev_month_orders: 75,
absolute_change: -15,
percent_change: -20,
},
{
year: 2022,
month: 11,
offline_orders: 40,
prev_month_orders: 60,
absolute_change: -20,
percent_change: -33.3,
},
{
year: 2022,
month: 12,
offline_orders: 90,
prev_month_orders: 40,
absolute_change: 50,
percent_change: 125,
},
{
year: 2023,
month: 1,
offline_orders: 63,
prev_month_orders: 90,
absolute_change: -27,
percent_change: -30,
},
{
year: 2023,
month: 2,
offline_orders: 40,
prev_month_orders: 63,
absolute_change: -23,
percent_change: -36.5,
},
{
year: 2023,
month: 3,
offline_orders: 79,
prev_month_orders: 40,
absolute_change: 39,
percent_change: 97.5,
},
{
year: 2023,
month: 4,
offline_orders: 64,
prev_month_orders: 79,
absolute_change: -15,
percent_change: -19,
},
{
year: 2023,
month: 5,
offline_orders: 37,
prev_month_orders: 64,
absolute_change: -27,
percent_change: -42.2,
},
{
year: 2023,
month: 6,
offline_orders: 85,
prev_month_orders: 37,
absolute_change: 48,
percent_change: 129.7,
},
{
year: 2023,
month: 7,
offline_orders: 68,
prev_month_orders: 85,
absolute_change: -17,
percent_change: -20,
},
{
year: 2023,
month: 8,
offline_orders: 72,
prev_month_orders: 68,
absolute_change: 4,
percent_change: 5.9,
},
{
year: 2023,
month: 9,
offline_orders: 139,
prev_month_orders: 72,
absolute_change: 67,
percent_change: 93.1,
},
{
year: 2023,
month: 10,
offline_orders: 111,
prev_month_orders: 139,
absolute_change: -28,
percent_change: -20.1,
},
{
year: 2023,
month: 11,
offline_orders: 73,
prev_month_orders: 111,
absolute_change: -38,
percent_change: -34.2,
},
{
year: 2023,
month: 12,
offline_orders: 133,
prev_month_orders: 73,
absolute_change: 60,
percent_change: 82.2,
},
{
year: 2024,
month: 1,
offline_orders: 114,
prev_month_orders: 133,
absolute_change: -19,
percent_change: -14.3,
},
{
year: 2024,
month: 2,
offline_orders: 65,
prev_month_orders: 114,
absolute_change: -49,
percent_change: -43,
},
{
year: 2024,
month: 3,
offline_orders: 132,
prev_month_orders: 65,
absolute_change: 67,
percent_change: 103.1,
},
{
year: 2024,
month: 4,
offline_orders: 106,
prev_month_orders: 132,
absolute_change: -26,
percent_change: -19.7,
},
{
year: 2024,
month: 5,
offline_orders: 74,
prev_month_orders: 106,
absolute_change: -32,
percent_change: -30.2,
},
{
year: 2024,
month: 6,
offline_orders: 134,
prev_month_orders: 74,
absolute_change: 60,
percent_change: 81.1,
},
{
year: 2024,
month: 7,
offline_orders: 102,
prev_month_orders: 134,
absolute_change: -32,
percent_change: -23.9,
},
{
year: 2024,
month: 8,
offline_orders: 95,
prev_month_orders: 102,
absolute_change: -7,
percent_change: -6.9,
},
{
year: 2024,
month: 9,
offline_orders: 186,
prev_month_orders: 95,
absolute_change: 91,
percent_change: 95.8,
},
{
year: 2024,
month: 10,
offline_orders: 176,
prev_month_orders: 186,
absolute_change: -10,
percent_change: -5.4,
},
{
year: 2024,
month: 11,
offline_orders: 99,
prev_month_orders: 176,
absolute_change: -77,
percent_change: -43.8,
},
{
year: 2024,
month: 12,
offline_orders: 179,
prev_month_orders: 99,
absolute_change: 80,
percent_change: 80.8,
},
{
year: 2025,
month: 1,
offline_orders: 176,
prev_month_orders: 179,
absolute_change: -3,
percent_change: -1.7,
},
{
year: 2025,
month: 2,
offline_orders: 96,
prev_month_orders: 176,
absolute_change: -80,
percent_change: -45.5,
},
{
year: 2025,
month: 3,
offline_orders: 175,
prev_month_orders: 96,
absolute_change: 79,
percent_change: 82.3,
},
{
year: 2025,
month: 4,
offline_orders: 175,
prev_month_orders: 175,
absolute_change: 0,
percent_change: 0,
},
{
year: 2025,
month: 5,
offline_orders: 94,
prev_month_orders: 175,
absolute_change: -81,
percent_change: -46.3,
},
{
year: 2025,
month: 6,
offline_orders: 180,
prev_month_orders: 94,
absolute_change: 86,
percent_change: 91.5,
},
{
year: 2025,
month: 7,
offline_orders: 181,
prev_month_orders: 180,
absolute_change: 1,
percent_change: 0.6,
},
],
},
};

View File

@ -1,9 +1,19 @@
export const NUMBER_OF_COLUMNS = 12;
export const MIN_NUMBER_OF_COLUMNS = 3;
export const MAX_NUMBER_OF_COLUMNS = 12;
export const MAX_NUMBER_OF_ITEMS = 4;
export const MIN_ROW_HEIGHT = 320;
export const MAX_ROW_HEIGHT = 550;
import {
MAX_NUMBER_OF_COLUMNS as SERVER_MAX_NUMBER_OF_COLUMNS,
MAX_NUMBER_OF_ITEMS as SERVER_MAX_NUMBER_OF_ITEMS,
MAX_ROW_HEIGHT as SERVER_MAX_ROW_HEIGHT,
MIN_NUMBER_OF_COLUMNS as SERVER_MIN_NUMBER_OF_COLUMNS,
MIN_ROW_HEIGHT as SERVER_MIN_ROW_HEIGHT,
NUMBER_OF_COLUMNS as SERVER_NUMBER_OF_COLUMNS,
} from '@buster/server-shared/dashboards';
export const NUMBER_OF_COLUMNS = SERVER_NUMBER_OF_COLUMNS;
export const MIN_NUMBER_OF_COLUMNS = SERVER_MIN_NUMBER_OF_COLUMNS;
export const MAX_NUMBER_OF_COLUMNS = SERVER_MAX_NUMBER_OF_COLUMNS;
export const MAX_NUMBER_OF_ITEMS = SERVER_MAX_NUMBER_OF_ITEMS;
export const MIN_ROW_HEIGHT = SERVER_MIN_ROW_HEIGHT;
export const MAX_ROW_HEIGHT = SERVER_MAX_ROW_HEIGHT;
export const HEIGHT_OF_DROPZONE = 100;
export const SASH_SIZE = 12;

View File

@ -0,0 +1,9 @@
export const MAX_NUMBER_OF_ITEMS_ON_DASHBOARD = 25;
export const NUMBER_OF_COLUMNS = 12;
export const MIN_NUMBER_OF_COLUMNS = 3;
export const MAX_NUMBER_OF_COLUMNS = 12;
export const MAX_NUMBER_OF_ITEMS = 4;
export const MIN_ROW_HEIGHT = 320;
export const MAX_ROW_HEIGHT = 550;

View File

@ -2,3 +2,4 @@ export * from './dashboard-response.types';
export * from './dashboard.types';
export * from './dashboard-list.types';
export * from './requests.types';
export * from './dashboard-grid';