Tooltip: Improved Timeseries and Candlestick tooltips (#75841)

This commit is contained in:
Adela Almasan 2023-12-13 16:34:56 -06:00 committed by GitHub
parent 23b4568597
commit d4b75928ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 360 additions and 130 deletions

View File

@ -1,21 +1,24 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FALLBACK_COLOR, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { ColorIndicator } from './types';
import { ColorIndicator, DEFAULT_COLOR_INDICATOR } from './types';
import { getColorIndicatorClass } from './utils';
interface Props {
color: string;
colorIndicator: ColorIndicator;
color?: string;
colorIndicator?: ColorIndicator;
}
export type ColorIndicatorStyles = ReturnType<typeof getStyles>;
export const VizTooltipColorIndicator = ({ color, colorIndicator = ColorIndicator.value }: Props) => {
export const VizTooltipColorIndicator = ({
color = FALLBACK_COLOR,
colorIndicator = DEFAULT_COLOR_INDICATOR,
}: Props) => {
const styles = useStyles2(getStyles);
return (

View File

@ -19,7 +19,7 @@ export const VizTooltipContent = ({ contentLabelValue, customContent }: Props) =
return (
<div className={styles.wrapper}>
<div>
{contentLabelValue?.map((labelValue, i) => {
{contentLabelValue.map((labelValue, i) => {
const { label, value, color, colorIndicator, colorPlacement, isActive } = labelValue;
return (
<VizTooltipRow
@ -29,7 +29,6 @@ export const VizTooltipContent = ({ contentLabelValue, customContent }: Props) =
color={color}
colorIndicator={colorIndicator}
colorPlacement={colorPlacement}
colorFirst={false}
isActive={isActive}
justify={'space-between'}
/>

View File

@ -16,7 +16,6 @@ export const VizTooltipHeaderLabelValue = ({ keyValuePairs }: Props) => (
value={keyValuePair.value}
color={keyValuePair.color}
colorIndicator={keyValuePair.colorIndicator!}
colorFirst={false}
justify={'space-between'}
/>
))}

View File

@ -11,7 +11,6 @@ import { ColorPlacement, LabelValue } from './types';
interface Props extends LabelValue {
justify?: string;
colorFirst?: boolean;
isActive?: boolean; // for series list
marginRight?: string;
}
@ -21,9 +20,8 @@ export const VizTooltipRow = ({
value,
color,
colorIndicator,
colorPlacement = ColorPlacement.leading,
colorPlacement = ColorPlacement.first,
justify = 'flex-start',
colorFirst = true,
isActive = false,
marginRight = '0px',
}: Props) => {
@ -52,7 +50,9 @@ export const VizTooltipRow = ({
<div className={styles.contentWrapper}>
{(color || label) && (
<div className={styles.valueWrapper}>
{color && colorFirst && <VizTooltipColorIndicator color={color} colorIndicator={colorIndicator!} />}
{color && colorPlacement === ColorPlacement.first && (
<VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} />
)}
<Tooltip content={label} interactive={false} show={showLabelTooltip}>
<div
className={cx(styles.label, isActive && styles.activeSeries)}
@ -66,18 +66,18 @@ export const VizTooltipRow = ({
)}
<div className={styles.valueWrapper}>
{color && !colorFirst && colorPlacement === ColorPlacement.leading && (
<VizTooltipColorIndicator color={color} colorIndicator={colorIndicator!} />
{color && colorPlacement === ColorPlacement.leading && (
<VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} />
)}
<Tooltip content={value ? value.toString() : ''} interactive={false} show={showValueTooltip}>
<div className={cx(styles.value, isActive)} onMouseEnter={onMouseEnterValue} onMouseLeave={onMouseLeaveValue}>
{value}
</div>
</Tooltip>
{color && !colorFirst && colorPlacement === ColorPlacement.trailing && (
{color && colorPlacement === ColorPlacement.trailing && (
<>
&nbsp;
<VizTooltipColorIndicator color={color} colorIndicator={colorIndicator!} />
<VizTooltipColorIndicator color={color} colorIndicator={colorIndicator} />
</>
)}
</div>

View File

@ -12,6 +12,7 @@ export enum ColorIndicator {
export enum ColorPlacement {
hidden = 'hidden',
first = 'first',
leading = 'leading',
trailing = 'trailing',
}
@ -24,3 +25,5 @@ export interface LabelValue {
colorPlacement?: ColorPlacement;
isActive?: boolean;
}
export const DEFAULT_COLOR_INDICATOR = ColorIndicator.series;

View File

@ -10,6 +10,8 @@ import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { CloseButton } from './CloseButton';
export const DEFAULT_TOOLTIP_WIDTH = 280;
// todo: barchart? histogram?
export const enum TooltipHoverMode {
// Single mode in TimeSeries, Candlestick, Trend, StateTimeline, Heatmap?

View File

@ -7,12 +7,14 @@ import uPlot from 'uplot';
import { Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { TooltipPlugin, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui';
import { TooltipPlugin, TooltipPlugin2, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { config } from 'app/core/config';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
@ -242,7 +244,7 @@ export const CandlestickPanel = ({
tweakScale={tweakScale}
options={options}
>
{(config, alignedDataFrame) => {
{(uplotConfig, alignedDataFrame) => {
alignedDataFrame.fields.forEach((field) => {
field.getLinks = getLinksSupplier(
alignedDataFrame,
@ -255,73 +257,100 @@ export const CandlestickPanel = ({
return (
<>
<ZoomPlugin config={config} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
data={alignedDataFrame}
config={config}
mode={TooltipDisplayMode.Multi}
sync={sync}
timeZone={timeZone}
/>
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
)}
{/* Enables annotations creation*/}
{enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={config}>
{({ startAnnotating }) => {
{config.featureToggles.newVizTooltips ? (
<TooltipPlugin2
config={uplotConfig}
hoverMode={TooltipHoverMode.xAll}
queryZoom={onChangeTimeRange}
clientZoom={true}
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={
enableAnnotationCreation
? [
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
},
},
],
},
]
: []
}
<TimeSeriesTooltip
frames={[info.frame]}
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={TooltipDisplayMode.Multi}
isPinned={isPinned}
/>
);
}}
</AnnotationEditorPlugin>
) : (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
) : (
<>
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
data={alignedDataFrame}
config={uplotConfig}
mode={TooltipDisplayMode.Multi}
sync={sync}
timeZone={timeZone}
/>
</>
)}
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
)}
{/* Enables annotations creation*/}
{!config.featureToggles.newVizTooltips ? (
enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={uplotConfig}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={
enableAnnotationCreation
? [
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
},
},
],
},
]
: []
}
/>
);
}}
</AnnotationEditorPlugin>
) : (
<ContextMenuPlugin
data={alignedDataFrame}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)
) : undefined}
{data.annotations && (
<ExemplarsPlugin config={uplotConfig} exemplars={data.annotations} timeZone={timeZone} />
)}
{data.annotations && <ExemplarsPlugin config={config} exemplars={data.annotations} timeZone={timeZone} />}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
config={config}
config={uplotConfig}
fieldConfig={fieldConfig}
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
<OutsideRangePlugin config={config} onChangeTimeRange={onChangeTimeRange} />
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -3,10 +3,12 @@ import React, { useMemo } from 'react';
import { PanelProps, DataFrameType } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { KeyboardPlugin, TooltipPlugin, usePanelContext, ZoomPlugin } from '@grafana/ui';
import { KeyboardPlugin, TooltipPlugin, TooltipPlugin2, usePanelContext, ZoomPlugin } from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { config } from 'app/core/config';
import { TimeSeriesTooltip } from './TimeSeriesTooltip';
import { Options } from './panelcfg.gen';
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
@ -74,7 +76,7 @@ export const TimeSeriesPanel = ({
legend={options.legend}
options={options}
>
{(config, alignedDataFrame) => {
{(uplotConfig, alignedDataFrame) => {
if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) {
alignedDataFrame = regenerateLinksSupplier(
alignedDataFrame,
@ -87,68 +89,98 @@ export const TimeSeriesPanel = ({
return (
<>
<KeyboardPlugin config={config} />
<ZoomPlugin config={config} onZoom={onChangeTimeRange} withZoomY={true} />
<KeyboardPlugin config={uplotConfig} />
{options.tooltip.mode === TooltipDisplayMode.None || (
<TooltipPlugin
frames={frames}
data={alignedDataFrame}
config={config}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
sync={sync}
timeZone={timeZone}
/>
<>
{config.featureToggles.newVizTooltips ? (
<TooltipPlugin2
config={uplotConfig}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
queryZoom={onChangeTimeRange}
clientZoom={true}
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
return (
<TimeSeriesTooltip
frames={frames}
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
/>
);
}}
/>
) : (
<>
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
frames={frames}
data={alignedDataFrame}
config={uplotConfig}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
sync={sync}
timeZone={timeZone}
/>
</>
)}
</>
)}
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
)}
{/* Enables annotations creation*/}
{enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={config}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
{/*Enables annotations creation*/}
{!config.featureToggles.newVizTooltips ? (
enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={uplotConfig}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
},
},
},
],
},
]}
/>
);
}}
</AnnotationEditorPlugin>
) : (
<ContextMenuPlugin
data={alignedDataFrame}
frames={frames}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)}
],
},
]}
/>
);
}}
</AnnotationEditorPlugin>
) : (
<ContextMenuPlugin
data={alignedDataFrame}
frames={frames}
config={uplotConfig}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)
) : undefined}
{data.annotations && (
<ExemplarsPlugin
visibleSeries={getVisibleLabels(config, frames)}
config={config}
visibleSeries={getVisibleLabels(uplotConfig, frames)}
config={uplotConfig}
exemplars={data.annotations}
timeZone={timeZone}
/>
@ -156,13 +188,13 @@ export const TimeSeriesPanel = ({
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
config={config}
config={uplotConfig}
fieldConfig={fieldConfig}
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
<OutsideRangePlugin config={config} onChangeTimeRange={onChangeTimeRange} />
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}

View File

@ -0,0 +1,162 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataFrame,
FALLBACK_COLOR,
FieldType,
GrafanaTheme2,
formattedValueToString,
getDisplayProcessor,
LinkModel,
Field,
getFieldDisplayName,
arrayUtils,
} from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { DEFAULT_TOOLTIP_WIDTH } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { getDataLinks } from '../status-history/utils';
// exemplar / annotation / time region hovering?
// add annotation UI / alert dismiss UI?
interface TimeSeriesTooltipProps {
frames?: DataFrame[];
// aligned series frame
seriesFrame: DataFrame;
// hovered points
dataIdxs: Array<number | null>;
// closest/hovered series
seriesIdx?: number | null;
mode?: TooltipDisplayMode;
sortOrder?: SortOrder;
isPinned: boolean;
}
export const TimeSeriesTooltip = ({
frames,
seriesFrame,
dataIdxs,
seriesIdx,
mode = TooltipDisplayMode.Single,
sortOrder = SortOrder.None,
isPinned,
}: TimeSeriesTooltipProps) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const xField = seriesFrame.fields[0];
if (!xField) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, theme });
let xVal = xFieldFmt(xField!.values[dataIdxs[0]!]).text;
let links: Array<LinkModel<Field>> = [];
let contentLabelValue: LabelValue[] = [];
// Single mode
if (mode === TooltipDisplayMode.Single || isPinned) {
const field = seriesFrame.fields[seriesIdx!];
if (!field) {
return null;
}
const dataIdx = dataIdxs[seriesIdx!]!;
xVal = xFieldFmt(xField!.values[dataIdx]).text;
const fieldFmt = field.display || getDisplayProcessor({ field, theme });
const display = fieldFmt(field.values[dataIdx]);
links = getDataLinks(field, dataIdx);
contentLabelValue = [
{
label: getFieldDisplayName(field, seriesFrame, frames),
value: display ? formattedValueToString(display) : null,
color: display.color || FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
},
];
}
if (mode === TooltipDisplayMode.Multi && !isPinned) {
const fields = seriesFrame.fields;
const sortIdx: unknown[] = [];
for (let i = 0; i < fields.length; i++) {
const field = seriesFrame.fields[i];
if (
!field ||
field === xField ||
field.type === FieldType.time ||
field.type !== FieldType.number ||
field.config.custom?.hideFrom?.tooltip ||
field.config.custom?.hideFrom?.viz
) {
continue;
}
const v = seriesFrame.fields[i].values[dataIdxs[i]!];
const display = field.display!(v); // super expensive :(
sortIdx.push(v);
contentLabelValue.push({
label: field.state?.displayName ?? field.name,
value: display ? formattedValueToString(display) : null,
color: display.color || FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
isActive: seriesIdx === i,
});
if (sortOrder !== SortOrder.None) {
// create sort reference series array, as Array.sort() mutates the original array
const sortRef = [...contentLabelValue];
const sortFn = arrayUtils.sortValues(sortOrder);
contentLabelValue.sort((a, b) => {
// get compared values indices to retrieve raw values from sortIdx
const aIdx = sortRef.indexOf(a);
const bIdx = sortRef.indexOf(b);
return sortFn(sortIdx[aIdx], sortIdx[bIdx]);
});
}
}
}
const getHeaderLabel = (): LabelValue => {
return {
label: '',
value: xVal,
};
};
const getContentLabelValue = () => {
return contentLabelValue;
};
return (
<div>
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} />
<VizTooltipContent contentLabelValue={getContentLabelValue()} />
{isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={false} />}
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: DEFAULT_TOOLTIP_WIDTH,
}),
});

View File

@ -20,6 +20,7 @@ import { SeriesList } from '@grafana/ui/src/components/VizTooltip/SeriesList';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
import { LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { DEFAULT_TOOLTIP_WIDTH } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
interface TrendTooltipProps {
frames?: DataFrame[];
@ -168,6 +169,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '280px',
width: DEFAULT_TOOLTIP_WIDTH,
}),
});