added different colorBy config

This commit is contained in:
Nate Kelley 2025-09-25 21:28:37 -06:00
parent 5dc479882c
commit 519f48929f
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
7 changed files with 256 additions and 145 deletions

View File

@ -2029,4 +2029,195 @@ describe('aggregateAndCreateDatasets', () => {
}),
]);
});
describe('createDatasetsByColorThenAggregate', () => {
it('should create separate datasets for each color value', () => {
const testData = [
{ month: 'Jan', sales: 100, productType: 'Electronics' },
{ month: 'Jan', sales: 80, productType: 'Clothing' },
{ month: 'Feb', sales: 120, productType: 'Electronics' },
{ month: 'Feb', sales: 90, productType: 'Clothing' },
];
const colorConfig = {
field: 'productType',
mapping: new Map([
['Electronics', '#ff0000'],
['Clothing', '#00ff00'],
]),
};
const result = aggregateAndCreateDatasets(
testData,
{
x: ['month'],
y: ['sales'],
},
{},
false,
colorConfig
);
// Should have 2 datasets (one for each color/productType)
expect(result.datasets).toHaveLength(2);
// Check first dataset (Electronics)
expect(result.datasets[0].colors).toBe('#ff0000');
expect(result.datasets[0].data).toEqual([100, 120]);
expect(result.datasets[0].label).toEqual([{ key: 'productType', value: 'Electronics' }]);
// Check second dataset (Clothing)
expect(result.datasets[1].colors).toBe('#00ff00');
expect(result.datasets[1].data).toEqual([80, 90]);
expect(result.datasets[1].label).toEqual([{ key: 'productType', value: 'Clothing' }]);
// Check ticks are consistent across all datasets
expect(result.ticks).toEqual([['Jan'], ['Feb']]);
expect(result.ticksKey).toEqual([{ key: 'month', value: '' }]);
});
it('should handle multiple y-axes with color configuration', () => {
const testData = [
{ month: 'Jan', sales: 100, profit: 20, region: 'North' },
{ month: 'Jan', sales: 80, profit: 15, region: 'South' },
{ month: 'Feb', sales: 120, profit: 25, region: 'North' },
{ month: 'Feb', sales: 90, profit: 18, region: 'South' },
];
const colorConfig = {
field: 'region',
mapping: new Map([
['North', '#0000ff'],
['South', '#ff00ff'],
]),
};
const result = aggregateAndCreateDatasets(
testData,
{
x: ['month'],
y: ['sales', 'profit'],
},
{},
false,
colorConfig
);
// Should have 4 datasets (2 metrics × 2 regions)
expect(result.datasets).toHaveLength(4);
// Find sales datasets
const salesDatasets = result.datasets.filter(d => d.dataKey === 'sales');
const profitDatasets = result.datasets.filter(d => d.dataKey === 'profit');
expect(salesDatasets).toHaveLength(2);
expect(profitDatasets).toHaveLength(2);
// Check labels include both metric and color field
const northSalesDataset = salesDatasets.find(d => d.colors === '#0000ff');
expect(northSalesDataset?.label).toEqual([
{ key: 'sales', value: '' },
{ key: 'region', value: 'North' },
]);
const southProfitDataset = profitDatasets.find(d => d.colors === '#ff00ff');
expect(southProfitDataset?.label).toEqual([
{ key: 'profit', value: '' },
{ key: 'region', value: 'South' },
]);
});
it('should handle categories combined with color configuration', () => {
const testData = [
{ quarter: 'Q1', sales: 100, product: 'A', region: 'North' },
{ quarter: 'Q1', sales: 80, product: 'B', region: 'North' },
{ quarter: 'Q2', sales: 120, product: 'A', region: 'North' },
{ quarter: 'Q1', sales: 90, product: 'A', region: 'South' },
{ quarter: 'Q2', sales: 95, product: 'A', region: 'South' },
];
const colorConfig = {
field: 'region',
mapping: new Map([
['North', '#aabbcc'],
['South', '#ddeeff'],
]),
};
const result = aggregateAndCreateDatasets(
testData,
{
x: ['quarter'],
y: ['sales'],
category: ['product'],
},
{},
false,
colorConfig
);
// Should have 3 datasets (North: A, B; South: A)
expect(result.datasets).toHaveLength(3);
// Check that labels include product category AND color field
const northProductADataset = result.datasets.find(
d => d.colors === '#aabbcc' && d.label.some(l => l.key === 'product' && l.value === 'A')
);
expect(northProductADataset).toBeDefined();
expect(northProductADataset?.label).toEqual([
{ key: 'region', value: 'North' },
{ key: 'product', value: 'A' },
]);
expect(northProductADataset?.data).toEqual([100, 120]);
const southProductADataset = result.datasets.find(
d => d.colors === '#ddeeff' && d.label.some(l => l.key === 'product' && l.value === 'A')
);
expect(southProductADataset).toBeDefined();
expect(southProductADataset?.data).toEqual([90, 95]);
});
it('should handle missing color values by filtering unmapped data', () => {
const testData = [
{ month: 'Jan', sales: 100, category: 'A' },
{ month: 'Jan', sales: 80, category: 'C' }, // C is not mapped to a color
{ month: 'Feb', sales: 120, category: 'A' },
{ month: 'Feb', sales: 90, category: 'C' }, // C is not mapped to a color
];
const colorConfig = {
field: 'category',
mapping: new Map([
['A', '#ff0000'],
['B', '#00ff00'], // B doesn't exist in data but is mapped
]),
};
const result = aggregateAndCreateDatasets(
testData,
{
x: ['month'],
y: ['sales'],
},
{},
false,
colorConfig
);
// Should have 1 dataset (only for A which has data and color mapping)
expect(result.datasets).toHaveLength(1);
// Dataset A should have real data
const datasetA = result.datasets.find(d => d.colors === '#ff0000');
expect(datasetA?.data).toEqual([100, 120]);
expect(datasetA?.label).toEqual([{ key: 'category', value: 'A' }]);
// Category C data should be filtered out since it's not in the color mapping
// Check ticks are still correct for all x-axis values from original data
expect(result.ticks).toEqual([['Jan'], ['Feb']]);
expect(result.ticksKey).toEqual([{ key: 'month', value: '' }]);
});
});
});

