chartjs handle legend items click a litle better

This commit is contained in:
Nate Kelley 2025-03-31 11:30:56 -06:00
parent f6a8246c9f
commit 79c00c4906
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
14 changed files with 447 additions and 65 deletions

View File

@ -8,7 +8,7 @@ import { BusterChartJSLegendWrapper } from './BusterChartJSLegendWrapper';
import { ChartJSOrUndefined } from './core/types';
import { useMemoizedFn } from '@/hooks';
import { BusterChartJSComponent } from './BusterChartJSComponent';
import { BusterChartComponentProps } from '../interfaces/chartComponentInterfaces';
import type { BusterChartComponentProps } from '../interfaces';
export const BusterChartJS: React.FC<BusterChartComponentProps> = ({
selectedChartType,

View File

@ -180,17 +180,17 @@ export const BusterChartJSComponent = React.memo(
return [];
}, [selectedChartType]);
console.log(options, data);
return (
<React.Fragment>
<Chart
className={className}
ref={ref}
options={options}
data={data}
type={type}
plugins={chartSpecificPlugins}
/>
</React.Fragment>
<Chart
className={className}
ref={ref}
options={options}
data={data}
type={type}
plugins={chartSpecificPlugins}
/>
);
}
)

View File

@ -75,44 +75,42 @@ export const useBusterChartJSLegend = ({
const categoryAxisColumnNames = (selectedAxis as ComboChartAxis).category as string[];
const { run: calculateLegendItems } = useDebounceFn(
() => {
if (showLegend === false) return;
const calculateLegendItems = useMemoizedFn(() => {
if (showLegend === false) return;
const items = getLegendItems({
chartRef,
colors,
inactiveDatasets,
selectedChartType,
allYAxisColumnNames,
const items = getLegendItems({
chartRef,
colors,
inactiveDatasets,
selectedChartType,
allYAxisColumnNames,
columnLabelFormats,
categoryAxisColumnNames,
columnSettings
});
if (!isStackPercentage && showLegendHeadline) {
addLegendHeadlines(
items,
datasetOptions,
showLegendHeadline,
columnMetadata,
columnLabelFormats,
categoryAxisColumnNames,
columnSettings
});
if (!isStackPercentage && showLegendHeadline) {
addLegendHeadlines(
items,
datasetOptions,
showLegendHeadline,
columnMetadata,
columnLabelFormats,
selectedChartType
);
}
startTransition(() => {
setLegendItems(items);
});
},
{
wait: 125
selectedChartType
);
}
);
setLegendItems(items);
});
const { run: calculateLegendItemsDebounced } = useDebounceFn(calculateLegendItems, {
wait: 125
});
const onHoverItem = useMemoizedFn((item: BusterChartLegendItem, isHover: boolean) => {
const chartjs = chartRef.current;
if (!chartjs) return;
if (chartjs.options.animation === false) return;
const data = chartjs.data;
const hasMultipleDatasets = data.datasets?.length > 1;
@ -148,6 +146,13 @@ export const useBusterChartJSLegend = ({
const data = chartjs.data;
setInactiveDatasets((prev) => ({
...prev,
[item.id]: prev[item.id] ? !prev[item.id] : true
}));
console.log('hit1', performance.now());
if (selectedChartType === 'pie') {
const index = data.labels?.indexOf(item.id) || 0;
// Pie and doughnut charts only have a single dataset and visibility is per item
@ -158,12 +163,16 @@ export const useBusterChartJSLegend = ({
chartjs.setDatasetVisibility(index, !chartjs.isDatasetVisible(index));
}
}
chartjs.update();
setInactiveDatasets((prev) => ({
...prev,
[item.id]: prev[item.id] ? !prev[item.id] : true
}));
console.log('hit2', performance.now());
//put this in a timeout and transition to avoid blocking the main thread
setTimeout(() => {
startTransition(() => {
chartjs.update();
console.log('hit3', performance.now());
});
}, 125);
});
const onLegendItemFocus = useMemoizedFn((item: BusterChartLegendItem) => {
@ -200,15 +209,17 @@ export const useBusterChartJSLegend = ({
chartjs.update();
});
//immediate items
useEffect(() => {
console.log('should run', performance.now());
calculateLegendItems();
}, [
colors,
isStackPercentage,
showLegend,
selectedChartType,
chartMounted,
selectedChartType,
isStackPercentage,
inactiveDatasets,
showLegend,
colors,
showLegendHeadline,
columnLabelFormats,
allYAxisColumnNames,

View File

@ -16,6 +16,12 @@ import { DatasetOption } from '../../../chartHooks';
import { useY2Axis } from './useY2Axis';
import { AnnotationPluginOptions } from 'chartjs-plugin-annotation';
import type { BusterChartTypeComponentProps } from '../../../interfaces';
import {
ANIMATION_DURATION,
ANIMATION_THRESHOLD,
LINE_DECIMATION_SAMPLES,
LINE_DECIMATION_THRESHOLD
} from '../../../config';
interface UseOptionsProps {
colors: string[];
@ -160,6 +166,13 @@ export const useOptions = ({
const interaction = useInteractions({ selectedChartType, barLayout });
const isAnimationEnabled = useMemo(() => {
const numberOfSources = datasetOptions.reduce((acc, curr) => {
return acc + curr.source.length;
}, 0);
return animate && numberOfSources <= ANIMATION_THRESHOLD;
}, [animate, datasetOptions]);
const tooltipOptions = useTooltipOptions({
columnLabelFormats,
selectedChartType,
@ -171,18 +184,18 @@ export const useOptions = ({
columnSettings,
datasetOptions,
hasMismatchedTooltipsAndMeasures,
disableTooltip,
disableTooltip: disableTooltip || !isAnimationEnabled,
colors
});
const options: ChartProps<ChartJSChartType>['options'] = useMemo(() => {
const chartAnnotations = chartPlugins?.annotation?.annotations;
const isLargeDataset = datasetOptions[0].source.length > 125;
const isLargeDataset = datasetOptions[0].source.length > LINE_DECIMATION_THRESHOLD;
return {
indexAxis: isHorizontalBar ? 'y' : 'x',
animation: animate && !isLargeDataset ? { duration: 1000 } : { duration: 0 },
animation: isAnimationEnabled ? { duration: ANIMATION_DURATION } : false,
backgroundColor: colors,
borderColor: colors,
scales,
@ -196,11 +209,12 @@ export const useOptions = ({
},
decimation: {
enabled: isLargeDataset,
algorithm: 'min-max'
algorithm: 'lttb',
samples: LINE_DECIMATION_SAMPLES
}
},
...chartOptions
};
} satisfies ChartProps<ChartJSChartType>['options'];
}, [
animate,
colors,

View File

@ -6,9 +6,15 @@ import {
import { createDimension } from './datasetHelpers_BarLinePie';
import { appendToKeyValueChain } from './groupingHelpers';
import { DatasetOption } from './interfaces';
import { randomSampling } from '@/lib/downsample';
import { DOWNSIZE_SAMPLE_THRESHOLD } from '../../config';
type DataItem = NonNullable<BusterChartProps['data']>[number];
export const downsampleScatterData = (data: NonNullable<BusterChartProps['data']>) => {
return randomSampling(data, DOWNSIZE_SAMPLE_THRESHOLD);
};
export const mapScatterData = (
sortedData: NonNullable<BusterChartProps['data']>,
categoryFields: string[]

View File

@ -23,6 +23,7 @@ import {
sortLineBarData
} from './datasetHelpers_BarLinePie';
import {
downsampleScatterData,
getScatterDatasetOptions,
getScatterDimensions,
getScatterTooltipKeys,
@ -110,15 +111,15 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
return uniq([...yAxisFields, ...y2AxisFields, ...tooltipFields]);
}, [yAxisFieldsString, y2AxisFieldsString, tooltipFieldsString]);
const sortedData = useMemo(() => {
if (isScatter) return data;
const sortedAndLimitedData = useMemo(() => {
if (isScatter) return downsampleScatterData(data);
return sortLineBarData(data, xFieldSorts, xFields);
}, [data, xFieldSortsString, xFieldsString, isScatter]);
const { dataMap, xValuesSet, categoriesSet } = useMemo(() => {
if (isScatter) return mapScatterData(sortedData, categoryFields);
return mapLineBarPieData(sortedData, xFields, categoryFields, measureFields);
}, [sortedData, xFieldsString, categoryFieldsString, measureFields, isScatter]);
if (isScatter) return mapScatterData(sortedAndLimitedData, categoryFields);
return mapLineBarPieData(sortedAndLimitedData, xFields, categoryFields, measureFields);
}, [sortedAndLimitedData, xFieldsString, categoryFieldsString, measureFields, isScatter]);
const measureFieldsReplaceDataWithKey = useMemo(() => {
return measureFields

View File

@ -0,0 +1,5 @@
export const DOWNSIZE_SAMPLE_THRESHOLD = 500;
export const LINE_DECIMATION_THRESHOLD = 300;
export const LINE_DECIMATION_SAMPLES = 450;
export const ANIMATION_THRESHOLD = 125;
export const ANIMATION_DURATION = 1000;

View File

@ -0,0 +1,2 @@
export * from './configColors';
export * from './config';

View File

@ -1,5 +1,5 @@
import { BusterChart } from './BusterChart';
export * from './configColors';
export * from './config/configColors';
export { BusterChart };

View File

@ -4,7 +4,6 @@ import { ChartType } from '../../../../api/asset_interfaces/metric/charts/enum';
import { IColumnLabelFormat } from '../../../../api/asset_interfaces/metric/charts/columnLabelInterfaces';
import { generateScatterChartData } from '../../../../mocks/chart/chartMocks';
import { sharedMeta } from './BusterChartShared';
import { BusterChartProps } from '@/api/asset_interfaces/metric';
import React from 'react';
import { Slider } from '@/components/ui/slider';
import { useDebounceFn } from '@/hooks';
@ -26,9 +25,14 @@ export const Default: Story = {
scatterAxis: {
x: ['x'],
y: ['y'],
size: ['size'],
size: [],
category: ['category']
},
barAndLineAxis: {
x: ['x'],
y: ['y'],
category: []
},
columnLabelFormats: {
x: {
columnType: 'number',

View File

@ -24,7 +24,7 @@ import {
BROWN_THEME,
PINK_THEME,
BLUE_THEME
} from '@/components/ui/charts/configColors';
} from '@/components/ui/charts/config/configColors';
import { IColorTheme } from '../Common/interfaces';
export enum ColorAppSegments {

View File

@ -0,0 +1,237 @@
import { uniformSampling, randomSampling } from './downsample';
import type { DataPoint } from './downsample';
describe('uniformSampling', () => {
// Sample data for testing
const testData: DataPoint[] = [
{ x: 0, y: 10 },
{ x: 1, y: 20 },
{ x: 2, y: 30 },
{ x: 3, y: 40 },
{ x: 4, y: 50 },
{ x: 5, y: 60 },
{ x: 6, y: 70 },
{ x: 7, y: 80 },
{ x: 8, y: 90 },
{ x: 9, y: 100 }
];
it('should return original data if targetPoints is greater than or equal to data length', () => {
// Test with targetPoints equal to data length
const result1 = uniformSampling(testData, 10);
expect(result1).toBe(testData);
// Test with targetPoints greater than data length
const result2 = uniformSampling(testData, 15);
expect(result2).toBe(testData);
});
it('should return just first and last points when targetPoints is 2', () => {
const result = uniformSampling(testData, 2);
expect(result.length).toBe(2);
expect(result[0]).toEqual(testData[0]);
expect(result[1]).toEqual(testData[testData.length - 1]);
});
it('should sample points at regular intervals when targetPoints is between 2 and data length', () => {
const targetPoints = 5;
const result = uniformSampling(testData, targetPoints);
// Should have exactly the requested number of points
expect(result.length).toBe(targetPoints);
// First and last points should be the same as original data
expect(result[0]).toEqual(testData[0]);
expect(result[result.length - 1]).toEqual(testData[testData.length - 1]);
// Check that intermediate points are at expected positions
// For example, with 5 points from 10, we'd expect points at indices 0, 3, 6, 8, 9
// This is because we include first and last points and space the middle 3 evenly
const step = (testData.length - 2) / (targetPoints - 2);
for (let i = 1; i < targetPoints - 1; i++) {
const expectedIndex = Math.floor(1 + i * step);
expect(result[i]).toEqual(testData[expectedIndex]);
}
});
it('should handle null or empty input appropriately', () => {
expect(uniformSampling(null, 5)).toEqual([]);
expect(uniformSampling([], 5)).toEqual([]);
});
it('should efficiently sample from a large dataset of 1000 points', () => {
// Generate a dataset with 1000 points
const largeDataset: DataPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.sin(i * 0.01) * 100 + 100 // Generate sine wave data
}));
const targetPoints = 50;
const result = uniformSampling(largeDataset, targetPoints);
// Should have exactly the requested number of points
expect(result.length).toBe(targetPoints);
// First and last points should be the same as original data
expect(result[0]).toEqual(largeDataset[0]);
expect(result[result.length - 1]).toEqual(largeDataset[largeDataset.length - 1]);
// Verify that intermediate points are properly sampled
const step = (largeDataset.length - 2) / (targetPoints - 2);
// Check a few specific points
const pointsToCheck = [1, Math.floor(targetPoints / 2), targetPoints - 2];
for (const i of pointsToCheck) {
const expectedIndex = Math.floor(1 + i * step);
expect(result[i]).toEqual(largeDataset[expectedIndex]);
}
});
});
describe('randomSampling', () => {
// Sample data for testing
const testData: DataPoint[] = [
{ x: 0, y: 10 },
{ x: 1, y: 20 },
{ x: 2, y: 30 },
{ x: 3, y: 40 },
{ x: 4, y: 50 },
{ x: 5, y: 60 },
{ x: 6, y: 70 },
{ x: 7, y: 80 },
{ x: 8, y: 90 },
{ x: 9, y: 100 }
];
it('should return original data if targetPoints is greater than or equal to data length', () => {
// Test with targetPoints equal to data length
const result1 = randomSampling(testData, 10);
expect(result1).toBe(testData);
// Test with targetPoints greater than data length
const result2 = randomSampling(testData, 15);
expect(result2).toBe(testData);
});
it('should return first and last points when preserveEnds is true and targetPoints is 2', () => {
const result = randomSampling(testData, 2, true);
expect(result.length).toBe(2);
expect(result[0]).toEqual(testData[0]); // First point
expect(result[1]).toEqual(testData[testData.length - 1]); // Last point
});
it('should return exactly targetPoints number of points', () => {
const targetPoints = 5;
const result = randomSampling(testData, targetPoints);
expect(result.length).toBe(targetPoints);
// With default preserveEnds=true, first and last points should be preserved
expect(result[0]).toEqual(testData[0]);
expect(result[result.length - 1]).toEqual(testData[testData.length - 1]);
});
it('should not include first and last points when preserveEnds is false', () => {
const targetPoints = 5;
const result = randomSampling(testData, targetPoints, false);
expect(result.length).toBe(targetPoints);
// Points should be randomly selected but maintained in original order
let isOrdered = true;
const indices = testData
.map((point, index) => {
const resultIndex = result.findIndex((p) => p === point);
return resultIndex !== -1 ? index : -1;
})
.filter((index) => index !== -1);
for (let i = 1; i < indices.length; i++) {
if (indices[i] < indices[i - 1]) {
isOrdered = false;
break;
}
}
expect(isOrdered).toBe(true);
});
it('should handle null or empty input appropriately', () => {
expect(randomSampling(null, 5)).toEqual([]);
expect(randomSampling([], 5)).toEqual([]);
// Test with preserveEnds=false
expect(randomSampling(null, 5, false)).toEqual([]);
expect(randomSampling([], 5, false)).toEqual([]);
// Edge case: should handle targetPoints=1 correctly
const singlePoint = randomSampling(testData, 1, true);
expect(singlePoint.length).toBe(1);
expect(singlePoint[0]).toEqual(testData[0]); // With preserveEnds, should be first point
const singlePointNoPreserve = randomSampling(testData, 1, false);
expect(singlePointNoPreserve.length).toBe(1);
// With preserveEnds=false, should be any point from the dataset
expect(testData).toContainEqual(singlePointNoPreserve[0]);
});
it('should efficiently handle very large datasets with performance testing', () => {
// Generate a complex dataset with over 10,000 points
const veryLargeDataset: DataPoint[] = Array.from({ length: 10000 }, (_, i) => ({
x: i,
y: Math.sin(i * 0.005) * 50 + Math.cos(i * 0.002) * 30 + 100, // Complex wave pattern
z: i % 5, // Add another property to make objects more complex
timestamp: new Date(2023, 0, 1, 0, 0, i % 60, i % 1000) // Add date objects too
}));
// Performance test - measure time to downsample
const startTime = performance.now();
// Test downsampling to different sizes
const sampledLarge = randomSampling(veryLargeDataset, 500, true);
const sampledMedium = randomSampling(veryLargeDataset, 100, true);
const sampledSmall = randomSampling(veryLargeDataset, 50, false);
const endTime = performance.now();
const executionTime = endTime - startTime;
// Verify correct number of points
expect(sampledLarge.length).toBe(500);
expect(sampledMedium.length).toBe(100);
expect(sampledSmall.length).toBe(50);
// With preserveEnds=true, first and last points should be preserved
expect(sampledLarge[0]).toEqual(veryLargeDataset[0]);
expect(sampledLarge[sampledLarge.length - 1]).toEqual(
veryLargeDataset[veryLargeDataset.length - 1]
);
expect(sampledMedium[0]).toEqual(veryLargeDataset[0]);
expect(sampledMedium[sampledMedium.length - 1]).toEqual(
veryLargeDataset[veryLargeDataset.length - 1]
);
// With preserveEnds=false, we expect random selection but points should still come from the dataset
sampledSmall.forEach((point) => {
const found = veryLargeDataset.some((dataPoint) => {
// Check numeric and string properties
const basicPropsMatch =
dataPoint.x === point.x && dataPoint.y === point.y && dataPoint.z === point.z;
// Handle timestamp date comparison separately with proper type checking
const timestampMatches =
dataPoint.timestamp instanceof Date && point.timestamp instanceof Date
? dataPoint.timestamp.getTime() === point.timestamp.getTime()
: dataPoint.timestamp === point.timestamp;
return basicPropsMatch && timestampMatches;
});
expect(found).toBe(true);
});
expect(executionTime).toBeLessThan(200);
});
});

102
web/src/lib/downsample.ts Normal file
View File

@ -0,0 +1,102 @@
export type DataPoint = Record<string, string | number | null | Date>;
/**
* Uniform sampling - selects points at regular intervals
* @param data - Array of data points to downsample
* @param targetPoints - Target number of points in the downsampled dataset
* @returns Downsampled array with exactly targetPoints number of points (or original if already smaller)
*/
export function uniformSampling(data: DataPoint[] | null, targetPoints: number): DataPoint[] {
if (!data || data.length <= targetPoints) {
return data || [];
}
const result: DataPoint[] = [];
// Always include the first and last points
result.push(data[0]);
// If we only want 2 points, just return first and last
if (targetPoints === 2) {
result.push(data[data.length - 1]);
return result;
}
// Calculate the step size to get targetPoints - 2 intermediate points
const step = (data.length - 2) / (targetPoints - 2);
// Select intermediate points at regular intervals
for (let i = 1; i < targetPoints - 1; i++) {
const index = Math.floor(1 + i * step);
result.push(data[index]);
}
// Add the last point
result.push(data[data.length - 1]);
return result;
}
/**
* Random sampling - selects points randomly
* @param data - Array of data points to downsample
* @param targetPoints - Target number of points in the downsampled dataset
* @param preserveEnds - Whether to always include the first and last points (default: true)
* @returns Downsampled array with exactly targetPoints number of points (or original if already smaller)
*/
export function randomSampling(
data: DataPoint[] | null,
targetPoints: number,
preserveEnds: boolean = true
): DataPoint[] {
if (!data || data.length <= targetPoints) {
return data || [];
}
if (preserveEnds && targetPoints < 2) {
return data.slice(0, 1); // Return just the first point if targetPoints is 1
}
// If preserving ends, adjust target to account for first and last points
const adjustedTarget = preserveEnds ? targetPoints - 2 : targetPoints;
const result: DataPoint[] = [];
if (preserveEnds) {
result.push(data[0]); // Add first point
}
// Create an array of available indices (excluding first and last if preserveEnds)
const availableIndices: number[] = [];
const startIdx = preserveEnds ? 1 : 0;
const endIdx = preserveEnds ? data.length - 1 : data.length;
for (let i = startIdx; i < endIdx; i++) {
availableIndices.push(i);
}
// Randomly select indices
for (let i = 0; i < adjustedTarget && availableIndices.length > 0; i++) {
const randomIndex = Math.floor(Math.random() * availableIndices.length);
const dataIndex = availableIndices[randomIndex];
// Remove the selected index from available indices
availableIndices.splice(randomIndex, 1);
result.push(data[dataIndex]);
}
if (preserveEnds) {
result.push(data[data.length - 1]); // Add last point
}
// Sort the result by the original index to maintain order
if (!preserveEnds) {
result.sort((a, b) => {
const aIndex = data.indexOf(a);
const bIndex = data.indexOf(b);
return aIndex - bIndex;
});
}
return result;
}