Sparkline: Add point annotations for some common calcs (#115595)
This commit is contained in:
@@ -3,11 +3,18 @@ import { merge } from 'lodash';
|
||||
import { toDataFrame } from '../dataframe/processDataFrame';
|
||||
import { createTheme } from '../themes/createTheme';
|
||||
import { ReducerID } from '../transformations/fieldReducer';
|
||||
import { FieldType } from '../types/dataFrame';
|
||||
import { FieldConfigPropertyItem } from '../types/fieldOverrides';
|
||||
import { MappingType, SpecialValueMatch, ValueMapping } from '../types/valueMapping';
|
||||
|
||||
import { getDisplayProcessor } from './displayProcessor';
|
||||
import { fixCellTemplateExpressions, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
|
||||
import {
|
||||
FieldSparkline,
|
||||
fixCellTemplateExpressions,
|
||||
getFieldDisplayValues,
|
||||
GetFieldDisplayValuesOptions,
|
||||
getSparklineHighlight,
|
||||
} from './fieldDisplay';
|
||||
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
|
||||
|
||||
describe('FieldDisplay', () => {
|
||||
@@ -556,3 +563,71 @@ describe('fixCellTemplateExpressions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSparklineHighlight', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
y: { name: 'A', type: FieldType.number, values: [null, 2, 3, 4, 10, 8, 8, 8, 9, null], config: {} },
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
calc: ReducerID.last,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.max,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.min,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.first,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.firstNotNull,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.lastNotNull,
|
||||
expected: {
|
||||
type: 'point',
|
||||
xIdx: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.mean,
|
||||
expected: {
|
||||
type: 'line',
|
||||
y: 6.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
calc: ReducerID.median,
|
||||
expected: {
|
||||
type: 'line',
|
||||
y: 8,
|
||||
},
|
||||
},
|
||||
])('it calculates the correct highlight for the $calc', ({ calc, expected }) => {
|
||||
const result = getSparklineHighlight(sparkline, calc);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isEmpty } from 'lodash';
|
||||
import { DataFrameView } from '../dataframe/DataFrameView';
|
||||
import { getTimeField } from '../dataframe/processDataFrame';
|
||||
import { GrafanaTheme2 } from '../themes/types';
|
||||
import { reduceField, ReducerID } from '../transformations/fieldReducer';
|
||||
import { isReducerID, reduceField, ReducerID } from '../transformations/fieldReducer';
|
||||
import { getFieldMatcher } from '../transformations/matchers';
|
||||
import { FieldMatcherID } from '../transformations/matchers/ids';
|
||||
import { ScopedVars } from '../types/ScopedVars';
|
||||
@@ -43,6 +43,7 @@ export interface FieldSparkline {
|
||||
x?: Field; // if this does not exist, use the index
|
||||
timeRange?: TimeRange; // Optionally force an absolute time
|
||||
highlightIndex?: number;
|
||||
highlightLine?: number;
|
||||
}
|
||||
|
||||
export interface FieldDisplay {
|
||||
@@ -72,6 +73,76 @@ export interface GetFieldDisplayValuesOptions {
|
||||
|
||||
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
|
||||
|
||||
interface SparklineHighlightPoint {
|
||||
type: 'point';
|
||||
xIdx: number;
|
||||
}
|
||||
|
||||
interface SparklineHighlightLine {
|
||||
type: 'line';
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function getSparklineHighlight(
|
||||
sparkline: FieldSparkline,
|
||||
calc: ReducerID
|
||||
): SparklineHighlightPoint | SparklineHighlightLine | void {
|
||||
switch (calc) {
|
||||
case ReducerID.last:
|
||||
return { type: 'point', xIdx: sparkline.y.values.length - 1 };
|
||||
case ReducerID.first:
|
||||
return { type: 'point', xIdx: 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 { type: 'point', xIdx: 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 { type: 'point', xIdx: 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 ? { type: 'point', xIdx: 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 ? { type: 'point', xIdx: maxIdx } : undefined;
|
||||
}
|
||||
case ReducerID.mean:
|
||||
return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.mean] }).mean };
|
||||
case ReducerID.median:
|
||||
return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.median] }).median };
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
|
||||
const { replaceVariables, reduceOptions, timeZone, theme } = options;
|
||||
const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last];
|
||||
@@ -190,62 +261,16 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
|
||||
y: dataFrame.fields[i],
|
||||
x: timeField,
|
||||
};
|
||||
let highlightIdx: number | undefined = (() => {
|
||||
switch (calc) {
|
||||
case ReducerID.last:
|
||||
return sparkline.y.values.length - 1;
|
||||
case ReducerID.first:
|
||||
return 0;
|
||||
// TODO: #112977 enable more reducers for highlight index
|
||||
// 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 (isReducerID(calc)) {
|
||||
const sparklineHighlight = getSparklineHighlight(sparkline, calc);
|
||||
switch (sparklineHighlight?.type) {
|
||||
case 'point':
|
||||
sparkline.highlightIndex = sparklineHighlight.xIdx;
|
||||
break;
|
||||
case 'line':
|
||||
sparkline.highlightLine = sparklineHighlight.y;
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
if (typeof highlightIdx === 'number') {
|
||||
sparkline.highlightIndex = highlightIdx;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const RadialSparkline = memo(
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: topPos }}>
|
||||
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
|
||||
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} showHighlights />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,18 +14,18 @@ export interface SparklineProps extends Themeable2 {
|
||||
height: number;
|
||||
config?: FieldConfig<GraphFieldConfig>;
|
||||
sparkline: FieldSparkline;
|
||||
showHighlights?: boolean;
|
||||
}
|
||||
|
||||
const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
const { sparkline, config: fieldConfig, theme, width, height } = props;
|
||||
|
||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
|
||||
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
|
||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
|
||||
if (warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame));
|
||||
const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme);
|
||||
const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights);
|
||||
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, FieldSparkline, FieldType } from '@grafana/data';
|
||||
import { createTheme, Field, FieldSparkline, FieldType, toDataFrame } from '@grafana/data';
|
||||
|
||||
import { getYRange, preparePlotFrame } from './utils';
|
||||
import { getYRange, prepareConfig, preparePlotFrame } from './utils';
|
||||
|
||||
describe('Prepare Sparkline plot frame', () => {
|
||||
it('should return sorted array if x-axis numeric', () => {
|
||||
@@ -201,3 +201,134 @@ describe('Get y range', () => {
|
||||
expect(actual[0]).toBeLessThan(actual[1]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareConfig', () => {
|
||||
it('should not throw an error if there are multiple values', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [sparkline.x, sparkline.y],
|
||||
});
|
||||
|
||||
const config = prepareConfig(sparkline, dataFrame, createTheme());
|
||||
expect(config.series.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not throw an error if there is a single value', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [sparkline.x, sparkline.y],
|
||||
});
|
||||
|
||||
const config = prepareConfig(sparkline, dataFrame, createTheme());
|
||||
expect(config.series.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not throw an error if there are no values', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [sparkline.x, sparkline.y],
|
||||
});
|
||||
|
||||
const config = prepareConfig(sparkline, dataFrame, createTheme());
|
||||
expect(config.series.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should set up highlight series if showHighlights is true and highlightIdx exists', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
},
|
||||
highlightIndex: 2,
|
||||
};
|
||||
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [sparkline.x, sparkline.y],
|
||||
});
|
||||
|
||||
const config = prepareConfig(sparkline, dataFrame, createTheme(), true);
|
||||
expect(config.series.length).toBe(1);
|
||||
expect(config.series[0].getConfig().points).toEqual(
|
||||
expect.objectContaining({
|
||||
show: true,
|
||||
filter: [2],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set up highlight series if showHighlights is false even if highlightIdx exists', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
},
|
||||
highlightIndex: 2,
|
||||
};
|
||||
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [sparkline.x, sparkline.y],
|
||||
});
|
||||
|
||||
const config = prepareConfig(sparkline, dataFrame, createTheme(), false);
|
||||
expect(config.series.length).toBe(1);
|
||||
expect(config.series[0].getConfig().points?.show).not.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Range } from 'uplot';
|
||||
|
||||
import {
|
||||
applyNullInsertThreshold,
|
||||
// colorManipulator,
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldSparkline,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
VisibilityMode,
|
||||
ScaleDirection,
|
||||
ScaleOrientation,
|
||||
// FieldColorModeId,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
@@ -112,8 +114,7 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax {
|
||||
return [roundedMin, roundedMax];
|
||||
}
|
||||
|
||||
// TODO: #112977 enable highlight index
|
||||
// const HIGHLIGHT_IDX_POINT_SIZE = 6;
|
||||
const HIGHLIGHT_IDX_POINT_SIZE = 6;
|
||||
|
||||
const defaultConfig: GraphFieldConfig = {
|
||||
drawStyle: GraphDrawStyle.Line,
|
||||
@@ -124,7 +125,9 @@ const defaultConfig: GraphFieldConfig = {
|
||||
|
||||
export const prepareSeries = (
|
||||
sparkline: FieldSparkline,
|
||||
fieldConfig?: FieldConfig<GraphFieldConfig>
|
||||
_theme: GrafanaTheme2,
|
||||
fieldConfig?: FieldConfig<GraphFieldConfig>,
|
||||
_showHighlights?: boolean
|
||||
): { frame: DataFrame; warning?: string } => {
|
||||
const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig));
|
||||
if (frame.fields.some((f) => f.values.length <= 1)) {
|
||||
@@ -136,16 +139,41 @@ export const prepareSeries = (
|
||||
frame,
|
||||
};
|
||||
}
|
||||
// TODO:rgb(24, 24, 24) will address this.
|
||||
// if (showHighlights && typeof sparkline.highlightLine === 'number') {
|
||||
// const highlightY = sparkline.highlightLine;
|
||||
// const colorMode = getFieldColorModeForField(sparkline.y);
|
||||
// const seriesColor = colorMode.getCalculator(sparkline.y, theme)(highlightY, 0);
|
||||
// frame.fields.push({
|
||||
// name: 'highlightLine',
|
||||
// type: FieldType.number,
|
||||
// values: new Array(frame.length).fill(highlightY),
|
||||
// config: {
|
||||
// color: {
|
||||
// mode: FieldColorModeId.Fixed,
|
||||
// fixedColor: colorManipulator.lighten(seriesColor, 0.5),
|
||||
// },
|
||||
// custom: {
|
||||
// lineStyle: {
|
||||
// fill: 'dash',
|
||||
// dash: [5, 2],
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// state: {},
|
||||
// });
|
||||
// }
|
||||
return { frame };
|
||||
};
|
||||
|
||||
export const prepareConfig = (
|
||||
sparkline: FieldSparkline,
|
||||
dataFrame: DataFrame,
|
||||
theme: GrafanaTheme2
|
||||
theme: GrafanaTheme2,
|
||||
showHighlights?: boolean
|
||||
): UPlotConfigBuilder => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
// const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
|
||||
const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
|
||||
|
||||
builder.setCursor({
|
||||
show: false,
|
||||
@@ -206,13 +234,14 @@ export const prepareConfig = (
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
// TODO: #112977 enable highlight index and adjust padding accordingly
|
||||
// const hasHighlightIndex = typeof sparkline.highlightIndex === 'number';
|
||||
// if (hasHighlightIndex) {
|
||||
// builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
|
||||
// }
|
||||
|
||||
const hasHighlightIndex = showHighlights && typeof sparkline.highlightIndex === 'number';
|
||||
if (hasHighlightIndex) {
|
||||
builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
|
||||
}
|
||||
|
||||
const pointsMode =
|
||||
customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex
|
||||
customConfig.drawStyle === GraphDrawStyle.Points || hasHighlightIndex
|
||||
? VisibilityMode.Always
|
||||
: customConfig.showPoints;
|
||||
|
||||
@@ -227,9 +256,8 @@ export const prepareConfig = (
|
||||
lineWidth: customConfig.lineWidth,
|
||||
lineInterpolation: customConfig.lineInterpolation,
|
||||
showPoints: pointsMode,
|
||||
// TODO: #112977 enable highlight index
|
||||
pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize,
|
||||
// pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
|
||||
pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize,
|
||||
pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
fillColor: customConfig.fillColor,
|
||||
lineStyle: customConfig.lineStyle,
|
||||
|
||||
Reference in New Issue
Block a user