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 Kistler
2025-11-05 09:00:28 -06:00
committed by GitHub
co-authored by Leon Sorokin
parent c7dd159db1
commit 8149f586b3
36 changed files with 2462 additions and 74 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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');
}