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:
@@ -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;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user