From 7359ba44d0d7e3001aba9dc31b97d228585fbac1 Mon Sep 17 00:00:00 2001 From: Dominik Prokop <dominik.prokop@grafana.com> Date: Thu, 3 Jun 2021 04:43:47 +0200 Subject: [PATCH] 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> --- .../src/components/VizTooltip/SeriesTable.tsx | 11 +- .../VizTooltip/VizTooltipContainer.tsx | 2 +- .../src/components/VizTooltip/index.tsx | 2 +- packages/grafana-ui/src/components/index.ts | 2 + .../uPlot/config/UPlotConfigBuilder.ts | 14 +-- .../uPlot/plugins/TooltipPlugin.tsx | 109 ++++++++++-------- .../grafana-ui/src/components/uPlot/types.ts | 5 +- .../grafana-ui/src/options/builder/text.tsx | 11 -- .../src/options/builder/tooltip.tsx | 22 +++- packages/grafana-ui/src/options/models.gen.ts | 13 ++- public/app/plugins/panel/barchart/bars.ts | 8 +- public/app/plugins/panel/barchart/utils.ts | 2 +- .../state-timeline/StateTimelinePanel.tsx | 43 ++++++- .../state-timeline/StateTimelineTooltip.tsx | 85 ++++++++++++++ .../panel/state-timeline/TimelineChart.tsx | 6 +- .../plugins/panel/state-timeline/module.tsx | 6 +- .../plugins/panel/state-timeline/timeline.ts | 55 ++++++--- .../app/plugins/panel/state-timeline/types.ts | 4 +- .../panel/state-timeline/utils.test.ts | 80 ++++++++++++- .../app/plugins/panel/state-timeline/utils.ts | 60 +++++++++- .../status-history/StatusHistoryPanel.tsx | 11 +- .../plugins/panel/status-history/module.tsx | 6 +- .../app/plugins/panel/status-history/types.ts | 5 +- 23 files changed, 431 insertions(+), 131 deletions(-) create mode 100644 public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx diff --git a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx index 107f31345ea..df3b00b259b 100644 --- a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx @@ -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> ); }; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index ce444577d13..411693e39cb 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -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; } /** diff --git a/packages/grafana-ui/src/components/VizTooltip/index.tsx b/packages/grafana-ui/src/components/VizTooltip/index.tsx index 534e96f6ee9..fcdd3363b83 100644 --- a/packages/grafana-ui/src/components/VizTooltip/index.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/index.tsx @@ -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'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 21214a8eb10..f900ea39c38 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index e415abc5477..0c9f705c301 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -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; } diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index ce4c8ce6736..fef6f365faf 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -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 ( diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 33d346cf291..7c4de95e6bb 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -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 diff --git a/packages/grafana-ui/src/options/builder/text.tsx b/packages/grafana-ui/src/options/builder/text.tsx index 2f87e8d64a2..2523d45a6a6 100644 --- a/packages/grafana-ui/src/options/builder/text.tsx +++ b/packages/grafana-ui/src/options/builder/text.tsx @@ -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 diff --git a/packages/grafana-ui/src/options/builder/tooltip.tsx b/packages/grafana-ui/src/options/builder/tooltip.tsx index 95ccdb2a40b..a5656da603c 100644 --- a/packages/grafana-ui/src/options/builder/tooltip.tsx +++ b/packages/grafana-ui/src/options/builder/tooltip.tsx @@ -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, }, }); } diff --git a/packages/grafana-ui/src/options/models.gen.ts b/packages/grafana-ui/src/options/models.gen.ts index 45cd632b50a..5fbfb5241d8 100644 --- a/packages/grafana-ui/src/options/models.gen.ts +++ b/packages/grafana-ui/src/options/models.gen.ts @@ -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 diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index 44948594110..47000cdaeb4 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -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, }; } diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index 48d592fa0d7..9c39f0d849c 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -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', diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index e88c140e777..5212f7e0372 100755 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -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> ); }; diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx new file mode 100644 index 00000000000..53eb510aa11 --- /dev/null +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx @@ -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'; diff --git a/public/app/plugins/panel/state-timeline/TimelineChart.tsx b/public/app/plugins/panel/state-timeline/TimelineChart.tsx index bf073ad7701..ae1ef810cc4 100755 --- a/public/app/plugins/panel/state-timeline/TimelineChart.tsx +++ b/public/app/plugins/panel/state-timeline/TimelineChart.tsx @@ -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; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 64648ddcdc4..18ea3c45b6b 100755 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -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); }); diff --git a/public/app/plugins/panel/state-timeline/timeline.ts b/public/app/plugins/panel/state-timeline/timeline.ts index df163655729..ead53e042af 100644 --- a/public/app/plugins/panel/state-timeline/timeline.ts +++ b/public/app/plugins/panel/state-timeline/timeline.ts @@ -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(); } } diff --git a/public/app/plugins/panel/state-timeline/types.ts b/public/app/plugins/panel/state-timeline/types.ts index 4397da50a35..6364dd4a778 100644 --- a/public/app/plugins/panel/state-timeline/types.ts +++ b/public/app/plugins/panel/state-timeline/types.ts @@ -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; diff --git a/public/app/plugins/panel/state-timeline/utils.test.ts b/public/app/plugins/panel/state-timeline/utils.test.ts index fac08d5fed7..0124bd1e60a 100644 --- a/public/app/plugins/panel/state-timeline/utils.test.ts +++ b/public/app/plugins/panel/state-timeline/utils.test.ts @@ -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); + }); + }); +}); diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index a261bbd4e88..f13926898a7 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -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; +} diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 90b5a2f163c..8d2906ff0d4 100755 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -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> ); }; diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx index 8d6893023d9..e83d1239372 100755 --- a/public/app/plugins/panel/status-history/module.tsx +++ b/public/app/plugins/panel/status-history/module.tsx @@ -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); }); diff --git a/public/app/plugins/panel/status-history/types.ts b/public/app/plugins/panel/status-history/types.ts index 8c06f64bc79..b9840d9fe31 100644 --- a/public/app/plugins/panel/status-history/types.ts +++ b/public/app/plugins/panel/status-history/types.ts @@ -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;