Timeline/Status grid panel: Add tooltip support (#35005)

* Timeline/Status grid tooltip support first pass

* Tooltips workin

* Use getValueFormat to get the duration

* Separate boxes highlight from tooltip interpolation

* Separate state timeline tooltip component, rely on field display color to retrieve color of series

* create an onHover/onLeave API and optimize implementation

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Dominik Prokop
2021-06-03 04:43:47 +02:00
committed by GitHub
parent 180bff77a4
commit 7359ba44d0
23 changed files with 431 additions and 131 deletions

View File

@@ -1,11 +1,9 @@
import uPlot, { Axis, Series } from 'uplot';
import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { TooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
import { CartesianCoords2D, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, measureText } from '@grafana/ui';
import { VizTextDisplayOptions } from '@grafana/ui/src/options/builder';
import { calculateFontSize, measureText, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui';
const groupDistr = SPACE_BETWEEN;
const barDistr = SPACE_BETWEEN;
@@ -311,7 +309,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
};
// handle hover interaction with quadtree probing
const interpolateBarChartTooltip: TooltipInterpolator = (
const interpolateTooltip: PlotTooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
@@ -368,7 +366,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
// hooks
init,
drawClear,
interpolateBarChartTooltip,
interpolateTooltip,
};
}

View File

@@ -80,7 +80,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
builder.addHook('drawClear', config.drawClear);
builder.addHook('draw', config.draw);
builder.setTooltipInterpolator(config.interpolateBarChartTooltip);
builder.setTooltipInterpolator(config.interpolateTooltip);
builder.addScale({
scaleKey: 'x',

View File

@@ -1,9 +1,10 @@
import React, { useMemo } from 'react';
import { PanelProps } from '@grafana/data';
import { useTheme2, ZoomPlugin } from '@grafana/ui';
import React, { useCallback, useMemo } from 'react';
import { DataFrame, PanelProps } from '@grafana/data';
import { TooltipPlugin, useTheme2, ZoomPlugin } from '@grafana/ui';
import { TimelineMode, TimelineOptions } from './types';
import { TimelineChart } from './TimelineChart';
import { prepareTimelineFields, prepareTimelineLegendItems } from './utils';
import { StateTimelineTooltip } from './StateTimelineTooltip';
interface TimelinePanelProps extends PanelProps<TimelineOptions> {}
@@ -32,6 +33,26 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
theme,
]);
const renderCustomTooltip = useCallback(
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
// Not caring about multi mode in StateTimeline
if (seriesIdx === null || datapointIdx === null) {
return null;
}
return (
<StateTimelineTooltip
data={data.series}
alignedData={alignedData}
seriesIdx={seriesIdx}
datapointIdx={datapointIdx}
timeZone={timeZone}
/>
);
},
[timeZone, data]
);
if (!frames || warn) {
return (
<div className="panel-empty">
@@ -51,10 +72,22 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
height={height}
legendItems={legendItems}
{...options}
// hardcoded
mode={TimelineMode.Changes}
>
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
{(config, alignedFrame) => {
return (
<>
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
<TooltipPlugin
data={alignedFrame}
config={config}
mode={options.tooltip.mode}
timeZone={timeZone}
renderTooltip={renderCustomTooltip}
/>
</>
);
}}
</TimelineChart>
);
};

View File

