Trendline: Add higher polynomial degrees (#109001)

* enhancement: add higher polynomial degrees + warning

* chore: i18n

* chore: refactor

* chore: renaming for clarity!
This commit is contained in:
Alex Spencer
2025-08-11 10:00:33 -07:00
committed by GitHub
parent 43ac5b52f9
commit 5363615d97
5 changed files with 121 additions and 97 deletions
@@ -0,0 +1,31 @@
import { FieldMatcherID, fieldMatchers } from '@grafana/data';
import { t } from '@grafana/i18n';
import { FieldNamePicker } from '@grafana/ui/internal';
import { ModelType } from './regression';
export const getModelTypeOptions = () =>
[
{
label: t('transformers.regression-transformer-editor.model-type-options.label.linear', 'Linear'),
value: ModelType.linear,
},
{
label: t('transformers.regression-transformer-editor.model-type-options.label.polynomial', 'Polynomial'),
value: ModelType.polynomial,
},
] as const satisfies Array<{ label: string; value: ModelType }>;
export const fieldNamePickerSettings = {
editor: FieldNamePicker,
id: '',
name: '',
settings: { width: 24, isClearable: false },
} as const;
export const LABEL_WIDTH = 20;
export const FIELD_MATCHERS = {
firstTimeMatcher: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
numericMatcher: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
@@ -33,6 +33,11 @@ export const DEGREES = [
{ label: () => t('transformers.regression-transformer-editor.label.cubic', 'Cubic'), value: 3 },
{ label: () => t('transformers.regression-transformer-editor.label.quartic', 'Quartic'), value: 4 },
{ label: () => t('transformers.regression-transformer-editor.label.quintic', 'Quintic'), value: 5 },
{ label: () => t('transformers.regression-transformer-editor.label.sextic', 'Sextic'), value: 6 },
{ label: () => t('transformers.regression-transformer-editor.label.septic', 'Septic'), value: 7 },
{ label: () => t('transformers.regression-transformer-editor.label.octic', 'Octic'), value: 8 },
{ label: () => t('transformers.regression-transformer-editor.label.nonic', 'Nonic'), value: 9 },
{ label: () => t('transformers.regression-transformer-editor.label.decic', 'Decic'), value: 10 },
];
export const getRegressionTransformer: () => SynchronousDataTransformerInfo<RegressionTransformerOptions> = () => ({
@@ -1,16 +1,14 @@
import { useEffect } from 'react';
import { ReactElement, useEffect, useMemo } from 'react';
import {
DataTransformerID,
TransformerRegistryItem,
TransformerUIProps,
TransformerCategory,
fieldMatchers,
FieldMatcherID,
Field,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { InlineField, Select } from '@grafana/ui';
import { Combobox, InlineField } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/internal';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
@@ -18,111 +16,80 @@ import { getTransformationContent } from '../docs/getTransformationContent';
import darkImage from '../images/dark/regression.svg';
import lightImage from '../images/light/regression.svg';
import { FIELD_MATCHERS, LABEL_WIDTH, fieldNamePickerSettings, getModelTypeOptions } from './constants';
import { DEFAULTS, DEGREES, ModelType, getRegressionTransformer, RegressionTransformerOptions } from './regression';
const fieldNamePickerSettings = {
editor: FieldNamePicker,
id: '',
name: '',
settings: { width: 24, isClearable: false },
};
const LABEL_WIDTH = 20;
import { findFirstFieldByMatcher } from './utils';
export const RegressionTransformerEditor = ({
input,
options,
onChange,
}: TransformerUIProps<RegressionTransformerOptions>) => {
const modelTypeOptions = [
{
label: t('transformers.regression-transformer-editor.model-type-options.label.linear', 'Linear'),
value: ModelType.linear,
},
{
label: t('transformers.regression-transformer-editor.model-type-options.label.polynomial', 'Polynomial'),
value: ModelType.polynomial,
},
];
const modelTypeOptions = useMemo(() => getModelTypeOptions(), []);
const degreeOptions = useMemo(
() =>
DEGREES.map((deg) => {
return {
label: deg.label(),
value: deg.value,
};
}),
[]
);
// Auto-select default X and Y fields when they're not explicitly set by the user.
// This provides better UX when adding a new regression transform by automatically
// choosing sensible defaults: time field (or numeric fallback) for X, and a different
// numeric field for Y. Only runs when fields are missing due to early return optimization.
useEffect(() => {
let x: Field | undefined;
let y: Field | undefined;
// Both fields already selected, nothing to do, early return
if (options.xFieldName && options.yFieldName) {
return;
}
if (!options.xFieldName) {
const timeMatcher = fieldMatchers.get(FieldMatcherID.firstTimeField).get({});
for (const frame of input) {
x = frame.fields.find((field) => timeMatcher(field, frame, input));
if (x) {
break;
}
}
if (!x) {
const firstMatcher = fieldMatchers.get(FieldMatcherID.numeric).get({});
for (const frame of input) {
x = frame.fields.find((field) => firstMatcher(field, frame, input));
if (x) {
break;
}
}
}
x =
findFirstFieldByMatcher(input, FIELD_MATCHERS.firstTimeMatcher) ||
findFirstFieldByMatcher(input, FIELD_MATCHERS.numericMatcher);
}
if (!options.yFieldName) {
const numberMatcher = fieldMatchers.get(FieldMatcherID.numeric).get({});
for (const frame of input) {
y = frame.fields.find((field) => numberMatcher(field, frame, input) && field !== x);
if (y) {
break;
}
}
y = findFirstFieldByMatcher(input, FIELD_MATCHERS.numericMatcher, x);
}
if (x && y) {
onChange({ ...options, xFieldName: x.name, yFieldName: y.name });
}
});
}, [input, options, onChange]);
return (
<>
<InlineField
labelWidth={LABEL_WIDTH}
label={t('transformers.regression-transformer-editor.label-x-field', 'X field')}
>
<RegressionField label={t('transformers.regression-transformer-editor.label-x-field', 'X field')}>
<FieldNamePicker
context={{ data: input }}
value={options.xFieldName ?? ''}
item={fieldNamePickerSettings}
onChange={(v) => {
onChange({ ...options, xFieldName: v });
}}
></FieldNamePicker>
</InlineField>
<InlineField
labelWidth={LABEL_WIDTH}
label={t('transformers.regression-transformer-editor.label-y-field', 'Y field')}
>
onChange={(v) => onChange({ ...options, xFieldName: v })}
/>
</RegressionField>
<RegressionField label={t('transformers.regression-transformer-editor.label-y-field', 'Y field')}>
<FieldNamePicker
context={{ data: input }}
value={options.yFieldName ?? ''}
item={fieldNamePickerSettings}
onChange={(v) => {
onChange({ ...options, yFieldName: v });
}}
></FieldNamePicker>
</InlineField>
<InlineField
labelWidth={LABEL_WIDTH}
label={t('transformers.regression-transformer-editor.label-model-type', 'Model type')}
>
<Select
onChange={(v) => onChange({ ...options, yFieldName: v })}
/>
</RegressionField>
<RegressionField label={t('transformers.regression-transformer-editor.label-model-type', 'Model type')}>
<Combobox
value={options.modelType ?? DEFAULTS.modelType}
onChange={(v) => {
onChange({ ...options, modelType: v.value ?? DEFAULTS.modelType });
}}
onChange={(v) => onChange({ ...options, modelType: v.value ?? DEFAULTS.modelType })}
options={modelTypeOptions}
></Select>
</InlineField>
<InlineField
labelWidth={LABEL_WIDTH}
/>
</RegressionField>
<RegressionField
label={t('transformers.regression-transformer-editor.label-predicted-points', 'Predicted points')}
tooltip={t(
'transformers.regression-transformer-editor.tooltip-number-of-xy-points-to-predict',
@@ -131,34 +98,34 @@ export const RegressionTransformerEditor = ({
>
<NumberInput
value={options.predictionCount ?? DEFAULTS.predictionCount}
onChange={(v) => {
onChange({ ...options, predictionCount: v });
}}
></NumberInput>
</InlineField>
onChange={(v) => onChange({ ...options, predictionCount: v })}
/>
</RegressionField>
{options.modelType === ModelType.polynomial && (
<InlineField
labelWidth={LABEL_WIDTH}
<RegressionField
label={t('transformers.regression-transformer-editor.label-degree', 'Degree')}
tooltip={t(
'transformers.regression-transformer-editor.tooltip-high-degree-polynomial',
'Higher-degree polynomials (e.g., degree 4 or higher) can result in misleading trends and unstable fits. Proceed with caution.'
)}
>
<Select<number>
<Combobox
value={options.degree ?? DEFAULTS.degree}
options={DEGREES.map((deg) => {
return {
label: deg.label(),
value: deg.value,
};
})}
onChange={(v) => {
onChange({ ...options, degree: v.value });
}}
></Select>
</InlineField>
options={degreeOptions}
onChange={(v) => onChange({ ...options, degree: Number(v.value) })}
/>
</RegressionField>
)}
</>
);
};
const RegressionField = ({ label, tooltip, children }: { label: string; tooltip?: string; children: ReactElement }) => (
<InlineField labelWidth={LABEL_WIDTH} label={label} tooltip={tooltip}>
{children}
</InlineField>
);
export const getRegressionTransformerRegistryItem: () => TransformerRegistryItem<RegressionTransformerOptions> = () => {
const regressionTransformer = getRegressionTransformer();
return {
@@ -0,0 +1,15 @@
import { DataFrame, Field } from '@grafana/data';
export const findFirstFieldByMatcher = (
input: DataFrame[],
matcher: (field: Field, frame: DataFrame, input: DataFrame[]) => boolean,
excludeField?: Field
): Field | undefined => {
for (const frame of input) {
const field = frame.fields.find((field) => matcher(field, frame, input) && field !== excludeField);
if (field) {
return field;
}
}
return undefined;
};
+7 -1
View File
@@ -13642,9 +13642,14 @@
"regression-transformer-editor": {
"label": {
"cubic": "Cubic",
"decic": "Decic",
"nonic": "Nonic",
"octic": "Octic",
"quadratic": "Quadratic",
"quartic": "Quartic",
"quintic": "Quintic"
"quintic": "Quintic",
"septic": "Septic",
"sextic": "Sextic"
},
"label-degree": "Degree",
"label-model-type": "Model type",
@@ -13661,6 +13666,7 @@
"tags": {
"regression-analysis": "Regression analysis"
},
"tooltip-high-degree-polynomial": "Higher-degree polynomials (e.g., degree 4 or higher) can result in misleading trends and unstable fits. Proceed with caution.",
"tooltip-number-of-xy-points-to-predict": "Number of X,Y points to predict"
},
"rename-by-regex-transformer": {