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 { scatterSeriesBuilder_data, scatterSeriesBuilder_labels } from './scatterSeriesBuilder';
import { createDayjsDate } from '@/lib/date';
import type {
DatasetOptionsWithTicks,
DatasetOption
} from '../../../chartHooks/useDatasetOptions/interfaces';
import type { DatasetOptionsWithTicks } from '../../../chartHooks/useDatasetOptions/interfaces';
import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric';
import type { SeriesBuilderProps } from './interfaces';
import { ChartType } from '@/api/asset_interfaces/metric/charts/enum';
import type { LabelBuilderProps } from './useSeriesOptions';
import { DEFAULT_COLUMN_LABEL_FORMAT } from '@/api/asset_interfaces/metric';
describe('scatterSeriesBuilder_data', () => {
const mockColors = ['#FF0000', '#00FF00'];
@ -126,67 +123,118 @@ describe('scatterSeriesBuilder_data', () => {
});
describe('scatterSeriesBuilder_labels', () => {
const baseTrendlineSeries = [
{
yAxisKey: 'metric1',
data: [10, 20, 30],
label: 'Trendline 1',
tooltipData: [],
xAxisKeys: ['timestamp'],
type: 'line' as const,
borderColor: '#FF0000',
borderWidth: 2,
pointRadius: 0
}
];
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: []
});
test('should return undefined when trendlineSeries is empty', () => {
const props: LabelBuilderProps = {
trendlineSeries: [],
datasetOptions: {
ticks: [],
datasets: [],
ticksKey: []
},
columnLabelFormats: {},
xAxisKeys: ['x'],
sizeKey: [],
columnSettings: {}
};
const result = scatterSeriesBuilder_labels(props);
expect(result).toBeUndefined();
});
it('should process trendline series correctly', () => {
const result = scatterSeriesBuilder_labels(baseProps);
expect(result).toBeDefined();
test('should process date labels directly when x-axis is date type', () => {
const dateString1 = '2023-01-01';
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) => {
const { trendlineSeries, datasetOptions } = props;
const { trendlineSeries, datasetOptions, columnLabelFormats, xAxisKeys } = props;
if (trendlineSeries.length > 0) {
// Create a Set of relevant yAxisKeys for O(1) lookup
const relevantYAxisKeys = new Set(trendlineSeries.map((t) => t.yAxisKey));
if (!trendlineSeries.length) return undefined;
// Combine filtering, flattening and uniqueness in a single pass
const allTicksForScatter = datasetOptions.datasets
.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]);
// Create a Set of relevant yAxisKeys for O(1) lookup
const relevantYAxisKeys = new Set(trendlineSeries.map((t) => t.yAxisKey));
const modifyFiedProps = {
...props,
datasetOptions: {
...props.datasetOptions,
ticks: allTicksForScatter
}
};
// Get X-axis format information once
const xColumnLabelFormat = columnLabelFormats[xAxisKeys[0]] || DEFAULT_COLUMN_LABEL_FORMAT;
const useDateLabels =
xAxisKeys.length === 1 &&
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/
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 { DATASET_IDS } from '../config';
import { isDateColumnType, isNumericColumnType } from '@/lib/messages';
@ -230,9 +230,6 @@ export const trendlineDatasetCreator: Record<
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);
// Get mapped data points using the updated dataMapper