From 277067ac9d6556af0900a445ae4833f52041dda5 Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Wed, 5 Jun 2024 22:48:37 +0100 Subject: [PATCH] AnnotationsPlugin2: Implement support for rectangular annotations in Heatmap (#88107) Co-authored-by: Leon Sorokin --- .../panelcfg/x/HeatmapPanelCfg_types.gen.ts | 14 ++++ .../components/PanelChrome/PanelContext.ts | 8 ++- .../src/components/PanelChrome/types.ts | 12 ++++ .../uPlot/plugins/TooltipPlugin2.tsx | 62 ++++++++++++++++- .../plugins/panel/heatmap/HeatmapPanel.tsx | 4 +- public/app/plugins/panel/heatmap/panelcfg.cue | 4 ++ .../app/plugins/panel/heatmap/panelcfg.gen.ts | 14 ++++ public/app/plugins/panel/heatmap/utils.ts | 23 +++++-- .../timeseries/plugins/AnnotationsPlugin2.tsx | 68 ++++++++++++++----- 9 files changed, 184 insertions(+), 25 deletions(-) diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index 1099ad1b755..6021fbab6a5 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -28,6 +28,15 @@ export enum HeatmapColorScale { Linear = 'linear', } +/** + * Controls which axis to allow selection on + */ +export enum HeatmapSelectionMode { + X = 'x', + Xy = 'xy', + Y = 'y', +} + /** * Controls various color options */ @@ -222,6 +231,10 @@ export interface Options { * Controls tick alignment and value name when not calculating from data */ rowsFrame?: RowsHeatmapOptions; + /** + * Controls which axis to allow selection on + */ + selectionMode?: HeatmapSelectionMode; /** * | *{ * layout: ui.HeatmapCellLayout & "auto" // TODO: fix after remove when https://github.com/grafana/cuetsy/issues/74 is fixed @@ -265,6 +278,7 @@ export const defaultOptions: Partial = { legend: { show: true, }, + selectionMode: HeatmapSelectionMode.X, showValue: ui.VisibilityMode.Auto, tooltip: { mode: ui.TooltipDisplayMode.Single, diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index 8159f3a5a4a..17dd87270a2 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -13,7 +13,7 @@ import { import { AdHocFilterItem } from '../Table/types'; -import { SeriesVisibilityChangeMode } from './types'; +import { OnSelectRangeCallback, SeriesVisibilityChangeMode } from './types'; /** @alpha */ export interface PanelContext { @@ -43,6 +43,12 @@ export interface PanelContext { onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void; onAnnotationDelete?: (id: string) => void; + /** + * Called when a user selects an area on the panel, if defined will override the default behavior of the panel, + * which is to update the time range + */ + onSelectRange?: OnSelectRangeCallback; + /** * Used from visualizations like Table to add ad-hoc filters from cell values */ diff --git a/packages/grafana-ui/src/components/PanelChrome/types.ts b/packages/grafana-ui/src/components/PanelChrome/types.ts index 0d7e74cf781..40701219c7b 100644 --- a/packages/grafana-ui/src/components/PanelChrome/types.ts +++ b/packages/grafana-ui/src/components/PanelChrome/types.ts @@ -8,3 +8,15 @@ export enum SeriesVisibilityChangeMode { ToggleSelection = 'select', AppendToSelection = 'append', } + +export type OnSelectRangeCallback = (selections: RangeSelection2D[]) => void; + +export interface RangeSelection1D { + from: number; + to: number; +} + +export interface RangeSelection2D { + x?: RangeSelection1D; + y?: RangeSelection1D; +} diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 095ccdca520..4ce45e20379 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -7,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { DashboardCursorSync } from '@grafana/schema'; import { useStyles2 } from '../../../themes'; +import { RangeSelection1D, RangeSelection2D, OnSelectRangeCallback } from '../../PanelChrome'; import { getPortalContainer } from '../../Portal/Portal'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; @@ -37,6 +38,8 @@ interface TooltipPlugin2Props { // y-only, via shiftKey clientZoom?: boolean; + onSelectRange?: OnSelectRangeCallback; + render: ( u: uPlot, dataIdxs: Array, @@ -107,6 +110,7 @@ export const TooltipPlugin2 = ({ render, clientZoom = false, queryZoom, + onSelectRange, maxWidth, syncMode = DashboardCursorSync.Off, syncScope = 'global', // eventsScope @@ -319,9 +323,65 @@ export const TooltipPlugin2 = ({ config.addHook('setSelect', (u) => { const isXAxisHorizontal = u.scales.x.ori === 0; + if (!viaSync && (clientZoom || queryZoom != null)) { if (maybeZoomAction(u.cursor!.event)) { - if (clientZoom && yDrag) { + if (onSelectRange != null) { + let selections: RangeSelection2D[] = []; + + const yDrag = Boolean(u.cursor!.drag!.y); + const xDrag = Boolean(u.cursor!.drag!.x); + + let xSel = null; + let ySels: RangeSelection1D[] = []; + + // get x selection + if (xDrag) { + xSel = { + from: isXAxisHorizontal + ? u.posToVal(u.select.left!, 'x') + : u.posToVal(u.select.top + u.select.height, 'x'), + to: isXAxisHorizontal + ? u.posToVal(u.select.left! + u.select.width, 'x') + : u.posToVal(u.select.top, 'x'), + }; + } + + // get y selections + if (yDrag) { + config.scales.forEach((scale) => { + const key = scale.props.scaleKey; + + if (key !== 'x') { + let ySel = { + from: isXAxisHorizontal + ? u.posToVal(u.select.top + u.select.height, key) + : u.posToVal(u.select.left + u.select.width, key), + to: isXAxisHorizontal ? u.posToVal(u.select.top, key) : u.posToVal(u.select.left, key), + }; + + ySels.push(ySel); + } + }); + } + + if (xDrag) { + if (yDrag) { + // x + y + selections = ySels.map((ySel) => ({ x: xSel!, y: ySel })); + } else { + // x only + selections = [{ x: xSel! }]; + } + } else { + if (yDrag) { + // y only + selections = ySels.map((ySel) => ({ y: ySel })); + } + } + + onSelectRange(selections); + } else if (clientZoom && yDrag) { if (u.select.height >= MIN_ZOOM_DIST) { for (let key in u.scales!) { if (key !== 'x') { diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 86471494726..0d32b9d69ad 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -45,7 +45,7 @@ export const HeatmapPanel = ({ }: HeatmapPanelProps) => { const theme = useTheme2(); const styles = useStyles2(getStyles); - const { sync, eventsScope, canAddAnnotations } = usePanelContext(); + const { sync, eventsScope, canAddAnnotations, onSelectRange } = usePanelContext(); const cursorSync = sync?.() ?? DashboardCursorSync.Off; // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 @@ -113,6 +113,7 @@ export const HeatmapPanel = ({ exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)', yAxisConfig: options.yAxis, ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1, + selectionMode: options.selectionMode, }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -179,6 +180,7 @@ export const HeatmapPanel = ({ options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll } queryZoom={onChangeTimeRange} + onSelectRange={onSelectRange} syncMode={cursorSync} syncScope={eventsScope} render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { diff --git a/public/app/plugins/panel/heatmap/panelcfg.cue b/public/app/plugins/panel/heatmap/panelcfg.cue index 2bba82710ad..b0587f4f2ea 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.cue +++ b/public/app/plugins/panel/heatmap/panelcfg.cue @@ -26,6 +26,8 @@ composableKinds: PanelCfg: lineage: { HeatmapColorMode: "opacity" | "scheme" @cuetsy(kind="enum") // Controls the color scale of the heatmap HeatmapColorScale: "linear" | "exponential" @cuetsy(kind="enum") + // Controls which axis to allow selection on + HeatmapSelectionMode: "x" | "y" | "xy" @cuetsy(kind="enum") // Controls various color options HeatmapColorOptions: { // Sets the color mode @@ -155,6 +157,8 @@ composableKinds: PanelCfg: lineage: { exemplars: ExemplarConfig | *{ color: "rgba(255,0,255,0.7)" } + // Controls which axis to allow selection on + selectionMode?: HeatmapSelectionMode & (*"x" | _) } @cuetsy(kind="interface") FieldConfig: { ui.HideableFieldConfig diff --git a/public/app/plugins/panel/heatmap/panelcfg.gen.ts b/public/app/plugins/panel/heatmap/panelcfg.gen.ts index ce34f829840..53d292681f6 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.gen.ts +++ b/public/app/plugins/panel/heatmap/panelcfg.gen.ts @@ -26,6 +26,15 @@ export enum HeatmapColorScale { Linear = 'linear', } +/** + * Controls which axis to allow selection on + */ +export enum HeatmapSelectionMode { + X = 'x', + Xy = 'xy', + Y = 'y', +} + /** * Controls various color options */ @@ -220,6 +229,10 @@ export interface Options { * Controls tick alignment and value name when not calculating from data */ rowsFrame?: RowsHeatmapOptions; + /** + * Controls which axis to allow selection on + */ + selectionMode?: HeatmapSelectionMode; /** * | *{ * layout: ui.HeatmapCellLayout & "auto" // TODO: fix after remove when https://github.com/grafana/cuetsy/issues/74 is fixed @@ -263,6 +276,7 @@ export const defaultOptions: Partial = { legend: { show: true, }, + selectionMode: HeatmapSelectionMode.X, showValue: ui.VisibilityMode.Auto, tooltip: { mode: ui.TooltipDisplayMode.Single, diff --git a/public/app/plugins/panel/heatmap/utils.ts b/public/app/plugins/panel/heatmap/utils.ts index 43692cc0cd1..0ad1167497d 100644 --- a/public/app/plugins/panel/heatmap/utils.ts +++ b/public/app/plugins/panel/heatmap/utils.ts @@ -18,7 +18,7 @@ import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/tra import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; import { HeatmapData } from './fields'; -import { FieldConfig, YAxisConfig } from './types'; +import { FieldConfig, HeatmapSelectionMode, YAxisConfig } from './types'; interface PathbuilderOpts { each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; @@ -51,10 +51,22 @@ interface PrepConfigOpts { hideGE?: number; yAxisConfig: YAxisConfig; ySizeDivisor?: number; + selectionMode?: HeatmapSelectionMode; } export function prepConfig(opts: PrepConfigOpts) { - const { dataRef, theme, timeZone, getTimeRange, cellGap, hideLE, hideGE, yAxisConfig, ySizeDivisor } = opts; + const { + dataRef, + theme, + timeZone, + getTimeRange, + cellGap, + hideLE, + hideGE, + yAxisConfig, + ySizeDivisor, + selectionMode = HeatmapSelectionMode.X, + } = opts; const xScaleKey = 'x'; let isTime = true; @@ -449,10 +461,13 @@ export function prepConfig(opts: PrepConfigOpts) { scaleKey: '', // facets' scales used (above) }); + const dragX = selectionMode === HeatmapSelectionMode.X || selectionMode === HeatmapSelectionMode.Xy; + const dragY = selectionMode === HeatmapSelectionMode.Y || selectionMode === HeatmapSelectionMode.Xy; + const cursor: Cursor = { drag: { - x: true, - y: false, + x: dragX, + y: dragY, setScale: false, }, dataIdx: (u, seriesIdx) => { diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx index ee801a8dd4d..945af93ca6c 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx @@ -125,38 +125,70 @@ export const AnnotationsPlugin2 = ({ const ctx = u.ctx; - let y0 = u.bbox.top; - let y1 = y0 + u.bbox.height; - ctx.save(); ctx.beginPath(); ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); ctx.clip(); - ctx.lineWidth = 2; - ctx.setLineDash([5, 5]); - annos.forEach((frame) => { let vals = getVals(frame); - for (let i = 0; i < vals.time.length; i++) { - let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + if (frame.name === 'xymark') { + // xMin, xMax, yMin, yMax, color, lineWidth, lineStyle, fillOpacity, text - let x0 = u.valToPos(vals.time[i], 'x', true); + let xKey = config.scales[0].props.scaleKey; + let yKey = config.scales[1].props.scaleKey; - if (!vals.isRegion?.[i]) { - renderLine(ctx, y0, y1, x0, color); - // renderUpTriangle(ctx, x0, y1, 8 * uPlot.pxRatio, 5 * uPlot.pxRatio, color); - } else if (canvasRegionRendering) { - renderLine(ctx, y0, y1, x0, color); + for (let i = 0; i < frame.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); - let x1 = u.valToPos(vals.timeEnd[i], 'x', true); + 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); - renderLine(ctx, y0, y1, x1, color); + ctx.fillStyle = colorManipulator.alpha(color, vals.fillOpacity[i]); + ctx.fillRect(x0, y0, x1 - x0, y1 - y0); - ctx.fillStyle = colorManipulator.alpha(color, 0.1); - ctx.fillRect(x0, y0, x1 - x0, u.bbox.height); + 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 { + let y0 = u.bbox.top; + let y1 = y0 + u.bbox.height; + + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + + for (let i = 0; i < vals.time.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + + let x0 = u.valToPos(vals.time[i], 'x', true); + + if (!vals.isRegion?.[i]) { + renderLine(ctx, y0, y1, x0, color); + // renderUpTriangle(ctx, x0, y1, 8 * uPlot.pxRatio, 5 * uPlot.pxRatio, color); + } else if (canvasRegionRendering) { + renderLine(ctx, y0, y1, x0, color); + + let x1 = u.valToPos(vals.timeEnd[i], 'x', true); + + renderLine(ctx, y0, y1, x1, color); + + ctx.fillStyle = colorManipulator.alpha(color, 0.1); + ctx.fillRect(x0, y0, x1 - x0, u.bbox.height); + } } } });