TimeSeries: Improve time compare default styling (#110575)

* TimeSeries: Use exported time comparison function

* Add alignTimeRangeCompareData to grafana/data

* Simplify tooltip time text formatting

* Bump scenes version

* Add tests for alignTimeRangeCompareData

* TimeSeries: Improve time compare default styling

* Update Time Comparison panel option menu

* Add backwards compatibility for older scenes

* Update shouldAlignTimeCompare for typical query

* Fix styling when multiple RefId matches

* Update default dash to be smaller

* Fix tooltip for older versions of scenes
This commit is contained in:
Drew Slobodnjak
2025-09-09 15:09:44 -07:00
committed by GitHub
parent ebd1f63aab
commit 651e52fd10
6 changed files with 111 additions and 40 deletions
@@ -1,3 +1,4 @@
import { createTheme } from '../themes/createTheme';
import { FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
@@ -118,7 +119,7 @@ describe('alignTimeRangeCompareData', () => {
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
alignTimeRangeCompareData(frame, ONE_DAY_MS, createTheme());
expect(frame.fields[0].values).toEqual([ONE_DAY_MS + 1000, ONE_DAY_MS + 2000, ONE_DAY_MS + 3000]);
expect(frame.fields[1].values).toEqual([10, 20, 30]); // non-time fields unchanged
@@ -132,13 +133,13 @@ describe('alignTimeRangeCompareData', () => {
],
});
alignTimeRangeCompareData(frame, -ONE_WEEK_MS);
alignTimeRangeCompareData(frame, -ONE_WEEK_MS, createTheme());
// When diff is negative, function does v - diff, so v - (-ONE_WEEK_MS) = v + ONE_WEEK_MS
expect(frame.fields[0].values).toEqual([ONE_WEEK_MS + 1000, ONE_WEEK_MS + 2000, ONE_WEEK_MS + 3000]);
});
it('should apply default gray color and timeCompare config', () => {
it('should apply timeCompare config', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
@@ -146,10 +147,9 @@ describe('alignTimeRangeCompareData', () => {
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
alignTimeRangeCompareData(frame, ONE_DAY_MS, createTheme());
frame.fields.forEach((field) => {
expect(field.config.color?.fixedColor).toBe('gray');
expect(field.config.custom?.timeCompare).toEqual({
diffMs: ONE_DAY_MS,
isTimeShiftQuery: true,
@@ -157,16 +157,6 @@ describe('alignTimeRangeCompareData', () => {
});
});
it('should apply custom color when provided', () => {
const frame = toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [10, 20] }],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS, 'red');
expect(frame.fields[0].config.color?.fixedColor).toBe('red');
});
it('should preserve existing config when merging', () => {
const frame = toDataFrame({
fields: [
@@ -182,7 +172,7 @@ describe('alignTimeRangeCompareData', () => {
],
});
alignTimeRangeCompareData(frame, ONE_WEEK_MS);
alignTimeRangeCompareData(frame, ONE_WEEK_MS, createTheme());
expect(frame.fields[0].config.displayName).toBe('My Display Name');
expect(frame.fields[0].config.custom?.existingProperty).toBe('existingValue');
+28 -6
View File
@@ -1,3 +1,5 @@
import { getFieldSeriesColor } from '../field/fieldColor';
import { GrafanaTheme2 } from '../themes/types';
import { DataFrame, Field, FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
@@ -129,9 +131,9 @@ export function addRow(dataFrame: DataFrame, row: Record<string, unknown> | unkn
* Aligns time range comparison data by adjusting timestamps and applying compare-specific styling
* @param series - The DataFrame containing the comparison data
* @param diff - The time difference in milliseconds to align the timestamps
* @param compareColor - Optional color to use for the comparison series (defaults to 'gray')
* @param theme - The Grafana theme for color calculations
*/
export function alignTimeRangeCompareData(series: DataFrame, diff: number, compareColor = 'gray') {
export function alignTimeRangeCompareData(series: DataFrame, diff: number, theme: GrafanaTheme2) {
series.fields.forEach((field: Field) => {
// Align compare series time stamps with reference series
if (field.type === FieldType.time) {
@@ -142,10 +144,6 @@ export function alignTimeRangeCompareData(series: DataFrame, diff: number, compa
field.config = {
...(field.config ?? {}),
color: {
mode: 'fixed',
fixedColor: compareColor,
},
custom: {
...(field.config?.custom ?? {}),
timeCompare: {
@@ -154,6 +152,30 @@ export function alignTimeRangeCompareData(series: DataFrame, diff: number, compa
},
},
};
// Apply visual styling for comparison series
if (field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum) {
const seriesColor = getFieldSeriesColor(field, theme).color;
const lineStyle = field.config.custom?.lineStyle;
const isSolid = !lineStyle?.fill || lineStyle.fill === 'solid';
if (isSolid) {
field.config.custom = {
...(field.config.custom ?? {}),
lineStyle: {
fill: 'dash',
dash: [5, 5],
},
};
} else {
// For already dashed/dotted lines, reduce opacity
const tinycolor = require('tinycolor2');
field.config.color = {
mode: 'fixed',
fixedColor: tinycolor(seriesColor).setAlpha(0.5).toString(),
};
}
}
});
}
@@ -11,9 +11,9 @@ export function addTimeCompareOption<T extends { timeCompare?: TimeCompareOption
) {
builder.addBooleanSwitch({
path: 'timeCompare',
name: 'Enable',
name: 'Show comparison selector',
category: ['Time Comparison'],
description: '',
description: 'Enables the comparison selector, so you can compare data between two time ranges',
defaultValue,
});
}
@@ -57,7 +57,6 @@ export const TimeSeriesPanel = ({
const isVerticallyOriented = options.orientation === VizOrientation.Vertical;
const { frames, compareDiffMs } = useMemo(() => {
let frames = prepareGraphableFields(data.series, config.theme2, timeRange);
if (frames != null) {
let compareDiffMs: number[] = [0];
@@ -76,7 +75,7 @@ export const TimeSeriesPanel = ({
const needsAlignment = shouldAlignTimeCompare(frame, frames, timeRange);
if (needsAlignment) {
alignTimeRangeCompareData(frame, diffMs, config.theme2.colors.text.disabled);
alignTimeRangeCompareData(frame, diffMs, config.theme2);
}
}
});
@@ -64,7 +64,6 @@ export const TimeSeriesTooltip = ({
compareDiffMs,
}: TimeSeriesTooltipProps) => {
const xField = series.fields[0];
let xVal = xField.values[dataIdxs[0]!];
if (compareDiffMs != null && xField.type === FieldType.time) {
+74 -13
View File
@@ -242,20 +242,81 @@ const matchEnumColorToSeriesColor = (frames: DataFrame[], theme: GrafanaTheme2)
export const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => {
let seriesIndex = 0;
frames.forEach((frame) => {
frame.fields.forEach((field, fieldIdx) => {
if (
fieldIdx !== skipFieldIdx &&
(field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)
) {
field.state = {
...field.state,
seriesIndex: seriesIndex++, // TODO: skip this for fields with custom renderers (e.g. Candlestick)?
};
field.display = getDisplayProcessor({ field, theme });
const updateFieldDisplay = (field: Field, idx: number) => {
field.state = { ...field.state, seriesIndex: idx };
field.display = getDisplayProcessor({ field, theme });
};
const shouldProcessField = (field: Field, fieldIdx: number) => {
return (
fieldIdx !== skipFieldIdx &&
(field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)
);
};
// Pre-pass to group main frames by refId
const mainFramesByRefId = new Map<string, DataFrame[]>();
for (const frame of frames) {
if (!frame.meta?.timeCompare?.isTimeShiftQuery && frame.refId) {
if (!mainFramesByRefId.has(frame.refId)) {
mainFramesByRefId.set(frame.refId, []);
}
});
});
mainFramesByRefId.get(frame.refId)!.push(frame);
}
}
// Counter for comparison indices per baseRefId
const compareIndicesByRefId = new Map<string, number>();
for (const frame of frames) {
const isCompareFrame = frame.meta?.timeCompare?.isTimeShiftQuery;
if (isCompareFrame) {
const baseRefId = frame.refId?.replace('-compare', '');
if (baseRefId) {
// Get and increment the comparison index
let compareIndex = compareIndicesByRefId.get(baseRefId) ?? 0;
compareIndicesByRefId.set(baseRefId, compareIndex + 1);
// Get the matching main frame using the index
const mainFrames = mainFramesByRefId.get(baseRefId);
const mainFrame = mainFrames?.[compareIndex];
if (mainFrame && mainFrame.fields.length === frame.fields.length) {
// Match series indices with main frame
frame.fields.forEach((field, fieldIdx) => {
if (shouldProcessField(field, fieldIdx)) {
const mainField = mainFrame.fields[fieldIdx];
updateFieldDisplay(field, mainField.state?.seriesIndex ?? seriesIndex++);
}
});
} else {
// Fallback
frame.fields.forEach((field, fieldIdx) => {
if (shouldProcessField(field, fieldIdx)) {
updateFieldDisplay(field, seriesIndex++);
}
});
}
} else {
// Fallback when no baseRefId
frame.fields.forEach((field, fieldIdx) => {
if (shouldProcessField(field, fieldIdx)) {
updateFieldDisplay(field, seriesIndex++);
}
});
}
} else {
// Main frames
frame.fields.forEach((field, fieldIdx) => {
if (shouldProcessField(field, fieldIdx)) {
updateFieldDisplay(field, seriesIndex++);
}
});
}
}
};
export function getTimezones(timezones: string[] | undefined, defaultTimezone: string): string[] {