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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 431 additions and 131 deletions

View File

@ -10,7 +10,7 @@ import { useStyles } from '../../themes';
export interface SeriesTableRowProps {
color?: string;
label?: string;
value: string | GraphSeriesValue;
value?: string | GraphSeriesValue;
isActive?: boolean;
}
@ -46,7 +46,10 @@ const getSeriesTableRowStyles = (theme: GrafanaTheme) => {
};
};
const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, isActive }) => {
/**
* @public
*/
export const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, isActive }) => {
const styles = useStyles(getSeriesTableRowStyles);
return (
@ -56,8 +59,8 @@ const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, is
<SeriesIcon color={color} className={styles.icon} />
</div>
)}
<div className={cx(styles.seriesTableCell, styles.label)}>{label}</div>
<div className={cx(styles.seriesTableCell, styles.value)}>{value}</div>
{label && <div className={cx(styles.seriesTableCell, styles.label)}>{label}</div>}
{value && <div className={cx(styles.seriesTableCell, styles.value)}>{value}</div>}
</div>
);
};

View File

@ -11,7 +11,7 @@ import { Dimensions2D, GrafanaTheme2 } from '@grafana/data';
export interface VizTooltipContainerProps extends HTMLAttributes<HTMLDivElement> {
position: { x: number; y: number };
offset: { x: number; y: number };
children?: JSX.Element;
children?: React.ReactNode;
}
/**

View File

@ -1,4 +1,4 @@
export { VizTooltip, VizTooltipContentProps, VizTooltipProps, ActiveDimensions } from './VizTooltip';
export { VizTooltipContainer, VizTooltipContainerProps } from './VizTooltipContainer';
export { SeriesTable, SeriesTableProps, SeriesTableRowProps } from './SeriesTable';
export { SeriesTable, SeriesTableRow, SeriesTableProps, SeriesTableRowProps } from './SeriesTable';
export { TooltipDisplayMode, VizTooltipOptions } from './models.gen';

View File

@ -79,6 +79,7 @@ export {
VizTooltipOptions,
TooltipDisplayMode,
SeriesTableProps,
SeriesTableRow,
SeriesTableRowProps,
} from './VizTooltip';
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
@ -239,6 +240,7 @@ export { PlotLegend } from './uPlot/PlotLegend';
export * from './uPlot/geometries';
export * from './uPlot/plugins';
export { usePlotContext } from './uPlot/context';
export { PlotTooltipInterpolator } from './uPlot/types';
export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG';
export { TimeSeries } from './TimeSeries/TimeSeries';
export { useGraphNGContext } from './GraphNG/hooks';

View File

@ -1,10 +1,5 @@
import uPlot, { Cursor, Band, Hooks, Select } from 'uplot';
import { defaultsDeep } from 'lodash';
import { PlotConfig, TooltipInterpolator } from '../types';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { AxisPlacement } from '../config';
import {
DataFrame,
DefaultTimeZone,
@ -14,6 +9,11 @@ import {
TimeRange,
TimeZone,
} from '@grafana/data';
import { PlotConfig, PlotTooltipInterpolator } from '../types';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { AxisPlacement } from '../config';
import { pluginLog } from '../utils';
import { getThresholdsDrawHook, UPlotThresholdOptions } from './UPlotThresholds';
@ -36,7 +36,7 @@ export class UPlotConfigBuilder {
* Custom handler for closest datapoint and series lookup. Technicaly returns uPlots setCursor hook
* that sets tooltips state.
*/
tooltipInterpolator: TooltipInterpolator | undefined = undefined;
tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
constructor(timeZone: TimeZone = DefaultTimeZone) {
this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
@ -131,7 +131,7 @@ export class UPlotConfigBuilder {
this.bands.push(band);
}
setTooltipInterpolator(interpolator: TooltipInterpolator) {
setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
this.tooltipInterpolator = interpolator;
}

View File

@ -4,6 +4,7 @@ import { usePlotContext } from '../context';
import {
CartesianCoords2D,
DataFrame,
FALLBACK_COLOR,
FieldType,
formattedValueToString,
getDisplayProcessor,
@ -17,10 +18,13 @@ import { useTheme2 } from '../../../themes/ThemeContext';
import uPlot from 'uplot';
interface TooltipPluginProps {
mode?: TooltipDisplayMode;
timeZone: TimeZone;
data: DataFrame;
config: UPlotConfigBuilder;
mode?: TooltipDisplayMode;
// Allows custom tooltip content rendering. Exposes aligned data frame with relevant indexes for data inspection
// Use field.state.origin indexes from alignedData frame field to get access to original data frame and field index.
renderTooltip?: (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => React.ReactNode;
}
const TOOLTIP_OFFSET = 10;
@ -32,6 +36,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
mode = TooltipDisplayMode.Single,
timeZone,
config,
renderTooltip,
...otherProps
}) => {
const theme = useTheme2();
@ -39,6 +44,8 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
const plotInstance = plotCtx.plot;
const pluginId = `TooltipPlugin`;
// Debug logs
@ -109,8 +116,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
}
}, [plotCtx, config, setFocusedPointIdx, setFocusedSeriesIdx, setCoords]);
const plotInstance = plotCtx.plot;
if (!plotInstance || focusedPointIdx === null) {
if (!plotInstance || focusedPointIdx === null || mode === TooltipDisplayMode.None) {
return null;
}
@ -120,61 +126,62 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
let tooltip = null;
let tooltip: React.ReactNode = null;
const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
// when interacting with a point in single mode
if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
const field = otherProps.data.fields[focusedSeriesIdx];
const plotSeries = plotInstance.series;
if (!renderTooltip) {
// when interacting with a point in single mode
if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
const field = otherProps.data.fields[focusedSeriesIdx];
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const value = fieldFmt(field.values.get(focusedPointIdx));
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const display = fieldFmt(field.values.get(focusedPointIdx));
tooltip = (
<SeriesTable
series={[
{
// TODO: align with uPlot typings
color: (plotSeries[focusedSeriesIdx!].stroke as any)(),
label: getFieldDisplayName(field, otherProps.data),
value: value ? formattedValueToString(value) : null,
},
]}
timestamp={xVal}
/>
);
}
if (mode === TooltipDisplayMode.Multi) {
let series: SeriesTableRowProps[] = [];
const plotSeries = plotInstance.series;
for (let i = 0; i < plotSeries.length; i++) {
const frame = otherProps.data;
const field = frame.fields[i];
if (
field === xField ||
field.type === FieldType.time ||
field.type !== FieldType.number ||
field.config.custom?.hideFrom?.tooltip
) {
continue;
}
const value = field.display!(otherProps.data.fields[i].values.get(focusedPointIdx));
series.push({
// TODO: align with uPlot typings
color: (plotSeries[i].stroke as any)!(),
label: getFieldDisplayName(field, frame),
value: value ? formattedValueToString(value) : null,
isActive: focusedSeriesIdx === i,
});
tooltip = (
<SeriesTable
series={[
{
color: display.color || FALLBACK_COLOR,
label: getFieldDisplayName(field, otherProps.data),
value: display ? formattedValueToString(display) : null,
},
]}
timestamp={xVal}
/>
);
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
if (mode === TooltipDisplayMode.Multi) {
let series: SeriesTableRowProps[] = [];
const plotSeries = plotInstance.series;
for (let i = 0; i < plotSeries.length; i++) {
const frame = otherProps.data;
const field = frame.fields[i];
if (
field === xField ||
field.type === FieldType.time ||
field.type !== FieldType.number ||
field.config.custom?.hideFrom?.tooltip
) {
continue;
}
const display = field.display!(otherProps.data.fields[i].values.get(focusedPointIdx));
series.push({
color: display.color || FALLBACK_COLOR,
label: getFieldDisplayName(field, frame),
value: display ? formattedValueToString(display) : null,
isActive: focusedSeriesIdx === i,
});
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
}
} else {
tooltip = renderTooltip(otherProps.data, focusedSeriesIdx, focusedPointIdx);
}
return (

View File

@ -28,7 +28,10 @@ export abstract class PlotConfigBuilder<P, T> {
abstract getConfig(): T;
}
export type TooltipInterpolator = (
/**
* @alpha
*/
export type PlotTooltipInterpolator = (
updateActiveSeriesIdx: (sIdx: number | null) => void,
updateActiveDatapointIdx: (dIdx: number | null) => void,
updateTooltipPosition: (clear?: boolean) => void

View File

@ -1,17 +1,6 @@
import { OptionsWithTextFormatting } from '../models.gen';
import { PanelOptionsEditorBuilder } from '@grafana/data';
/**
* Explicit control for visualization text settings
* @public
**/
export interface VizTextDisplayOptions {
/* Explicit title text size */
titleSize?: number;
/* Explicit value text size */
valueSize?: number;
}
/**
* Adds common text control options to a visualization options
* @param builder

View File

@ -1,7 +1,21 @@
import { OptionsWithTooltip } from '../models.gen';
import { PanelOptionsEditorBuilder } from '@grafana/data';
export function addTooltipOptions<T extends OptionsWithTooltip>(builder: PanelOptionsEditorBuilder<T>) {
export function addTooltipOptions<T extends OptionsWithTooltip>(
builder: PanelOptionsEditorBuilder<T>,
singleOnly = false
) {
const options = singleOnly
? [
{ value: 'single', label: 'Single' },
{ value: 'none', label: 'Hidden' },
]
: [
{ value: 'single', label: 'Single' },
{ value: 'multi', label: 'All' },
{ value: 'none', label: 'Hidden' },
];
builder.addRadio({
path: 'tooltip.mode',
name: 'Tooltip mode',
@ -9,11 +23,7 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(builder: PanelOp
description: '',
defaultValue: 'single',
settings: {
options: [
{ value: 'single', label: 'Single' },
{ value: 'multi', label: 'All' },
{ value: 'none', label: 'Hidden' },
],
options,
},
});
}

View File

@ -1,7 +1,16 @@
// TODO: this should be generated with cue
import { VizLegendOptions, VizTooltipOptions } from '../components';
import { VizTextDisplayOptions } from './builder/text';
/**
* Explicit control for visualization text settings
* @public
**/
export interface VizTextDisplayOptions {
/* Explicit title text size */
titleSize?: number;
/* Explicit value text size */
valueSize?: number;
}
/**
* @public

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;