Nate/linear regressions (#290)

Scatter chart fixes for regression lines
This commit is contained in:
Nate Kelley 2025-05-08 15:37:04 -06:00 committed by GitHub
parent 8ecea82a88
commit fdde29b9ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1365 additions and 90 deletions

View File

@ -1,14 +1,11 @@
import { type IColumnLabelFormat } from '@/api/asset_interfaces/metric'; import { type IColumnLabelFormat } from '@/api/asset_interfaces/metric';
import { scatterSeriesBuilder_data, scatterSeriesBuilder_labels } from './scatterSeriesBuilder'; import { scatterSeriesBuilder_data, scatterSeriesBuilder_labels } from './scatterSeriesBuilder';
import { createDayjsDate } from '@/lib/date'; import { createDayjsDate } from '@/lib/date';
import type { import type { DatasetOptionsWithTicks } from '../../../chartHooks/useDatasetOptions/interfaces';
DatasetOptionsWithTicks,
DatasetOption
} from '../../../chartHooks/useDatasetOptions/interfaces';
import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric'; import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric';
import type { SeriesBuilderProps } from './interfaces'; import type { SeriesBuilderProps } from './interfaces';
import { ChartType } from '@/api/asset_interfaces/metric/charts/enum';
import type { LabelBuilderProps } from './useSeriesOptions'; import type { LabelBuilderProps } from './useSeriesOptions';
import { DEFAULT_COLUMN_LABEL_FORMAT } from '@/api/asset_interfaces/metric';
describe('scatterSeriesBuilder_data', () => { describe('scatterSeriesBuilder_data', () => {
const mockColors = ['#FF0000', '#00FF00']; const mockColors = ['#FF0000', '#00FF00'];
@ -126,67 +123,118 @@ describe('scatterSeriesBuilder_data', () => {
}); });
describe('scatterSeriesBuilder_labels', () => { describe('scatterSeriesBuilder_labels', () => {
const baseTrendlineSeries = [ test('should return undefined when trendlineSeries is empty', () => {
{ const props: LabelBuilderProps = {
yAxisKey: 'metric1', trendlineSeries: [],
data: [10, 20, 30], datasetOptions: {
label: 'Trendline 1', ticks: [],
tooltipData: [], datasets: [],
xAxisKeys: ['timestamp'], ticksKey: []
type: 'line' as const, },
borderColor: '#FF0000', columnLabelFormats: {},
borderWidth: 2, xAxisKeys: ['x'],
pointRadius: 0 sizeKey: [],
} columnSettings: {}
]; };
const baseProps: LabelBuilderProps = {
trendlineSeries: baseTrendlineSeries,
datasetOptions: {
datasets: [
{
id: '1',
dataKey: 'metric1',
data: [10, 20, 30],
ticksForScatter: [
[1000, 'Jan 1'],
[2000, 'Jan 2'],
[3000, 'Jan 3']
],
label: [{ key: 'metric1', value: 'Dataset 1' }],
axisType: 'y',
tooltipData: []
}
],
ticks: [
[1000, 'Jan 1'],
[2000, 'Jan 2'],
[3000, 'Jan 3']
],
ticksKey: [{ key: 'timestamp', value: 'Timestamp' }]
},
columnLabelFormats: {
timestamp: {
columnType: 'timestamp' as SimplifiedColumnType,
style: 'date'
}
},
xAxisKeys: ['timestamp'],
sizeKey: [],
columnSettings: {}
};
it('should return undefined when no trendlines exist', () => {
const result = scatterSeriesBuilder_labels({
...baseProps,
trendlineSeries: []
});
const result = scatterSeriesBuilder_labels(props);
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should process trendline series correctly', () => { test('should process date labels directly when x-axis is date type', () => {
const result = scatterSeriesBuilder_labels(baseProps); const dateString1 = '2023-01-01';
expect(result).toBeDefined(); const dateString2 = '2023-01-02';
const props: LabelBuilderProps = {
trendlineSeries: [{ yAxisKey: 'y1' } as any],
datasetOptions: {
ticks: [[dateString1], [dateString2]],
datasets: [{ dataKey: 'y1', data: [10, 20] } as any],
ticksKey: [{ key: 'x', value: 'X Axis' }]
} as any,
columnLabelFormats: {
x: {
columnType: 'date',
style: 'date'
}
},
xAxisKeys: ['x'],
sizeKey: [],
columnSettings: {}
};
const result = scatterSeriesBuilder_labels(props);
expect(result).toEqual([
createDayjsDate(dateString1).toDate(),
createDayjsDate(dateString2).toDate()
]);
});
test('should collect all ticks without deduplication', () => {
const props: LabelBuilderProps = {
trendlineSeries: [{ yAxisKey: 'y1' } as any, { yAxisKey: 'y2' } as any],
datasetOptions: {
ticks: [],
datasets: [
{
dataKey: 'y1',
data: [10, 20],
ticksForScatter: [
[1, 'A'],
[2, 'B']
]
} as any,
{
dataKey: 'y2',
data: [30, 40, 50],
ticksForScatter: [
[1, 'A'],
[3, 'C'],
[5, 'D']
]
} as any
],
ticksKey: [{ key: 'x', value: 'X Axis' }]
} as any,
columnLabelFormats: {
x: DEFAULT_COLUMN_LABEL_FORMAT
},
xAxisKeys: ['x'],
sizeKey: [],
columnSettings: {}
};
const result = scatterSeriesBuilder_labels(props);
// Should include duplicate [1, 'A'] from both datasets
expect(result).toEqual([1, 'A', 1, 'A', 2, 'B', 3, 'C', 5, 'D']);
});
test('should return undefined when no relevant datasets are found', () => {
const props: LabelBuilderProps = {
trendlineSeries: [{ yAxisKey: 'y1' } as any],
datasetOptions: {
ticks: [],
datasets: [
{
dataKey: 'y2', // Not matching with trendlineSeries
data: [10, 20],
ticksForScatter: [
[1, 'A'],
[2, 'B']
]
} as any
],
ticksKey: [{ key: 'x', value: 'X Axis' }]
} as any,
columnLabelFormats: {
x: DEFAULT_COLUMN_LABEL_FORMAT
},
xAxisKeys: ['x'],
sizeKey: [],
columnSettings: {}
};
const result = scatterSeriesBuilder_labels(props);
expect(result).toBeUndefined();
}); });
}); });

View File

@ -143,33 +143,44 @@ const computeSizeRatio = (
}; };
export const scatterSeriesBuilder_labels = (props: LabelBuilderProps) => { export const scatterSeriesBuilder_labels = (props: LabelBuilderProps) => {
const { trendlineSeries, datasetOptions } = props; const { trendlineSeries, datasetOptions, columnLabelFormats, xAxisKeys } = props;
if (trendlineSeries.length > 0) { if (!trendlineSeries.length) return undefined;
// Create a Set of relevant yAxisKeys for O(1) lookup
const relevantYAxisKeys = new Set(trendlineSeries.map((t) => t.yAxisKey));
// Combine filtering, flattening and uniqueness in a single pass // Create a Set of relevant yAxisKeys for O(1) lookup
const allTicksForScatter = datasetOptions.datasets const relevantYAxisKeys = new Set(trendlineSeries.map((t) => t.yAxisKey));
.filter((dataset) => relevantYAxisKeys.has(dataset.dataKey))
.flatMap((dataset) => dataset.ticksForScatter || [])
.sort((a, b) => {
const aVal = a[0],
bVal = b[0];
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
})
.filter((tick, index, array) => index === 0 || tick[0] !== array[index - 1][0]);
const modifyFiedProps = { // Get X-axis format information once
...props, const xColumnLabelFormat = columnLabelFormats[xAxisKeys[0]] || DEFAULT_COLUMN_LABEL_FORMAT;
datasetOptions: { const useDateLabels =
...props.datasetOptions, xAxisKeys.length === 1 &&
ticks: allTicksForScatter datasetOptions.ticks[0]?.length === 1 &&
} xColumnLabelFormat.columnType === 'date' &&
}; xColumnLabelFormat.style === 'date';
return lineSeriesBuilder_labels(modifyFiedProps); if (useDateLabels) {
// Process date labels directly without extra iterations
return datasetOptions.ticks.flatMap((item) =>
item.map<Date>((dateItem) => createDayjsDate(dateItem as string).toDate())
);
} }
return undefined; // Only process relevant datasets
const relevantDatasets = datasetOptions.datasets.filter((dataset) =>
relevantYAxisKeys.has(dataset.dataKey)
);
// Early return for no relevant datasets
if (!relevantDatasets.length) return undefined;
// Collect all ticks without deduplication
const allTicks: (string | number)[][] = [];
relevantDatasets.forEach((dataset) => {
dataset.ticksForScatter?.forEach((tick) => {
allTicks.push(tick);
});
});
// Sort and flatten
return allTicks.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).flat();
}; };

View File

@ -1,7 +1,7 @@
// Also consider modifying this package to make it work with chartjs 4 https://pomgui.github.io/chartjs-plugin-regression/demo/ // Also consider modifying this package to make it work with chartjs 4 https://pomgui.github.io/chartjs-plugin-regression/demo/
import type { BusterChartProps, Trendline } from '@/api/asset_interfaces/metric/charts'; import type { BusterChartProps, Trendline } from '@/api/asset_interfaces/metric/charts';
import type { DatasetOption, DatasetOptionsWithTicks } from '../interfaces'; import type { DatasetOptionsWithTicks } from '../interfaces';
import type { TrendlineDataset } from './trendlineDataset.types'; import type { TrendlineDataset } from './trendlineDataset.types';
import { DATASET_IDS } from '../config'; import { DATASET_IDS } from '../config';
import { isDateColumnType, isNumericColumnType } from '@/lib/messages'; import { isDateColumnType, isNumericColumnType } from '@/lib/messages';
@ -230,9 +230,6 @@ export const trendlineDatasetCreator: Record<
if (!selectedDatasets || selectedDatasets.length === 0 || validData.length === 0) return []; if (!selectedDatasets || selectedDatasets.length === 0 || validData.length === 0) return [];
const isXAxisNumeric = isNumericColumnType(
columnLabelFormats[xAxisColumn]?.columnType || DEFAULT_COLUMN_LABEL_FORMAT.columnType
);
const isXAxisDate = isDateColumnType(columnLabelFormats[xAxisColumn]?.columnType); const isXAxisDate = isDateColumnType(columnLabelFormats[xAxisColumn]?.columnType);
// Get mapped data points using the updated dataMapper // Get mapped data points using the updated dataMapper