refactor out utils, experiment with getting highlightIndex working

This commit is contained in:
Paul Marbach
2025-11-19 17:55:40 -05:00
parent 3e08e784c5
commit 7aa58f690a
6 changed files with 224 additions and 140 deletions
@@ -190,10 +190,61 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
y: dataFrame.fields[i],
x: timeField,
};
if (calc === ReducerID.last) {
sparkline.highlightIndex = sparkline.y.values.length - 1;
} else if (calc === ReducerID.first) {
sparkline.highlightIndex = 0;
let highlightIdx: number | undefined = (() => {
switch (calc) {
case ReducerID.last:
return sparkline.y.values.length - 1;
case ReducerID.first:
return 0;
case ReducerID.lastNotNull: {
for (let k = sparkline.y.values.length - 1; k >= 0; k--) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v)) {
return k;
}
}
return;
}
case ReducerID.firstNotNull: {
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v)) {
return k;
}
}
return;
}
case ReducerID.min: {
let minIdx = -1;
let prevMin = Infinity;
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) {
prevMin = v;
minIdx = k;
}
}
return minIdx >= 0 ? minIdx : undefined;
}
case ReducerID.max: {
let maxIdx = -1;
let prevMax = -Infinity;
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) {
prevMax = v;
maxIdx = k;
}
}
return maxIdx >= 0 ? maxIdx : undefined;
}
default:
return;
}
})();
if (typeof highlightIdx === 'number') {
sparkline.highlightIndex = highlightIdx;
}
}
@@ -248,9 +248,6 @@ export function RadialGauge(props: RadialGaugeProps) {
}
if (displayValue.sparkline) {
if (props.timeRange && displayValue.sparkline.x?.type === FieldType.time) {
displayValue.sparkline.timeRange = props.timeRange;
}
sparklineElement = (
<RadialSparkline
sparkline={displayValue.sparkline}
@@ -1,29 +1,13 @@
import React, { memo } from 'react';
import {
DataFrame,
FieldConfig,
FieldSparkline,
FieldType,
getFieldColorModeForField,
GrafanaTheme2,
nullToValue,
} from '@grafana/data';
import {
AxisPlacement,
GraphDrawStyle,
GraphFieldConfig,
VisibilityMode,
ScaleDirection,
ScaleOrientation,
} from '@grafana/schema';
import { FieldConfig, FieldSparkline } from '@grafana/data';
import { GraphFieldConfig } from '@grafana/schema';
import { Themeable2 } from '../../types/theme';
import { UPlotChart } from '../uPlot/Plot';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
import { getYRange, preparePlotFrame } from './utils';
import { prepareSeries, prepareConfig } from './utils';
export interface SparklineProps extends Themeable2 {
width: number;
@@ -32,106 +16,11 @@ export interface SparklineProps extends Themeable2 {
sparkline: FieldSparkline;
}
const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
};
const prepareConfig = (sparkline: FieldSparkline, dataFrame: DataFrame, theme: GrafanaTheme2): UPlotConfigBuilder => {
const builder = new UPlotConfigBuilder();
builder.setCursor({
show: false,
x: false,
y: false,
});
// X is the first field in the aligned frame
const xField = dataFrame.fields[0];
builder.addScale({
scaleKey: 'x',
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
isTime: false,
range: () => {
if (sparkline.x) {
if (sparkline.timeRange && sparkline.x.type === FieldType.time) {
return [sparkline.timeRange.from.valueOf(), sparkline.timeRange.to.valueOf()];
}
const vals = sparkline.x.values;
return [vals[0], vals[vals.length - 1]];
}
return [0, sparkline.y.values.length - 1];
},
});
builder.addAxis({
scaleKey: 'x',
theme,
placement: AxisPlacement.Hidden,
});
for (let i = 0; i < dataFrame.fields.length; i++) {
const field = dataFrame.fields[i];
const config: FieldConfig<GraphFieldConfig> = field.config;
const customConfig: GraphFieldConfig = {
...defaultConfig,
...config.custom,
};
if (field === xField || field.type !== FieldType.number) {
continue;
}
const scaleKey = config.unit || '__fixed';
builder.addScale({
scaleKey,
orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up,
range: () => getYRange(field, dataFrame),
});
builder.addAxis({
scaleKey,
theme,
placement: AxisPlacement.Hidden,
});
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
const pointsMode =
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
builder.addSeries({
pxAlign: false,
scaleKey,
theme,
colorMode,
thresholds: config.thresholds,
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
showPoints: pointsMode,
pointSize: customConfig.pointSize,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
gradientMode: customConfig.gradientMode,
spanNulls: customConfig.spanNulls,
});
}
return builder;
};
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
const alignedDataFrame = nullToValue(preparePlotFrame(sparkline, fieldConfig));
// do not render sparklines for fields with 1 or less values - this can cause an infinite loop in uPlot
if (alignedDataFrame.fields.some((f) => f.values.length <= 1)) {
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
if (warning) {
return null;
}
@@ -140,4 +29,5 @@ export const Sparkline: React.FC<SparklineProps> = memo((props) => {
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
Sparkline.displayName = 'Sparkline';
@@ -1,16 +1,29 @@
import { Range } from 'uplot';
import {
applyNullInsertThreshold,
DataFrame,
Field,
FieldConfig,
FieldSparkline,
FieldType,
getFieldColorModeForField,
GrafanaTheme2,
isLikelyAscendingVector,
nullToValue,
sortDataFrame,
applyNullInsertThreshold,
Field,
} from '@grafana/data';
import { GraphFieldConfig } from '@grafana/schema';
import { t } from '@grafana/i18n';
import {
AxisPlacement,
GraphDrawStyle,
GraphFieldConfig,
VisibilityMode,
ScaleDirection,
ScaleOrientation,
} from '@grafana/schema';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
/** @internal
* Given a sparkline config returns a DataFrame ready to be turned into Plot data set
@@ -85,3 +98,129 @@ export function getYRange(field: Field, alignedFrame: DataFrame): Range.MinMax {
return [min, max];
}
// const HIGHLIGHT_IDX_POINT_SIZE = 6;
const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
};
export const prepareSeries = (
sparkline: FieldSparkline,
fieldConfig?: FieldConfig<GraphFieldConfig>
): { frame: DataFrame; warning?: string } => {
const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig));
if (frame.fields.some((f) => f.values.length <= 1)) {
return {
warning: t(
'grafana-ui.components.sparkline.warning.too-few-values',
'Sparkline requires at least two values to render.'
),
frame,
};
}
return { frame };
};
export const prepareConfig = (
sparkline: FieldSparkline,
dataFrame: DataFrame,
theme: GrafanaTheme2
): UPlotConfigBuilder => {
const builder = new UPlotConfigBuilder();
// const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
builder.setCursor({
show: false,
x: false, // no crosshairs
y: false,
});
// X is the first field in the aligned frame
const xField = dataFrame.fields[0];
builder.addScale({
scaleKey: 'x',
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
isTime: false, // xField.type === FieldType.time,
range: () => {
if (sparkline.x) {
if (sparkline.timeRange && sparkline.x.type === FieldType.time) {
return [sparkline.timeRange.from.valueOf(), sparkline.timeRange.to.valueOf()];
}
const vals = sparkline.x.values;
return [vals[0], vals[vals.length - 1]];
}
return [0, sparkline.y.values.length - 1];
},
});
builder.addAxis({
scaleKey: 'x',
theme,
placement: AxisPlacement.Hidden,
});
for (let i = 0; i < dataFrame.fields.length; i++) {
const field = dataFrame.fields[i];
const config: FieldConfig<GraphFieldConfig> = field.config;
const customConfig: GraphFieldConfig = {
...defaultConfig,
...config.custom,
};
if (field === xField || field.type !== FieldType.number) {
continue;
}
const scaleKey = config.unit || '__fixed';
builder.addScale({
scaleKey,
orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up,
range: () => getYRange(field, dataFrame),
});
builder.addAxis({
scaleKey,
theme,
placement: AxisPlacement.Hidden,
});
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
// const hasHighlightIndex = typeof sparkline.highlightIndex === 'number';
// if (hasHighlightIndex) {
// builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
// }
const pointsMode =
customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex
? VisibilityMode.Always
: customConfig.showPoints;
builder.addSeries({
pxAlign: false,
scaleKey,
theme,
colorMode,
thresholds: config.thresholds,
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
showPoints: pointsMode,
// pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize,
// pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
gradientMode: customConfig.gradientMode,
spanNulls: customConfig.spanNulls,
});
}
return builder;
};
@@ -9,20 +9,20 @@ import { config } from '@grafana/runtime';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
export const panelsToCheckFirst = [
// 'timeseries',
// 'barchart',
'timeseries',
'barchart',
'gauge',
// 'stat',
// 'piechart',
// 'bargauge',
// 'table',
// 'state-timeline',
// 'status-history',
// 'logs',
// 'candlestick',
// 'flamegraph',
// 'traces',
// 'nodeGraph',
'stat',
'piechart',
'bargauge',
'table',
'state-timeline',
'status-history',
'logs',
'candlestick',
'flamegraph',
'traces',
'nodeGraph',
];
export async function getAllSuggestions(
+7
View File
@@ -8693,6 +8693,13 @@
"aria-label-default": "Pick a color",
"aria-label-selected-color": "{{colorLabel}} color"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": "Sparkline requires at least two values to render."
}
}
},
"confirm-button": {
"aria-label-delete": "Delete",
"cancel": "Cancel",