From 79c00c490629cdfb9c0a75e55b897c297d25874a Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Mon, 31 Mar 2025 11:30:56 -0600 Subject: [PATCH] chartjs handle legend items click a litle better --- .../ui/charts/BusterChartJS/BusterChartJS.tsx | 2 +- .../BusterChartJS/BusterChartJSComponent.tsx | 20 +- .../useBusterChartJSLegend.ts | 91 ++++--- .../hooks/useOptions/useOptions.tsx | 24 +- .../datasetHelpers_Scatter.ts | 6 + .../useDatasetOptions/useDatasetOptions.tsx | 11 +- web/src/components/ui/charts/config/config.ts | 5 + .../ui/charts/{ => config}/configColors.ts | 0 web/src/components/ui/charts/config/index.ts | 2 + web/src/components/ui/charts/index.ts | 2 +- .../BusterChart.ScatterChart.stories.tsx | 8 +- .../StylingAppColors/ColorsApp/config.ts | 2 +- web/src/lib/downsample.test.ts | 237 ++++++++++++++++++ web/src/lib/downsample.ts | 102 ++++++++ 14 files changed, 447 insertions(+), 65 deletions(-) create mode 100644 web/src/components/ui/charts/config/config.ts rename web/src/components/ui/charts/{ => config}/configColors.ts (100%) create mode 100644 web/src/components/ui/charts/config/index.ts create mode 100644 web/src/lib/downsample.test.ts create mode 100644 web/src/lib/downsample.ts diff --git a/web/src/components/ui/charts/BusterChartJS/BusterChartJS.tsx b/web/src/components/ui/charts/BusterChartJS/BusterChartJS.tsx index b2a31f28b..240dd398a 100644 --- a/web/src/components/ui/charts/BusterChartJS/BusterChartJS.tsx +++ b/web/src/components/ui/charts/BusterChartJS/BusterChartJS.tsx @@ -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 = ({ selectedChartType, diff --git a/web/src/components/ui/charts/BusterChartJS/BusterChartJSComponent.tsx b/web/src/components/ui/charts/BusterChartJS/BusterChartJSComponent.tsx index e42f7be24..cb11e6838 100644 --- a/web/src/components/ui/charts/BusterChartJS/BusterChartJSComponent.tsx +++ b/web/src/components/ui/charts/BusterChartJS/BusterChartJSComponent.tsx @@ -180,17 +180,17 @@ export const BusterChartJSComponent = React.memo( return []; }, [selectedChartType]); + console.log(options, data); + return ( - - - + ); } ) diff --git a/web/src/components/ui/charts/BusterChartJS/hooks/useBusterChartJSLegend/useBusterChartJSLegend.ts b/web/src/components/ui/charts/BusterChartJS/hooks/useBusterChartJSLegend/useBusterChartJSLegend.ts index 776036487..ea96ad04f 100644 --- a/web/src/components/ui/charts/BusterChartJS/hooks/useBusterChartJSLegend/useBusterChartJSLegend.ts +++ b/web/src/components/ui/charts/BusterChartJS/hooks/useBusterChartJSLegend/useBusterChartJSLegend.ts @@ -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, diff --git a/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useOptions.tsx b/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useOptions.tsx index 0bee3f160..95226e70d 100644 --- a/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useOptions.tsx +++ b/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useOptions.tsx @@ -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['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['options']; }, [ animate, colors, diff --git a/web/src/components/ui/charts/chartHooks/useDatasetOptions/datasetHelpers_Scatter.ts b/web/src/components/ui/charts/chartHooks/useDatasetOptions/datasetHelpers_Scatter.ts index c09899ba9..69d68bac3 100644 --- a/web/src/components/ui/charts/chartHooks/useDatasetOptions/datasetHelpers_Scatter.ts +++ b/web/src/components/ui/charts/chartHooks/useDatasetOptions/datasetHelpers_Scatter.ts @@ -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[number]; +export const downsampleScatterData = (data: NonNullable) => { + return randomSampling(data, DOWNSIZE_SAMPLE_THRESHOLD); +}; + export const mapScatterData = ( sortedData: NonNullable, categoryFields: string[] diff --git a/web/src/components/ui/charts/chartHooks/useDatasetOptions/useDatasetOptions.tsx b/web/src/components/ui/charts/chartHooks/useDatasetOptions/useDatasetOptions.tsx index 063971846..ee85d4d4f 100644 --- a/web/src/components/ui/charts/chartHooks/useDatasetOptions/useDatasetOptions.tsx +++ b/web/src/components/ui/charts/chartHooks/useDatasetOptions/useDatasetOptions.tsx @@ -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 diff --git a/web/src/components/ui/charts/config/config.ts b/web/src/components/ui/charts/config/config.ts new file mode 100644 index 000000000..cb6ae11b3 --- /dev/null +++ b/web/src/components/ui/charts/config/config.ts @@ -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; diff --git a/web/src/components/ui/charts/configColors.ts b/web/src/components/ui/charts/config/configColors.ts similarity index 100% rename from web/src/components/ui/charts/configColors.ts rename to web/src/components/ui/charts/config/configColors.ts diff --git a/web/src/components/ui/charts/config/index.ts b/web/src/components/ui/charts/config/index.ts new file mode 100644 index 000000000..f8b3beb79 --- /dev/null +++ b/web/src/components/ui/charts/config/index.ts @@ -0,0 +1,2 @@ +export * from './configColors'; +export * from './config'; diff --git a/web/src/components/ui/charts/index.ts b/web/src/components/ui/charts/index.ts index a60493b5c..d68a337f3 100644 --- a/web/src/components/ui/charts/index.ts +++ b/web/src/components/ui/charts/index.ts @@ -1,5 +1,5 @@ import { BusterChart } from './BusterChart'; -export * from './configColors'; +export * from './config/configColors'; export { BusterChart }; diff --git a/web/src/components/ui/charts/stories/BusterChart.ScatterChart.stories.tsx b/web/src/components/ui/charts/stories/BusterChart.ScatterChart.stories.tsx index d65ce1e8a..552b1cc17 100644 --- a/web/src/components/ui/charts/stories/BusterChart.ScatterChart.stories.tsx +++ b/web/src/components/ui/charts/stories/BusterChart.ScatterChart.stories.tsx @@ -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', diff --git a/web/src/controllers/MetricController/MetricViewChart/MetricEditController/MetricStylingApp/StylingAppColors/ColorsApp/config.ts b/web/src/controllers/MetricController/MetricViewChart/MetricEditController/MetricStylingApp/StylingAppColors/ColorsApp/config.ts index c34fb029d..95005d03c 100644 --- a/web/src/controllers/MetricController/MetricViewChart/MetricEditController/MetricStylingApp/StylingAppColors/ColorsApp/config.ts +++ b/web/src/controllers/MetricController/MetricViewChart/MetricEditController/MetricStylingApp/StylingAppColors/ColorsApp/config.ts @@ -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 { diff --git a/web/src/lib/downsample.test.ts b/web/src/lib/downsample.test.ts new file mode 100644 index 000000000..918bbefeb --- /dev/null +++ b/web/src/lib/downsample.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/downsample.ts b/web/src/lib/downsample.ts new file mode 100644 index 000000000..85b7bc6cf --- /dev/null +++ b/web/src/lib/downsample.ts @@ -0,0 +1,102 @@ +export type DataPoint = Record; + +/** + * 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; +}