View File

@ -423,7 +423,7 @@ export function aggregateAndCreateDatasets<
* Creates datasets by splitting data by color values first, then aggregating each subset
* This preserves the granular color information needed for proper aggregation
*/
function createDatasetsByColorThenAggregate<
export function createDatasetsByColorThenAggregate<
T extends Record<string, string | number | null | Date | undefined>,
>(
data: T[],
@ -724,102 +724,6 @@ function createDatasetsByColorThenAggregate<
};
}
/**
* Creates separate datasets for each unique color value when colorBy is specified
* Optimized to minimize redundant operations and loops
*/
function createSeparateDatasetsByColor<
T extends Record<string, string | number | null | Date | undefined>,
>(
datasets: DatasetOption[],
originalData: T[],
colorConfig: { field: string; mapping: Map<string, string> },
xGroups: Array<{ id: string; rec: Record<string, string>; rows: T[] }>
): DatasetOption[] {
const { field: colorByField, mapping: colorMapping } = colorConfig;
// Get unique color values that have valid colors mapped
const uniqueColorValues = Array.from(
new Set(
originalData
.map((row) => row[colorByField])
.filter((val) => val !== null && val !== undefined)
.map(String)
)
).filter((colorValue) => colorMapping.has(colorValue)); // Only include values with mapped colors
if (uniqueColorValues.length === 0) {
return datasets; // No valid color values, return original datasets
}
// Pre-compute which x-groups contain each color value to avoid repeated calculations
const colorValuesByXGroup: Map<number, Set<string>> = new Map();
for (let dataIndex = 0; dataIndex < xGroups.length; dataIndex++) {
const xGroup = xGroups[dataIndex];
const colorValuesInGroup = new Set<string>();
for (const row of xGroup.rows) {
const colorValue = row[colorByField];
if (colorValue !== null && colorValue !== undefined) {
colorValuesInGroup.add(String(colorValue));
}
}
colorValuesByXGroup.set(dataIndex, colorValuesInGroup);
}
const newDatasets: DatasetOption[] = [];
// For each original dataset, create separate datasets for each color value
for (const originalDataset of datasets) {
for (let i = 0; i < uniqueColorValues.length; i++) {
const colorValue = uniqueColorValues[i];
const color = colorMapping.get(colorValue); // Safe to use ! since we filtered above
// Create new data array with nulls where the color value doesn't match
const newData: (number | null)[] = [];
const newTooltipData: (typeof originalDataset.tooltipData)[0][] = [];
// Use pre-computed lookup for efficiency
for (let dataIndex = 0; dataIndex < originalDataset.data.length; dataIndex++) {
const colorValuesInGroup = colorValuesByXGroup.get(dataIndex);
const hasColorValue = colorValuesInGroup?.has(colorValue) ?? false;
if (hasColorValue) {
// Keep the original data point
newData.push(originalDataset.data[dataIndex]);
newTooltipData.push(originalDataset.tooltipData[dataIndex]);
} else {
// Set to null where color value doesn't exist
newData.push(null);
newTooltipData.push([]);
}
}
// Create new dataset ID and label
const newId = `${originalDataset.id}${i + 1}`;
const newLabel = [
{
key: colorByField,
value: colorValue,
},
];
newDatasets.push({
...originalDataset,
id: newId,
label: newLabel,
data: newData,
tooltipData: newTooltipData,
colors: color, // Single color string instead of array
});
}
}
return newDatasets;
}
/**
* Creates a consistent dataset ID by combining the measure field with category or x-axis information
*/

View File

@ -11,7 +11,7 @@ describe('useColorMapping', () => {
it('should create color mapping when colorBy is present', () => {
const { result } = renderHook(() =>
useColorMapping(mockData, { columnId: 'level' }, ['#FF0000', '#00FF00', '#0000FF'])
useColorMapping(mockData, ['level'], ['#FF0000', '#00FF00', '#0000FF'])
);
expect(result.current.hasColorMapping).toBe(true);
@ -36,16 +36,14 @@ describe('useColorMapping', () => {
});
it('should not create color mapping when colors array is empty', () => {
const { result } = renderHook(() => useColorMapping(mockData, { columnId: 'level' }, []));
const { result } = renderHook(() => useColorMapping(mockData, ['level'], []));
expect(result.current.hasColorMapping).toBe(false);
expect(result.current.colorMapping.size).toBe(0);
});
it('should handle undefined colors gracefully', () => {
const { result } = renderHook(() =>
useColorMapping(mockData, { columnId: 'level' }, undefined as any)
);
const { result } = renderHook(() => useColorMapping(mockData, ['level'], undefined as any));
expect(result.current.hasColorMapping).toBe(false);
expect(result.current.colorMapping.size).toBe(0);
@ -53,7 +51,7 @@ describe('useColorMapping', () => {
it('should return undefined for values not found', () => {
const { result } = renderHook(() =>
useColorMapping(mockData, { columnId: 'level' }, ['#FF0000', '#00FF00'])
useColorMapping(mockData, ['level'], ['#FF0000', '#00FF00'])
);
expect(result.current.getColorForValue('NonExistent')).toBeUndefined();
@ -71,7 +69,7 @@ describe('useColorMapping', () => {
const { result } = renderHook(() =>
useColorMapping(
dataWithManyLevels,
{ columnId: 'level' },
['level'],
['#FF0000', '#00FF00'] // Only 2 colors for 4 levels
)
);
@ -82,4 +80,37 @@ describe('useColorMapping', () => {
expect(result.current.getColorForValue('Level 3')).toBe('#FF0000'); // Cycles back
expect(result.current.getColorForValue('Level 4')).toBe('#00FF00'); // Cycles back
});
it('should handle empty data array gracefully', () => {
const { result } = renderHook(() =>
useColorMapping([], ['level'], ['#FF0000', '#00FF00', '#0000FF'])
);
expect(result.current.hasColorMapping).toBe(false);
expect(result.current.colorMapping.size).toBe(0);
expect(result.current.getColorForValue('Level 1')).toBeUndefined();
expect(result.current.colorConfig).toBeUndefined();
});
it('should handle data with null and undefined values in colorBy field', () => {
const dataWithNullValues = [
{ month: 'Jan', sales: 100, level: 'Level 1' },
{ month: 'Feb', sales: 200, level: null },
{ month: 'Mar', sales: 300, level: null },
{ month: 'Apr', sales: 400, level: 'Level 2' },
{ month: 'May', sales: 500, level: null }, // missing level field (set as null)
];
const { result } = renderHook(() =>
useColorMapping(dataWithNullValues, ['level'], ['#FF0000', '#00FF00', '#0000FF'])
);
// Should only create mappings for non-null/undefined values
expect(result.current.hasColorMapping).toBe(true);
expect(result.current.colorMapping.size).toBe(2); // Only 'Level 1' and 'Level 2'
expect(result.current.getColorForValue('Level 1')).toBe('#FF0000');
expect(result.current.getColorForValue('Level 2')).toBe('#00FF00');
expect(result.current.getColorForValue(null)).toBeUndefined();
expect(result.current.getColorForValue(undefined)).toBeUndefined();
});
});

View File

@ -11,19 +11,24 @@ import type { BusterChartProps } from '../../BusterChart.types';
* @param colors - Array of available colors
* @returns Object with color mapping and a function to get color for a value
*/
export function useColorMapping(
export const useColorMapping = (
data: NonNullable<BusterChartProps['data']>,
colorBy: ColorBy | null,
colors: string[]
) {
): {
hasColorMapping: boolean;
colorMapping: Map<string, string>;
colorConfig: { field: string; mapping: Map<string, string> } | undefined;
getColorForValue: (value: string | number | null | undefined) => string | undefined;
} => {
// Memoize the unique values extraction
const uniqueColorValues = useMemo(() => {
if (!colorBy?.columnId || !colors || colors.length === 0) {
if (!colorBy || !colors || colors.length === 0 || colorBy.length === 0) {
return new Set<string>();
}
const values = new Set<string>();
const columnId = colorBy.columnId;
const columnId = colorBy[0];
for (const row of data) {
const value = row[columnId];
@ -33,11 +38,11 @@ export function useColorMapping(
}
return values;
}, [data, colorBy?.columnId, colors?.length]);
}, [data, colorBy, colors?.length]);
// Memoize the color mapping creation
const colorMapping = useMemo(() => {
if (!colorBy?.columnId || !colors || colors.length === 0 || uniqueColorValues.size === 0) {
if (!colorBy || !colors || colors.length === 0 || uniqueColorValues.size === 0) {
return new Map<string, string>();
}
@ -49,18 +54,18 @@ export function useColorMapping(
});
return mapping;
}, [uniqueColorValues, colors, colorBy?.columnId]);
}, [uniqueColorValues, colors, colorBy]);
// Create colorConfig for dataset aggregation
const colorConfig = useMemo(() => {
if (!colorBy?.columnId || !colors || colors.length === 0 || colorMapping.size === 0) {
if (!colorBy || !colors || colors.length === 0 || colorMapping.size === 0) {
return undefined;
}
return {
field: colorBy.columnId,
field: colorBy[0],
mapping: colorMapping,
};
}, [colorBy?.columnId, colors?.length, colorMapping]);
}, [colorBy, colors?.length, colorMapping]);
// Return the mapping and helper functions
return useMemo(
@ -77,4 +82,4 @@ export function useColorMapping(
}),
[colorMapping, colors, colorConfig]
);
}
};

View File

@ -50,6 +50,7 @@ export enum SelectAxisContainerId {
Tooltip = 'tooltip',
Y2Axis = 'y2Axis',
Metric = 'metric',
ColorBy = 'colorBy',
}
export const zoneIdToAxis: Record<SelectAxisContainerId, string> = {
@ -61,6 +62,7 @@ export const zoneIdToAxis: Record<SelectAxisContainerId, string> = {
[SelectAxisContainerId.Tooltip]: 'tooltip',
[SelectAxisContainerId.Y2Axis]: 'y2',
[SelectAxisContainerId.Metric]: 'metric',
[SelectAxisContainerId.ColorBy]: 'colorBy',
};
export const chartTypeToAxis: Record<

View File

@ -15,6 +15,7 @@ export const ZoneIdToTitle: Record<SelectAxisContainerId, string> = {
[SelectAxisContainerId.Tooltip]: 'Tooltip',
[SelectAxisContainerId.Available]: 'Available',
[SelectAxisContainerId.Metric]: 'Metric',
[SelectAxisContainerId.ColorBy]: 'Color By',
};
const makeDropZone = (
@ -62,6 +63,9 @@ const makeTooltipDropZone = (tooltipItems: string[] | null | undefined): DropZon
const makeY2AxisDropZone = (y2Items: string[] | null | undefined): DropZone =>
makeDropZone(SelectAxisContainerId.Y2Axis, y2Items ?? EMPTY_ARRAY);
const makeColorByDropZone = (colorByItems: string[] | null | undefined): DropZone =>
makeDropZone(SelectAxisContainerId.ColorBy, colorByItems ?? EMPTY_ARRAY);
export const chartTypeToDropZones: Record<
ChartConfigProps['selectedChartType'],
(
@ -80,6 +84,7 @@ export const chartTypeToDropZones: Record<
isHorizontalBar
? makeReverseXAxisDropZone(_selectedAxis.x)
: makeYAxisDropZone(_selectedAxis.y),
makeColorByDropZone(_selectedAxis.colorBy?.columnId),
makeCategoryAxisDropZone(_selectedAxis.category),
makeTooltipDropZone(_selectedAxis.tooltip),
];

View File

@ -1,33 +1,6 @@
import { z } from 'zod';
export const ColorBySchema = z
.object({
/**
* Column ID whose values will determine colors for chart elements.
* Use this when you want to apply colors based on a column's values
* without creating separate series (unlike category which creates multiple series).
*
* Example use cases:
* - Color bars by status (red for "failed", green for "success")
* - Color points by priority level
* - Apply conditional formatting based on categorical values
*/
columnId: z.string().nullable().optional(),
/**
* Optional override for specific value-to-color mappings.
* This allows custom color assignment for specific values.
*/
overrideColorByValue: z
.object({
// The specific value to override color for
value: z.string(),
})
.nullable()
.optional(),
})
.nullable()
.optional()
.default(null);
export const ColorBySchema = z.tuple([z.string()]).or(z.array(z.string()).length(0)).default([]);
export const BarAndLineAxisSchema = z
.object({
@ -63,7 +36,7 @@ export const BarAndLineAxisSchema = z
y: [],
category: [],
tooltip: null,
colorBy: null,
colorBy: [],
});
export const ScatterAxisSchema = z
@ -143,7 +116,7 @@ export const ComboChartAxisSchema = z
y2: [],
category: [],
tooltip: null,
colorBy: null,
colorBy: [],
});
export const PieChartAxisSchema = z