Annotations: Multi-lane annotations rendering (lane per frame) (#111559)

* feat: support multi-lane annotations panel option

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Galen KistlerandLeon Sorokin authored and GitHub committed 2025-11-05 09:00:28 -06:00
1 parent c7dd159db1
commit 8149f586b3
36 files changed
+2462 -74

No files matched your search

File diff suppressed because it is too large. Load diff
+1
View File
@@ -74,6 +74,7 @@
"mostly-blank-dashboard": (import '../dev-dashboards/scenarios/mostly-blank-dashboard.json'),
"mssql_fakedata": (import '../dev-dashboards/datasource-mssql/mssql_fakedata.json'),
"mssql_unittest": (import '../dev-dashboards/datasource-mssql/mssql_unittest.json'),
"multi-lane-annotations": (import '../dev-dashboards/annotations/multi-lane-annotations.json'),
"mysql_fakedata": (import '../dev-dashboards/datasource-mysql/mysql_fakedata.json'),
"mysql_unittest": (import '../dev-dashboards/datasource-mysql/mysql_unittest.json'),
"new_features_in_v74": (import '../dev-dashboards/datasource-testdata/new_features_in_v74.json'),
+14
View File
@@ -502,6 +502,20 @@ export enum VizOrientation {
Vertical = 'vertical',
}
/**
* Breaks out each annotation frame into multiple lanes on the x-axis
*/
export interface VizAnnotations {
multiLane?: boolean;
}
/**
* TODO docs
*/
export interface OptionsWithAnnotations {
annotations?: VizAnnotations;
}
/**
* TODO docs
*/
@@ -158,6 +158,16 @@ ReduceDataOptions: {
// TODO docs
VizOrientation: "auto" | "vertical" | "horizontal" @cuetsy(kind="enum")
// Breaks out each annotation frame into multiple lanes on the x-axis
VizAnnotations: {
multiLane?: bool
} @cuetsy(kind="interface")
// TODO docs
OptionsWithAnnotations: {
annotations?: VizAnnotations
} @cuetsy(kind="interface")
// TODO docs
OptionsWithTooltip: {
tooltip: VizTooltipOptions
@@ -63,7 +63,7 @@ export const defaultCandlestickColors: Partial<CandlestickColors> = {
up: 'green',
};
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip {
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithAnnotations {
/**
* Sets the style of the candlesticks
*/
@@ -188,6 +188,7 @@ export interface RowsHeatmapOptions {
}
export interface Options {
annotations?: ui.VizAnnotations;
/**
* Controls if the heatmap should be calculated from data
*/
@@ -12,7 +12,7 @@ import * as ui from '@grafana/schema';
export const pluginVersion = "12.3.0-pre";
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations {
/**
* Controls value alignment on the timelines
*/
@@ -12,7 +12,7 @@ import * as ui from '@grafana/schema';
export const pluginVersion = "12.3.0-pre";
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations {
/**
* Controls the column width
*/
@@ -12,7 +12,7 @@ import * as common from '@grafana/schema';
export const pluginVersion = "12.3.0-pre";
export interface Options extends common.OptionsWithTimezones {
export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations {
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
@@ -286,6 +286,7 @@ type UPlotConfigPrepOpts<T extends Record<string, unknown> = {}> = {
tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps;
hoverProximity?: number;
orientation?: VizOrientation;
xAxisConfig?: Pick<AxisProps, 'size' | 'gap' | 'ticks'>;
} & T;
/** @alpha */
+18 -5
View File
@@ -1,5 +1,5 @@
import { Component } from 'react';
import * as React from 'react';
import { Component } from 'react';
import uPlot, { AlignedData } from 'uplot';
import {
@@ -16,7 +16,7 @@ import {
} from '@grafana/data';
import { DashboardCursorSync, VizLegendOptions } from '@grafana/schema';
import { Themeable2, VizLayout } from '@grafana/ui';
import { UPlotChart, AxisProps, Renderers, UPlotConfigBuilder, ScaleProps, pluginLog } from '@grafana/ui/internal';
import { AxisProps, pluginLog, Renderers, ScaleProps, UPlotChart, UPlotConfigBuilder } from '@grafana/ui/internal';
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
import { preparePlotFrame as defaultPreparePlotFrame } from './utils';
@@ -40,7 +40,12 @@ export interface GraphNGProps extends Themeable2 {
tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps;
onLegendClick?: (event: GraphNGLegendEvent) => void;
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
prepConfig: (
alignedFrame: DataFrame,
allFrames: DataFrame[],
getTimeRange: () => TimeRange,
annotationLanes?: number
) => UPlotConfigBuilder;
propsToDiff?: Array<string | PropDiffFn>;
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null;
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
@@ -63,6 +68,9 @@ export interface GraphNGProps extends Themeable2 {
* similar to structureRev. then we can drop propsToDiff entirely.
*/
options?: Record<string, any>;
// Annotation lanes count
annotationLanes?: number;
}
function sameProps<T extends Record<string, unknown>>(
@@ -191,7 +199,7 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
let config = this.state?.config;
if (withConfig) {
config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange);
config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange, this.props.annotationLanes);
pluginLog('GraphNG', false, 'config prepared', config);
}
@@ -229,7 +237,12 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
propsChanged;
if (shouldReconfig) {
newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange);
newState.config = this.props.prepConfig(
newState.alignedFrame,
this.props.frames,
this.getTimeRange,
this.props.annotationLanes
);
pluginLog('GraphNG', false, 'config recreated', newState.config);
}
@@ -6,14 +6,19 @@ import { hasVisibleLegendSeries, PlotLegend, UPlotConfigBuilder } from '@grafana
import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG';
import { preparePlotConfigBuilder } from './utils';
import { getXAxisConfig, preparePlotConfigBuilder } from './utils';
const propsToDiff: Array<string | PropDiffFn> = ['legend', 'options', 'theme'];
const propsToDiff: Array<string | PropDiffFn> = ['legend', 'options', 'annotationLanes', 'theme'];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
prepConfig = (
alignedFrame: DataFrame,
allFrames: DataFrame[],
getTimeRange: () => TimeRange,
annotationLanes?: number
) => {
const { theme, timeZone, options, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
@@ -27,6 +32,7 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
tweakAxis,
hoverProximity: options?.tooltip?.hoverProximity,
orientation: options?.orientation,
xAxisConfig: getXAxisConfig(annotationLanes),
});
};
@@ -1,7 +1,7 @@
import { createDataFrame, dateTime, DateTimeInput, EventBus, FieldType } from '@grafana/data';
import { getTheme } from '@grafana/ui';
import { preparePlotConfigBuilder } from './utils';
import { getXAxisConfig, preparePlotConfigBuilder, UPLOT_DEFAULT_AXIS_GAP } from './utils';
describe('when fill below to option is used', () => {
let eventBus: EventBus;
@@ -375,3 +375,26 @@ describe('time axis units', () => {
expect(config.axes![0]!.values(config, [1667406900000, 1761316576114], 0, 100, 1000)).toEqual(['11-02', '10-24']);
});
});
describe('calculateAnnotationLaneSizes', () => {
it('should not regress', () => {
expect(getXAxisConfig()).toEqual(undefined);
expect(getXAxisConfig(0)).toEqual(undefined);
});
it('should return config to resize x-axis size, gap, and ticks size', () => {
expect(getXAxisConfig(2)).toEqual({
gap: UPLOT_DEFAULT_AXIS_GAP,
size: 36,
ticks: {
size: 19,
},
});
expect(getXAxisConfig(3)).toEqual({
gap: UPLOT_DEFAULT_AXIS_GAP,
size: 43,
ticks: {
size: 26,
},
});
});
});
@@ -68,8 +68,15 @@ import {
buildScaleKey,
getStackingGroups,
preparePlotData2,
AxisProps,
} from '@grafana/ui/internal';
import { ANNOTATION_LANE_SIZE } from '../../../plugins/panel/timeseries/plugins/utils';
// See UPlotAxisBuilder.ts::calculateAxisSize for default axis size calculation
export const UPLOT_DEFAULT_AXIS_SIZE = 17;
export const UPLOT_DEFAULT_AXIS_GAP = 5;
const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals));
const defaultConfig: GraphFieldConfig = {
@@ -90,6 +97,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
tweakAxis = (opts) => opts,
hoverProximity,
orientation = VizOrientation.Horizontal,
xAxisConfig,
}) => {
// we want the Auto and Horizontal orientation to default to Horizontal
const isHorizontal = orientation !== VizOrientation.Vertical;
@@ -159,6 +167,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
formatValue: xField.config.unit?.startsWith('time:')
? (v, decimals) => xField.display!(v, decimals).text
: undefined,
...xAxisConfig,
});
}
@@ -709,3 +718,25 @@ function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<str
});
return originNames;
}
export function getXAxisConfig(lanes = 1): Pick<AxisProps, 'size' | 'gap' | 'ticks'> | undefined {
if (lanes > 1) {
const annotationLanesSize = lanes * ANNOTATION_LANE_SIZE;
// Add an extra lane's worth of height below the annotation lanes in order to show the gridlines through the annotation lanes
const axisSize = annotationLanesSize + UPLOT_DEFAULT_AXIS_GAP;
// Consistent gap between gridlines and x-axis labels
const gap = UPLOT_DEFAULT_AXIS_GAP;
// Axis size is: default size + gap size + annotationLaneSize
const size = UPLOT_DEFAULT_AXIS_SIZE + gap + annotationLanesSize;
return {
size,
gap,
ticks: {
size: axisSize,
},
};
}
return undefined;
}
@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
import { TimelineValueAlignment, TooltipDisplayMode, VisibilityMode, VizTooltipOptions } from '@grafana/schema';
import { UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
import { getXAxisConfig } from '../TimeSeries/utils';
import { preparePlotConfigBuilder, TimelineMode } from './utils';
@@ -23,7 +24,16 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
paginationRev?: string;
}
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip', 'paginationRev'];
const propsToDiff = [
'rowHeight',
'colWidth',
'showValue',
'mergeValues',
'alignValue',
'tooltip',
'paginationRev',
'annotationLanes',
];
export const TimelineChart = (props: TimelineProps) => {
const { frames, timeZone, rowHeight, tooltip, legend, legendItems } = props;
@@ -60,6 +70,7 @@ export const TimelineChart = (props: TimelineProps) => {
getValueColor: getValueColor,
hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi,
xAxisConfig: getXAxisConfig(props.annotationLanes),
});
},
[frames, props, timeZone, rowHeight, getValueColor, tooltip]
@@ -93,6 +93,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
mergeValues,
getValueColor,
hoverMulti,
xAxisConfig,
}) => {
const builder = new UPlotConfigBuilder(timeZones[0]);
@@ -167,7 +168,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
});
const xField = frame.fields[0];
const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden;
builder.addAxis({
@@ -181,6 +181,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
formatValue: xField.config.unit?.startsWith('time:')
? (v, decimals) => xField.display!(v, decimals).text
: undefined,
...xAxisConfig,
});
const yCustomConfig = frame.fields[1].config.custom;
@@ -24,6 +24,7 @@ import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin';
import { getXAnnotationFrames } from '../timeseries/plugins/utils';
import { prepareCandlestickFields } from './fields';
import { Options, defaultCandlestickColors, VizDisplayMode } from './types';
@@ -266,6 +267,7 @@ export const CandlestickPanel = ({
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
>
{(uplotConfig, alignedFrame) => {
return (
@@ -322,6 +324,7 @@ export const CandlestickPanel = ({
)}
<AnnotationsPlugin2
replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane}
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
@@ -49,6 +49,7 @@ composableKinds: PanelCfg: {
Options: {
common.OptionsWithLegend
common.OptionsWithTooltip
common.OptionsWithAnnotations
// Sets which dimensions are used for the visualization
mode: VizDisplayMode & (*"candles+volume" | _)
+1 -1
View File
@@ -61,7 +61,7 @@ export const defaultCandlestickColors: Partial<CandlestickColors> = {
up: 'green',
};
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip {
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithAnnotations {
/**
* Sets the style of the candlesticks
*/
@@ -19,8 +19,10 @@ import { FacetedData, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { getXAxisConfig } from '../../../core/components/TimeSeries/utils';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getXAnnotationFrames } from '../timeseries/plugins/utils';
import { HeatmapTooltip } from './HeatmapTooltip';
import { HeatmapData, prepareHeatmapData } from './fields';
@@ -132,6 +134,8 @@ const HeatmapPanelViz = ({
const dataRef = useRef(info);
dataRef.current = info;
const annotationsLength = options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined;
const builder = useMemo(() => {
const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution;
@@ -147,10 +151,11 @@ const HeatmapPanelViz = ({
yAxisConfig: options.yAxis,
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
selectionMode: options.selectionMode,
xAxisConfig: getXAxisConfig(annotationsLength),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, timeZone, data.structureRev, cursorSync]);
}, [options, timeZone, data.structureRev, cursorSync, annotationsLength]);
const renderLegend = () => {
if (!options.legend.show) {
@@ -242,6 +247,7 @@ const HeatmapPanelViz = ({
)}
<AnnotationsPlugin2
replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
@@ -107,6 +107,7 @@ composableKinds: PanelCfg: lineage: {
layout?: ui.HeatmapCellLayout
} @cuetsy(kind="interface")
Options: {
annotations?: ui.VizAnnotations
// Controls if the heatmap should be calculated from data
calculate?: bool | *false
// Calculation options for the heatmap
+1
View File
@@ -186,6 +186,7 @@ export interface RowsHeatmapOptions {
}
export interface Options {
annotations?: ui.VizAnnotations;
/**
* Controls if the heatmap should be calculated from data
*/
+4 -1
View File
@@ -13,7 +13,7 @@ import {
getDisplayProcessor,
} from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation, HeatmapCellLayout } from '@grafana/schema';
import { UPlotConfigBuilder } from '@grafana/ui';
import { UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
@@ -53,6 +53,7 @@ interface PrepConfigOpts {
yAxisConfig: YAxisConfig;
ySizeDivisor?: number;
selectionMode?: HeatmapSelectionMode;
xAxisConfig?: Parameters<UPlotConfigPrepFn>[0]['xAxisConfig'];
}
export function prepConfig(opts: PrepConfigOpts) {
@@ -67,6 +68,7 @@ export function prepConfig(opts: PrepConfigOpts) {
yAxisConfig,
ySizeDivisor,
selectionMode = HeatmapSelectionMode.X,
xAxisConfig,
} = opts;
const xScaleKey = 'x';
@@ -182,6 +184,7 @@ export function prepConfig(opts: PrepConfigOpts) {
isTime && xField.config.unit?.startsWith('time:')
? (v, decimals) => xField.display!(v, decimals).text
: undefined,
...xAxisConfig,
});
const yField = dataRef.current?.heatmap?.fields[1]!;
@@ -20,6 +20,7 @@ import {
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getXAnnotationFrames } from '../timeseries/plugins/utils';
import { getTimezones } from '../timeseries/utils';
import { StateTimelineTooltip } from './StateTimelineTooltip';
@@ -87,11 +88,13 @@ export const StateTimelinePanel = ({
width={width}
height={height - paginationHeight}
legendItems={legendItems}
annotations={options.annotations}
{...options}
mode={TimelineMode.Changes}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
>
{(builder, alignedFrame) => {
return (
@@ -149,6 +152,7 @@ export const StateTimelinePanel = ({
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2
replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
@@ -28,6 +28,7 @@ composableKinds: PanelCfg: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
ui.OptionsWithTimezones
ui.OptionsWithAnnotations
//Show timeline values on chart
showValue: ui.VisibilityMode & (*"auto" | _)
+1 -1
View File
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations {
/**
* Controls value alignment on the timelines
*/
@@ -24,6 +24,7 @@ import { usePagination } from '../state-timeline/hooks';
import { containerStyles } from '../state-timeline/styles';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { getXAnnotationFrames } from '../timeseries/plugins/utils';
import { getTimezones } from '../timeseries/utils';
import { Options } from './panelcfg.gen';
@@ -108,6 +109,7 @@ export const StatusHistoryPanel = ({
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
>
{(builder, alignedFrame) => {
return (
@@ -165,6 +167,7 @@ export const StatusHistoryPanel = ({
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2
replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
@@ -28,6 +28,7 @@ composableKinds: PanelCfg: {
ui.OptionsWithLegend
ui.OptionsWithTooltip
ui.OptionsWithTimezones
ui.OptionsWithAnnotations
//Set the height of the rows
rowHeight: float32 & >=0 & <=1 | *0.9
+1 -1
View File
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones, ui.OptionsWithAnnotations {
/**
* Controls the column width
*/
@@ -23,6 +23,7 @@ import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2';
import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
import { getXAnnotationFrames } from './plugins/utils';
import { getPrepareTimeseriesSuggestion } from './suggestions';
import { getTimezones, prepareGraphableFields } from './utils';
@@ -131,6 +132,7 @@ export const TimeSeriesPanel = ({
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
>
{(uplotConfig, alignedFrame) => {
return (
@@ -192,6 +194,7 @@ export const TimeSeriesPanel = ({
<>
<AnnotationsPlugin2
replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane}
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}
@@ -22,11 +22,15 @@ composableKinds: PanelCfg: lineage: {
schemas: [{
version: [0, 0]
schema: {
Options: common.OptionsWithTimezones & {
Options: {
common.OptionsWithTimezones
common.OptionsWithAnnotations
legend: common.VizLegendOptions
tooltip: common.VizTooltipOptions
timeCompare?: common.TimeCompareOptions
orientation?: common.VizOrientation
annotations?: common.VizAnnotations
} @cuetsy(kind="interface")
FieldConfig: common.GraphFieldConfig & {} @cuetsy(kind="interface")
+1 -1
View File
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
export interface Options extends common.OptionsWithTimezones {
export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations {
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useReducer } from 'react';
import * as React from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
@@ -17,6 +17,7 @@ import {
} from '@grafana/ui';
import { AnnotationMarker2 } from './annotations2/AnnotationMarker2';
import { ANNOTATION_LANE_SIZE, getXAnnotationFrames, getXYAnnotationFrames } from './utils';
// (copied from TooltipPlugin2)
interface TimeRange2 {
@@ -32,6 +33,7 @@ interface AnnotationsPluginProps {
setNewRange: (newRage: TimeRange2 | null) => void;
canvasRegionRendering?: boolean;
replaceVariables: InterpolateFunction;
multiLane?: boolean;
}
// TODO: batch by color, use Path2D objects
@@ -72,6 +74,7 @@ export const AnnotationsPlugin2 = ({
setNewRange,
replaceVariables,
canvasRegionRendering = true,
multiLane = false,
}: AnnotationsPluginProps) => {
const [plot, setPlot] = useState<uPlot>();
@@ -85,10 +88,9 @@ export const AnnotationsPlugin2 = ({
const { canExecuteActions } = usePanelContext();
const userCanExecuteActions = canExecuteActions?.() ?? false;
const annos = useMemo(() => {
let annos = annotations.filter(
(frame) => frame.name !== 'exemplar' && frame.length > 0 && frame.fields.some((f) => f.name === 'time')
);
const { xAnnos, xyAnnos } = useMemo(() => {
let xAnnos = getXAnnotationFrames(annotations);
let xyAnnos = getXYAnnotationFrames(annotations);
if (newRange) {
let isRegion = newRange.to > newRange.from;
@@ -109,18 +111,25 @@ export const AnnotationsPlugin2 = ({
},
};
annos.push(wipAnnoFrame);
xAnnos.push(wipAnnoFrame);
}
return annos;
return {
xAnnos,
xyAnnos,
};
}, [annotations, newRange]);
const exitWipEdit = useCallback(() => {
setNewRange(null);
}, [setNewRange]);
const annoRef = useRef(annos);
annoRef.current = annos;
const xAnnoRef = useRef(xAnnos);
xAnnoRef.current = xAnnos;
const xyAnnoRef = useRef(xyAnnos);
xyAnnoRef.current = xyAnnos;
const newRangeRef = useRef(newRange);
newRangeRef.current = newRange;
@@ -134,7 +143,8 @@ export const AnnotationsPlugin2 = ({
});
config.addHook('draw', (u) => {
let annos = annoRef.current;
let xAnnos = xAnnoRef.current;
let xyAnnos = xyAnnoRef.current;
const ctx = u.ctx;
@@ -144,40 +154,10 @@ export const AnnotationsPlugin2 = ({
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
annos.forEach((frame) => {
// Multi-lane annotations do not support vertical lines or shaded regions
xAnnos.forEach((frame) => {
let vals = getVals(frame);
if (frame.name === 'xymark') {
// xMin, xMax, yMin, yMax, color, lineWidth, lineStyle, fillOpacity, text
let xKey = config.scales[0].props.scaleKey;
let yKey = config.scales[1].props.scaleKey;
for (let i = 0; i < frame.length; i++) {
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8);
let x0 = u.valToPos(vals.xMin[i], xKey, true);
let x1 = u.valToPos(vals.xMax[i], xKey, true);
let y0 = u.valToPos(vals.yMax[i], yKey, true);
let y1 = u.valToPos(vals.yMin[i], yKey, true);
ctx.fillStyle = colorManipulator.alpha(color, vals.fillOpacity[i]);
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.lineWidth = Math.round(vals.lineWidth[i] * uPlot.pxRatio);
if (vals.lineStyle[i] === 'dash') {
// maybe extract this to vals.lineDash[i] in future?
ctx.setLineDash([5, 5]);
} else {
// solid
ctx.setLineDash([]);
}
ctx.strokeStyle = color;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
}
} else {
if (!multiLane) {
let y0 = u.bbox.top;
let y1 = y0 + u.bbox.height;
@@ -185,12 +165,14 @@ export const AnnotationsPlugin2 = ({
ctx.setLineDash([5, 5]);
for (let i = 0; i < vals.time.length; i++) {
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8);
let color = getColorByName(vals.color?.[i] ?? DEFAULT_ANNOTATION_COLOR_HEX8);
let x0 = u.valToPos(vals.time[i], 'x', true);
renderLine(ctx, y0, y1, x0, color);
if (vals.isRegion?.[i]) {
// If dataframe does not have end times, let's omit rendering the region for now to prevent runtime error in valToPos
// @todo do we want to fix isRegion to render a point (or use "to" as timeEnd) when we're missing timeEnd?
if (vals.isRegion?.[i] && vals.timeEnd?.[i]) {
let x1 = u.valToPos(vals.timeEnd[i], 'x', true);
renderLine(ctx, y0, y1, x1, color);
@@ -203,11 +185,44 @@ export const AnnotationsPlugin2 = ({
}
});
// xMin, xMax, yMin, yMax, color, lineWidth, lineStyle, fillOpacity, text
xyAnnos.forEach((frame) => {
let vals = getVals(frame);
let xKey = config.scales[0].props.scaleKey;
let yKey = config.scales[1].props.scaleKey;
for (let i = 0; i < frame.length; i++) {
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8);
let x0 = u.valToPos(vals.xMin[i], xKey, true);
let x1 = u.valToPos(vals.xMax[i], xKey, true);
let y0 = u.valToPos(vals.yMax[i], yKey, true);
let y1 = u.valToPos(vals.yMin[i], yKey, true);
ctx.fillStyle = colorManipulator.alpha(color, vals.fillOpacity[i]);
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.lineWidth = Math.round(vals.lineWidth[i] * uPlot.pxRatio);
if (vals.lineStyle[i] === 'dash') {
// maybe extract this to vals.lineDash[i] in future?
ctx.setLineDash([5, 5]);
} else {
// solid
ctx.setLineDash([]);
}
ctx.strokeStyle = color;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
}
});
ctx.restore();
});
}, [config, canvasRegionRendering, getColorByName]);
}, [config, canvasRegionRendering, getColorByName, multiLane]);
// ensure annos are re-drawn whenever they change
// ensure xAnnos are re-drawn whenever they change
useEffect(() => {
if (plot) {
plot.redraw();
@@ -219,14 +234,17 @@ export const AnnotationsPlugin2 = ({
forceUpdate();
}, 0);
}
}, [annos, plot]);
}, [xAnnos, plot]);
if (plot) {
let markers = annos.flatMap((frame, frameIdx) => {
let markers = xAnnos.flatMap((frame, frameIdx) => {
let vals = getVals(frame);
let markers: React.ReactNode[] = [];
// Top offset for multi-lane annotations
const top = multiLane ? frameIdx * ANNOTATION_LANE_SIZE : undefined;
for (let i = 0; i < vals.time.length; i++) {
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR);
let left = Math.round(plot.valToPos(vals.time[i], 'x')) || 0; // handles -0
@@ -243,14 +261,14 @@ export const AnnotationsPlugin2 = ({
let clampedLeft = Math.max(0, left);
let clampedRight = Math.min(plot.rect.width, right);
style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft };
style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft, top };
className = styles.annoRegion;
}
} else {
isVisible = left >= 0 && left <= plot.rect.width;
if (isVisible) {
style = { left, borderBottomColor: color };
style = { left, borderBottomColor: color, top };
className = styles.annoMarker;
}
}
@@ -0,0 +1,165 @@
import { arrayToDataFrame, createDataFrame, DataFrame, DataTopic, FieldType } from '@grafana/data';
import { getXAnnotationFrames, getXYAnnotationFrames } from './utils';
const exemplarFrame = createDataFrame({
refId: 'A',
name: 'exemplar',
meta: {
custom: {
resultType: 'exemplar',
},
},
fields: [
{ name: 'Time', type: FieldType.time, values: [6, 5, 4, 3, 2, 1] },
{
name: 'Value',
type: FieldType.number,
values: [30, 10, 40, 90, 14, 21],
labels: { le: '6' },
},
{
name: 'traceID',
type: FieldType.string,
values: ['unknown'],
labels: { le: '6' },
},
],
});
const annotationRegionFrame: DataFrame = {
fields: [
{
name: 'type',
config: {
custom: {},
},
values: ['Milestones'],
type: FieldType.string,
state: {
displayName: null,
seriesIndex: 0,
},
},
{
name: 'color',
config: {
custom: {},
},
values: ['#F2495C'],
type: FieldType.string,
state: {
displayName: null,
seriesIndex: 1,
},
},
{
name: 'time',
config: {
custom: {},
},
values: [1720697881000],
type: FieldType.time,
state: {
displayName: null,
seriesIndex: 2,
},
},
{
name: 'timeEnd',
config: {
custom: {},
},
values: [1729081505000],
type: FieldType.number,
state: {
displayName: null,
seriesIndex: 2,
range: {
min: 1729081505000,
max: 1759857566000,
delta: 30776061000,
},
},
},
{
name: 'title',
config: {
custom: {},
},
values: ['0.1.0'],
type: FieldType.string,
state: {
displayName: null,
seriesIndex: 3,
},
},
{
name: 'text',
config: {
custom: {},
},
values: [true],
type: FieldType.boolean,
state: {
displayName: null,
seriesIndex: 4,
},
},
{
name: 'isRegion',
config: {
custom: {},
},
values: [true],
type: FieldType.boolean,
state: {
displayName: null,
seriesIndex: 6,
},
},
],
length: 1,
meta: {
dataTopic: DataTopic.Annotations,
},
};
const annotationFrame: DataFrame = {
...annotationRegionFrame,
fields: [...annotationRegionFrame.fields.filter((f) => f.name !== 'timeEnd')],
};
const frames: DataFrame[] = [exemplarFrame, annotationRegionFrame, annotationFrame];
const xymark = arrayToDataFrame([
{
time: 0,
xMin: 0,
xMax: 0,
timeEnd: 0,
yMin: 0,
yMax: 100,
isRegion: true,
fillOpacity: 0.15,
lineWidth: 1,
lineStyle: 'solid',
color: '#FF9930',
text: 'Comparison selection',
},
]);
xymark.name = 'xymark';
describe('getXAnnotationFrames', () => {
it('should filter exemplar frames', () => {
expect(getXAnnotationFrames(frames)).toEqual([annotationRegionFrame, annotationFrame]);
});
it('should exclude xymark frames', () => {
const framesWithxymark = [...frames, xymark];
expect(getXAnnotationFrames(framesWithxymark)).toEqual([annotationRegionFrame, annotationFrame]);
});
});
describe('getXYAnnotationFrames', () => {
it('should include xymark frames', () => {
const framesWithxymark = [...frames, xymark];
expect(getXYAnnotationFrames(framesWithxymark)).toEqual([xymark]);
});
});
@@ -0,0 +1,18 @@
import { DataFrame, FieldType } from '@grafana/data';
// Annotation points/regions are 5px with 1px of padding
export const ANNOTATION_LANE_SIZE = 7;
export function getXAnnotationFrames(dataFrames: DataFrame[] = []) {
return dataFrames.filter(
(frame) =>
frame.name !== 'exemplar' &&
frame.name !== 'xymark' &&
frame.length > 0 &&
frame.fields.some((f) => f.type === FieldType.time)
);
}
export function getXYAnnotationFrames(dataFrames: DataFrame[] = []) {
return dataFrames.filter((frame) => frame.name === 'xymark');
}