@@ -0,0 +1,85 @@
import React from 'react';
import {
DataFrame,
FALLBACK_COLOR,
formattedValueToString,
getDisplayProcessor,
getFieldDisplayName,
getValueFormat,
TimeZone,
} from '@grafana/data';
import { SeriesTableRow, useTheme2 } from '@grafana/ui';
import { findNextStateIndex } from './utils';
interface StateTimelineTooltipProps {
data: DataFrame[];
alignedData: DataFrame;
seriesIdx: number;
datapointIdx: number;
timeZone: TimeZone;
}
export const StateTimelineTooltip: React.FC<StateTimelineTooltipProps> = ({
data,
alignedData,
seriesIdx,
datapointIdx,
timeZone,
}) => {
const theme = useTheme2();
const xField = alignedData.fields[0];
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
const field = alignedData.fields[seriesIdx!];
const dataFrameFieldIndex = field.state?.origin;
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const value = field.values.get(datapointIdx!);
const display = fieldFmt(value);
const fieldDisplayName = dataFrameFieldIndex
? getFieldDisplayName(
data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex],
data[dataFrameFieldIndex.frameIndex],
data
)
: null;
const nextStateIdx = findNextStateIndex(field, datapointIdx!);
let nextStateTs;
if (nextStateIdx) {
nextStateTs = xField.values.get(nextStateIdx!);
}
const stateTs = xField.values.get(datapointIdx!);
let toFragment = null;
let durationFragment = null;
if (nextStateTs) {
const duration = nextStateTs && formattedValueToString(getValueFormat('dtdurationms')(nextStateTs - stateTs, 0));
durationFragment = (
<>
<br />
<strong>Duration:</strong> {duration}
</>
);
toFragment = (
<>
{' to'} <strong>{xFieldFmt(xField.values.get(nextStateIdx!)).text}</strong>
</>
);
}
return (
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
{fieldDisplayName}
<br />
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
From <strong>{xFieldFmt(xField.values.get(datapointIdx!)).text}</strong>
{toFragment}
{durationFragment}
</div>
);
};
StateTimelineTooltip.displayName = 'StateTimelineTooltip';

View File

@@ -13,12 +13,14 @@ import {
} from '@grafana/ui';
import { DataFrame, FieldType, TimeRange } from '@grafana/data';
import { preparePlotConfigBuilder } from './utils';
import { TimelineMode, TimelineValueAlignment } from './types';
import { TimelineMode, TimelineOptions, TimelineValueAlignment } from './types';
/**
* @alpha
*/
export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
export interface TimelineProps
extends TimelineOptions,
Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
mode: TimelineMode;
rowHeight: number;
showValue: BarValueVisibility;

View File

@@ -1,8 +1,7 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { StateTimelinePanel } from './StateTimelinePanel';
import { TimelineOptions, TimelineFieldConfig, defaultPanelOptions, defaultTimelineFieldConfig } from './types';
import { BarValueVisibility } from '@grafana/ui';
import { addLegendOptions } from '@grafana/ui/src/options/builder';
import { BarValueVisibility, commonOptionsBuilder } from '@grafana/ui';
import { timelinePanelChangedHandler } from './migrations';
export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(StateTimelinePanel)
@@ -84,5 +83,6 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
defaultValue: defaultPanelOptions.rowHeight,
});
addLegendOptions(builder, false);
commonOptionsBuilder.addLegendOptions(builder, false);
commonOptionsBuilder.addTooltipOptions(builder, true);
});

View File

