mirror of https://github.com/buster-so/buster.git
chartjs handle legend items click a litle better
This commit is contained in:
parent
f6a8246c9f
commit
79c00c4906
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export * from './configColors';
|
||||
export * from './config';
|
|
@ -1,5 +1,5 @@
|
|||
import { BusterChart } from './BusterChart';
|
||||
|
||||
export * from './configColors';
|
||||
export * from './config/configColors';
|
||||
|
||||
export { BusterChart };
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue