mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
180bff77a4
commit
7359ba44d0
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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';
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user