@@ -1,6 +1,6 @@
import uPlot, { Series, Cursor } from 'uplot';
import uPlot, { Cursor, Series } from 'uplot';
import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
import { Quadtree, Rect, pointWithin } from 'app/plugins/panel/barchart/quadtree';
import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
import { TimelineFieldConfig, TimelineMode, TimelineValueAlignment } from './types';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
@@ -47,8 +47,8 @@ export interface TimelineCoreOptions {
getTimeRange: () => TimeRange;
formatValue?: (seriesIdx: number, value: any) => string;
getFieldConfig: (seriesIdx: number) => TimelineFieldConfig;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
onHover?: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
onLeave?: () => void;
}
/**
@@ -69,8 +69,8 @@ export function getConfig(opts: TimelineCoreOptions) {
getTimeRange,
getValueColor,
getFieldConfig,
// onHover,
// onLeave,
onHover,
onLeave,
} = opts;
let qt: Quadtree;
@@ -382,16 +382,24 @@ export function getConfig(opts: TimelineCoreOptions) {
hovered[i] = o;
}
let hoveredAtCursor: Rect | null = null;
function hoverMulti(cx: number, cy: number) {
let foundAtCursor: Rect | null = null;
for (let i = 0; i < numSeries; i++) {
let found: Rect | null = null;
if (cx >= 0) {
cy = yMids[i];
let cy2 = yMids[i];
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
qt.get(cx, cy2, 1, 1, (o) => {
if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
found = o;
if (Math.abs(cy - cy2) <= o.h / 2) {
foundAtCursor = o;
}
}
});
}
@@ -404,21 +412,40 @@ export function getConfig(opts: TimelineCoreOptions) {
setHoverMark(i, null);
}
}
if (foundAtCursor) {
if (foundAtCursor !== hoveredAtCursor) {
hoveredAtCursor = foundAtCursor;
// @ts-ignore
onHover && onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
}
} else if (hoveredAtCursor) {
hoveredAtCursor = null;
onLeave && onLeave();
}
}
function hoverOne(cx: number, cy: number) {
let found: Rect | null = null;
let foundAtCursor: Rect | null = null;
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
found = o;
foundAtCursor = o;
}
});
if (found) {
setHoverMark(0, found);
} else if (hovered[0] != null) {
if (foundAtCursor) {
setHoverMark(0, foundAtCursor);
if (foundAtCursor !== hoveredAtCursor) {
hoveredAtCursor = foundAtCursor;
// @ts-ignore
onHover && onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
}
} else if (hoveredAtCursor) {
setHoverMark(0, null);
hoveredAtCursor = null;
onLeave && onLeave();
}
}

View File

@@ -1,9 +1,9 @@
import { HideableFieldConfig, BarValueVisibility, OptionsWithLegend } from '@grafana/ui';
import { HideableFieldConfig, BarValueVisibility, OptionsWithLegend, OptionsWithTooltip } from '@grafana/ui';
/**
* @alpha
*/
export interface TimelineOptions extends OptionsWithLegend {
export interface TimelineOptions extends OptionsWithLegend, OptionsWithTooltip {
mode: TimelineMode; // not in the saved model!
showValue: BarValueVisibility;

View File

@@ -1,5 +1,5 @@
import { FieldType, toDataFrame } from '@grafana/data';
import { prepareTimelineFields } from './utils';
import { ArrayVector, FieldType, toDataFrame } from '@grafana/data';
import { findNextStateIndex, prepareTimelineFields } from './utils';
describe('prepare timeline graph', () => {
it('errors with no time fields', () => {
@@ -58,3 +58,79 @@ describe('prepare timeline graph', () => {
`);
});
});
describe('findNextStateIndex', () => {
it('handles leading datapoint index', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, undefined]),
} as any;
const result = findNextStateIndex(field, 0);
expect(result).toEqual(3);
});
it('handles trailing datapoint index', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3]),
} as any;
const result = findNextStateIndex(field, 5);
expect(result).toEqual(null);
});
it('handles trailing undefined', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3, undefined]),
} as any;
const result = findNextStateIndex(field, 5);
expect(result).toEqual(null);
});
it('handles datapoint index inside range', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([
1,
undefined,
undefined,
3,
undefined,
undefined,
undefined,
undefined,
2,
undefined,
undefined,
]),
} as any;
const result = findNextStateIndex(field, 3);
expect(result).toEqual(8);
});
describe('single data points', () => {
const field = {
name: 'time',
type: FieldType.number,
values: new ArrayVector([1, 3, 2]),
} as any;
test('leading', () => {
const result = findNextStateIndex(field, 0);
expect(result).toEqual(1);
});
test('trailing', () => {
const result = findNextStateIndex(field, 2);
expect(result).toEqual(null);
});
test('inside', () => {
const result = findNextStateIndex(field, 1);
expect(result).toEqual(2);
});
});
});

View File

@@ -26,6 +26,7 @@ import {
import { TimelineCoreOptions, getConfig } from './timeline';
import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
import { TimelineFieldConfig, TimelineOptions } from './types';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
const defaultConfig: TimelineFieldConfig = {
lineWidth: 0,
@@ -95,21 +96,46 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
getTimeRange,
// hardcoded formatter for state values
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
// TODO: unimplemeted for now
onHover: (seriesIdx: number, valueIdx: number) => {
console.log('hover', { seriesIdx, valueIdx });
onHover: (seriesIndex, valueIndex) => {
hoveredSeriesIdx = seriesIndex;
hoveredDataIdx = valueIndex;
},
onLeave: (seriesIdx: number, valueIdx: number) => {
console.log('leave', { seriesIdx, valueIdx });
onLeave: () => {
hoveredSeriesIdx = null;
hoveredDataIdx = null;
},
};
let hoveredSeriesIdx: number | null = null;
let hoveredDataIdx: number | null = null;
const coreConfig = getConfig(opts);
builder.addHook('init', coreConfig.init);
builder.addHook('drawClear', coreConfig.drawClear);
builder.addHook('setCursor', coreConfig.setCursor);
// in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
// which fires after the above setCursor hook, so can take advantage of hoveringOver
// already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
const interpolateTooltip: PlotTooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
) => (u: uPlot) => {
if (hoveredSeriesIdx != null) {
// @ts-ignore
updateActiveSeriesIdx(hoveredSeriesIdx);
// @ts-ignore
updateActiveDatapointIdx(hoveredDataIdx);
updateTooltipPosition();
} else {
updateTooltipPosition(true);
}
};
builder.setTooltipInterpolator(interpolateTooltip);
builder.setCursor(coreConfig.cursor);
builder.addScale({
@@ -366,3 +392,27 @@ function allNonTimeFields(frames: DataFrame[]): Field[] {
}
return fields;
}
export function findNextStateIndex(field: Field, datapointIdx: number) {
let end;
let rightPointer = datapointIdx + 1;
if (rightPointer === field.values.length) {
return null;
}
while (end === undefined) {
if (rightPointer === field.values.length) {
return null;
}
const rightValue = field.values.get(rightPointer);
if (rightValue !== undefined) {
end = rightPointer;
} else {
rightPointer++;
}
}
return end;
}

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { PanelProps } from '@grafana/data';
import { useTheme2, ZoomPlugin } from '@grafana/ui';
import { TooltipPlugin, useTheme2, ZoomPlugin } from '@grafana/ui';
import { StatusPanelOptions } from './types';
import { TimelineChart } from '../state-timeline/TimelineChart';
import { TimelineMode } from '../state-timeline/types';
@@ -64,7 +64,14 @@ export const StatusHistoryPanel: React.FC<TimelinePanelProps> = ({
// hardcoded
mode={TimelineMode.Samples}
>
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
{(config, alignedFrame) => {
return (
<>
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
<TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />
</>
);
}}
</TimelineChart>
);
};

View File

@@ -1,8 +1,7 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { StatusHistoryPanel } from './StatusHistoryPanel';
import { StatusPanelOptions, StatusFieldConfig, defaultStatusFieldConfig } from './types';
import { BarValueVisibility } from '@grafana/ui';
import { addLegendOptions } from '@grafana/ui/src/options/builder';
import { BarValueVisibility, commonOptionsBuilder } from '@grafana/ui';
export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(StatusHistoryPanel)
.useFieldConfig({
@@ -75,5 +74,6 @@ export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(Sta
},
});
addLegendOptions(builder, false);
commonOptionsBuilder.addLegendOptions(builder, false);
commonOptionsBuilder.addTooltipOptions(builder, true);
});

View File

@@ -1,10 +1,9 @@
import { VizLegendOptions, HideableFieldConfig, BarValueVisibility } from '@grafana/ui';
import { HideableFieldConfig, BarValueVisibility, OptionsWithTooltip, OptionsWithLegend } from '@grafana/ui';
/**
* @alpha
*/
export interface StatusPanelOptions {
legend: VizLegendOptions;
export interface StatusPanelOptions extends OptionsWithTooltip, OptionsWithLegend {
showValue: BarValueVisibility;
rowHeight: number;
colWidth?: number;