AnnotationsPlugin2: Implement support for rectangular annotations in Heatmap (#88107)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Andre Pereira 2024-06-05 22:48:37 +01:00 committed by GitHub
parent 2403665998
commit 277067ac9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 184 additions and 25 deletions

View File

@ -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<Options> = {
legend: {
show: true,
},
selectionMode: HeatmapSelectionMode.X,
showValue: ui.VisibilityMode.Auto,
tooltip: {
mode: ui.TooltipDisplayMode.Single,

View File

@ -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
*/

View File

@ -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;
}

View File

@ -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<number | null>,
@ -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') {

View File

@ -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) => {

View File

@ -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

View File

@ -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<Options> = {
legend: {
show: true,
},
selectionMode: HeatmapSelectionMode.X,
showValue: ui.VisibilityMode.Auto,
tooltip: {
mode: ui.TooltipDisplayMode.Single,

View File

@ -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) => {

View File

@ -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);
}
}
}
});